Skip to content

fix(arcgis): load feature services by viewport - #1765

Merged
giswqs merged 7 commits into
mainfrom
fix/issue-1756-arcgis-viewport-loading
Aug 8, 2026
Merged

fix(arcgis): load feature services by viewport#1765
giswqs merged 7 commits into
mainfrom
fix/issue-1756-arcgis-viewport-loading

Conversation

@giswqs

@giswqs giswqs commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

  • add ArcGIS feature layers immediately without waiting for the full service download
  • query and progressively render features intersecting the current map viewport
  • cancel stale requests and reload when the map extent changes
  • retain complete paged downloads for headless API consumers

Verification

  • node --import tsx --test tests/arcgis-feature-layer.test.ts
  • npm run build
  • real browser using the Vicmap Parcel FeatureServer from the issue
  • verified viewport request cancellation and reload while zooming and panning
  • verified rendering in light and dark themes

Fixes #1756

Summary by CodeRabbit

  • New Features

    • ArcGIS feature layers appear immediately while visible features load dynamically for the current viewport.
    • Map movements refresh displayed features incrementally, including across the international date line.
    • Saved projects restore viewport-based ArcGIS layers automatically.
    • Layers can be reloaded for the current viewport.
    • Complete paginated downloads remain available for non-map use.
  • Bug Fixes

    • Canceled or outdated requests no longer overwrite newer map results.
    • Layer removal stops pending loading activity.
    • Refreshing layers preserves the current visible map area.
    • Query failures are reported and recover on subsequent reloads.
    • Feature limits remain accurate across split map regions.

Copilot AI lite review requested due to automatic review settings August 7, 2026 21:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

ArcGIS interactive feature layers now appear with empty GeoJSON data, then load features for the settled viewport. Paging supports incremental publication, cancellation, stale-result filtering, antimeridian splitting, restoration, bounded refresh, and cleanup. Headless callers retain complete downloads.

Changes

ArcGIS viewport loading

Layer / File(s) Summary
Paging parameters and cancellation
packages/plugins/src/plugins/arcgis-layer.ts
Paging requests preserve shared parameters, publish pages, skip viewport counts, and propagate abort signals through count, ObjectID, and GeoJSON requests.
Interactive viewport loader
packages/plugins/src/plugins/arcgis-layer.ts, tests/arcgis-feature-layer.test.ts
Interactive layers query current bounds, split antimeridian envelopes, publish incremental results, cancel superseded requests, ignore stale results, report failures, deduplicate features, and clean up listeners.
Restoration, refresh, and public integration
packages/plugins/src/plugins/arcgis-layer.ts, apps/geolibre-desktop/src/lib/layer-refresh.ts, apps/geolibre-desktop/src/components/layout/DesktopShell.tsx, packages/plugins/src/index.ts, tests/arcgis-feature-layer.test.ts
Saved viewport layers are restored and rebound to the active map. Refresh uses bounded reloads. Public helpers are exported. Tests cover restoration and retry behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Map
  participant ArcGISPlugin
  participant FeatureServer
  participant GeoJSONLayer
  Map->>ArcGISPlugin: create viewport layer
  ArcGISPlugin->>GeoJSONLayer: create empty layer
  ArcGISPlugin->>FeatureServer: request bounded feature pages
  FeatureServer-->>ArcGISPlugin: return page features
  ArcGISPlugin->>GeoJSONLayer: publish incremental features
  Map->>ArcGISPlugin: moveend or reload
  ArcGISPlugin->>FeatureServer: cancel prior request and query new bounds
Loading

Possibly related PRs

Poem

A rabbit watches bounds appear,
New feature pages hop near.
Old requests stop when maps move fast,
Fresh viewport data replaces the past.
Saved layers wake and rebind at last.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: viewport-based loading for ArcGIS feature services.
Linked Issues check ✅ Passed The changes add immediate loading, progressive paging, viewport reloads, cancellation, and restoration support required by issue [#1756].
Out of Scope Changes check ✅ Passed The code, tests, exports, refresh handling, and restoration updates directly support the linked issue and stated objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1756-arcgis-viewport-loading

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://2295019e.geolibre-preview.pages.dev
Demo app https://2295019e.geolibre-preview.pages.dev/demo/
Commit 739f103

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/plugins/src/plugins/arcgis-layer.ts`:
- Around line 453-487: In the load function, after defining publish and before
starting fetchArcGISFeaturePages, clear the layer by publishing an empty feature
collection through publish. Keep the existing request sequencing and fetch
behavior unchanged.
- Around line 459-470: Update the viewport query logic around the bbox and query
construction to detect antimeridian-crossing or west-greater-than-east longitude
bounds before clamping. Build separate ArcGIS envelope queries for the eastern
and western segments, execute both, then merge results using each feature’s ID
to remove duplicates; retain the existing single-query path for non-wrapped
bounds and add tests covering wrapped and west > east viewports.

In `@tests/arcgis-feature-layer.test.ts`:
- Around line 224-273: Extend the “adds an interactive layer immediately and
queries the current viewport” test to mutate map bounds, invoke the stored
moveend listener, and verify a second query uses the new viewport. Resolve the
initial request before resolving the replacement request, then assert only the
new response is published; remove the layer and assert the registered moveend
listener is passed to map.off.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0e1936ee-871e-4051-8fc3-ddc0d211fdcd

📥 Commits

Reviewing files that changed from the base of the PR and between 6597457 and 43fc2c7.

📒 Files selected for processing (2)
  • packages/plugins/src/plugins/arcgis-layer.ts
  • tests/arcgis-feature-layer.test.ts

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread tests/arcgis-feature-layer.test.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Since layer-refresh.ts isn't part of this diff, GitHub won't allow an inline comment there — I'll fold that finding into the summary instead.

Code review

Bugs

  • startArcGISViewportLoader's bbox clamp (packages/plugins/src/plugins/arcgis-layer.ts:458-464) doesn't handle a viewport crossing the antimeridian. map.getBounds() can return west > east or east/west beyond ±180 near the dateline — a case this codebase explicitly handles elsewhere (offline-tiles.ts's splitAntimeridian, ogc-vector-tiles.ts, print-data-blocks.ts) — but here each axis is clamped independently, producing an inverted or zero-width ArcGIS envelope and silently dropping features for that half of the view. Confidence: medium.
  • Manual "Refresh" on a viewport-loaded ArcGIS feature layer bypasses the new bounded loading entirely: refreshArcGISLayer (apps/geolibre-desktop/src/lib/layer-refresh.ts:565-586, unchanged by this PR) still calls refreshArcGISFeatureLayer, which replays the full unbounded paged download regardless of the new metadata.viewportLoading flag. That flag is set (arcgis-layer.ts:428) but never read anywhere else in the codebase, so refresh silently reintroduces the "hold hundreds of thousands of features in memory" workload this PR was built to avoid for interactive use. Confidence: medium (could be an intentional scope decision, but worth confirming).

Security

  • None found. URL/query construction continues to go through appendArcGISParams/URL/URLSearchParams, and the access token still never lands in the persisted refresh URL.

Performance

  • startArcGISViewportLoader creates a brand-new, permanent useAppStore.subscribe per viewport-loaded layer instead of reusing the shared single-subscription cleanup pattern already established in the same file (ensureArcGISStoreCleanup/arcgisStoreUnsubscribe, ~line 293). Each of these per-layer subscriptions does an O(n) layers.some(...) scan on every store state change (not just layer changes) for as long as the layer exists. With several such layers open this doesn't scale and diverges from the file's own convention. Confidence: medium. (Inline comment posted.)

Quality

  • Minor: existing?.abort?.abort() / existing.map.off(...) handling in startArcGISViewportLoader (~lines 497-499) guards against re-registering the same layerId, but the function is only ever called once per freshly-created id, so that branch currently looks unreachable — low-impact, not flagged inline.
  • The new test only covers the initial add + a single viewport query; there's no test exercising cancellation of an in-flight query on rapid successive moveends, or that a second viewport query replaces rather than appends to the previous one — both are called out in the PR description as behaviors but aren't directly asserted.

CLAUDE.md

  • No violations noted; the change stays within packages/plugins, doesn't touch tile/style host allowlists, and the new test lives under tests/ as expected.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site Deploy failed. See the job log.
Demo app Unavailable
Commit 739f103

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
pageSize: options.pageSize,
},
metadata: { sourceKind: ARCGIS_FEATURE_SOURCE_KIND },
metadata: { sourceKind: ARCGIS_FEATURE_SOURCE_KIND, viewportLoading: Boolean(map) },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality/UX (high confidence): once viewportLoading is true, layer.geojson only ever holds the features intersecting the last-loaded viewport, never the complete dataset — but nothing downstream is aware of that:

  • The Attribute Table (AttributeTable.tsx) reads layer.geojson.features.length straight into its "N features" status line, which will now read as a complete count when it's actually viewport-scoped.
  • Export (vector-export.tsresolveLayerGeojson, used by LayerPanel.tsx's handleExportLayer/handleSaveEditsToSource) reads layer.geojson directly, so exporting or writing back a viewport-loading ArcGIS layer silently produces a file containing only whatever happened to be loaded for the last-viewed extent, with no warning that it's partial.

metadata.viewportLoading is set here but appears to have no other consumer in the codebase. Worth surfacing this to the user in at least the Attribute Table and Export flows (e.g. a "partial data — pan/zoom to load more" notice), since silently truncated exports are easy to miss.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is a real gap, but leaving it out of this PR — it is a UX change spanning the Attribute Table status line, the export/save-edits flows in LayerPanel, and a new string across 18 locale catalogs, none of which this PR otherwise touches. metadata.viewportLoading has a second consumer as of ea2c38a (refresh routes through the viewport loader instead of replaying the full download), so the flag is no longer dead. Leaving this thread open for a follow-up on surfacing partial data in the table and export.

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

All six inline comments posted successfully. Now finalizing the summary.

Code review

Bugs

  • startArcGISViewportLoader's bbox clamp (Math.max(-180, …)/Math.min(180, …)) doesn't account for antimeridian-crossing viewports or unwrapped longitudes after multiple globe pans — either drops part of the queried extent or queries the wrong region entirely. Medium confidence.
  • Manual/periodic layer refresh on a viewport-loading ArcGIS layer bypasses viewport scoping and replays the full unbounded paged download via refreshArcGISLayer/refreshArcGISFeatureLayer, defeating this PR's stated purpose for large services and racing with the next moveend. Medium-high confidence.

Security

  • None found.

Performance

  • None beyond the per-layer store subscription noted under Quality.

Quality

  • metadata.viewportLoading is set but has no consumer: the Attribute Table's feature count and Export/"Save edits to source" both read layer.geojson directly and would show/produce a silently partial (viewport-scoped) result with no warning. High confidence (verified via codebase search).
  • startArcGISViewportLoader creates a new permanent useAppStore.subscribe per layer instead of following this file's own established singleton ??=-subscription pattern (arcgisStoreUnsubscribe), so each interactive ArcGIS layer adds a full-store scan on every store update for its lifetime. Medium confidence.
  • Failed viewport queries only console.error; there's no user-facing signal (toast, connection.lastError) that displayed data is stale, unlike the periodic-refresh path elsewhere. Low-medium confidence.
  • options.maxFeatures, elsewhere a total-download cap, is silently reapplied as a per-viewport cap once viewport loading is active — an undocumented behavior difference between the interactive and headless paths. Low confidence.

CLAUDE.md

  • No violations found; the change is scoped to packages/plugins and its test, with no touch to the mirrored-constant or catalog files this file calls out.

- Split an antimeridian-crossing viewport into two ArcGIS envelopes instead
  of clamping each edge on its own, which inverted the envelope (west 170,
  east -170) or collapsed it to zero width (west 200, east 210) and made the
  service answer with nothing for that part of the screen. Results from the
  two halves are merged and deduplicated by ObjectID.
- Fold the per-layer store subscription into one shared, `??=`-guarded
  subscription (`ensureArcGISFeatureLoaderCleanup`), matching the existing
  `ensureArcGISStoreCleanup` pattern, so N viewport layers no longer mean N
  full-store subscribers each scanning `state.layers` on every update.
- Route refresh for a viewport-loading layer through the loader's bounded
  query (`reloadArcGISViewportLayer`) rather than replaying the unbounded
  paged download, which would have pulled the whole service in on every
  refresh cycle.
- Record a failed viewport query on `connection.lastError` so the Layers
  panel surfaces it, instead of only logging to the console; the write is
  skipped when the state is unchanged so a healthy pan does not dirty the
  project.
- Document that `maxFeatures` caps each viewport query in the interactive
  app, not the layer as a whole.
- Extend the viewport test to pan mid-flight, assert the superseded response
  is discarded and the listener is detached on removal, and add a test for
  the antimeridian split.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/lib/layer-refresh.ts`:
- Around line 576-585: Handle superseded viewport reloads in the layer refresh
path: in apps/geolibre-desktop/src/lib/layer-refresh.ts lines 576-585, catch
await viewport failures and return the layer’s current features for DOMException
errors named AbortError, while rethrowing other errors. In
packages/plugins/src/plugins/arcgis-layer.ts lines 453-526, document at line 470
that load() rejects when superseded, or instead resolve superseded calls with
the current collection so reloadArcGISViewportLayer does not reject during
panning.

In `@tests/arcgis-feature-layer.test.ts`:
- Around line 347-378: Add a test alongside the existing ArcGIS viewport tests
that makes the viewport query reject, then asserts the connection’s lastError
contains the rejection message. Resolve a subsequent viewport query and assert
connection.lastError is reset to null, using the existing layer setup and fetch
stubs.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 050d163f-e228-4a14-a42f-cb662c823883

📥 Commits

Reviewing files that changed from the base of the PR and between 43fc2c7 and ea2c38a.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/lib/layer-refresh.ts
  • packages/plugins/src/index.ts
  • packages/plugins/src/plugins/arcgis-layer.ts
  • tests/arcgis-feature-layer.test.ts

Comment thread apps/geolibre-desktop/src/lib/layer-refresh.ts
Comment thread tests/arcgis-feature-layer.test.ts
Comment thread apps/geolibre-desktop/src/lib/layer-refresh.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • refreshArcGISLayer in apps/geolibre-desktop/src/lib/layer-refresh.ts:580-585 silently falls through to the unbounded full-service download for any restored/reopened viewport-loading layer, because reloadArcGISViewportLayer returns null when there's no live in-memory loader — exactly the state a layer restored from a saved project is in. This is the very behavior the surrounding comment says viewport layers are exempt from. Confidence: medium-high.
  • startArcGISViewportLoader (packages/plugins/src/plugins/arcgis-layer.ts:448-526) is only ever invoked from the fresh Add Data flow, never re-run on project load, so a reopened project's viewportLoading: true layer never gets its moveend listener reattached — it stays frozen on whatever was in view at save time, and any subsequent refresh hits the bug above. Confidence: medium.
  • Manual/scheduled refresh (reloadArcGISViewportLayer) shares its abort/requestSequence state with pan-triggered loads, so a concurrent moveend can abort an in-flight manual refresh; the resulting error can spuriously trip an onFailure: "clear" policy and wipe the layer's features even though nothing is actually broken. Confidence: low-medium (narrow race window).

Performance / Quality

  • The Add Data dialog's onProgress callback (bound to component state) is captured into options and retained by the viewport loader's closure for the layer's entire lifetime, firing pointlessly on every future pan long after the dialog has unmounted — dead work and an unnecessary retained closure. Confidence: low-medium.
  • Minor: with maxFeatures set and a viewport spanning the antimeridian, the cap is applied independently per envelope half, so up to ~2× maxFeatures can be fetched for a single pan instead of one combined cap. Confidence: low.

Security

  • None found. Token handling (kept out of persisted refresh URLs), AbortSignal plumbing, and URL param construction all look consistent with existing patterns.

CLAUDE.md

  • No violations noted — no new user-facing strings requiring t(), no CSP/tile-host changes, no geolibre-wasm/catalog-mirroring concerns triggered by this diff.

The three findings above (all interrelated around restored-project viewport layers) are posted as inline comments with more detail and suggested directions.

- Stop a superseded viewport query from rejecting: `load()` now resolves with
  the layer's current features when a `moveend` aborts it, so a refresh that
  overlaps a pan is no longer reported as a failed refresh (and, under an
  `onFailure` of "clear", no longer wipes a healthy layer).
- Re-attach viewport loaders on project load via the new
  `restoreArcGISViewportLayers`, wired into DesktopShell's restore effect.
  `metadata.viewportLoading` round-trips through the saved project, but only
  the Add Data flow started a loader, so a reopened project's layer stayed
  frozen on the extent it was saved with and its refresh fell back to the
  unbounded download.
- Drop the Add Data dialog's `onProgress` from the options the loader keeps,
  rather than holding an unmounted dialog's closure for the layer's lifetime.
- Test the connection.lastError path: a rejected viewport query records the
  message, and a later successful one clears it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/plugins/src/plugins/arcgis-layer.ts`:
- Around line 506-523: Update the parallel envelope-fetch logic around
fetchArcGISFeaturePages so queryOptions.maxFeatures is enforced across the
entire viewport rather than independently per antimeridian request. Coordinate a
shared remaining-feature budget (including onPage pagination) or divide the
limit between envelopes, ensuring the combined pages never exceed the configured
maximum; add an antimeridian test covering maxFeatures.
- Around line 525-529: Update the catch block in the ArcGIS layer request flow
to return currentArcGISLayerGeojson(layerId) for any stale request where
sequence !== requestSequence, regardless of error type; only rethrow errors from
the current request. Add a regression test covering a first request rejected
with a non-AbortError after a replacement viewport request begins.
- Around line 578-584: Update the layer restoration logic around the existing
`arcgisFeatureLoaders` check to skip only when the stored loader is already
bound to the current `map`; when it belongs to a previous Map instance, stop
that loader before creating and attaching its replacement so the moveend
listener and bounds use the current map.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7ecf151d-21e3-4c5b-82be-51f75e0622b5

📥 Commits

Reviewing files that changed from the base of the PR and between ea2c38a and edcc102.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
  • packages/plugins/src/index.ts
  • packages/plugins/src/plugins/arcgis-layer.ts
  • tests/arcgis-feature-layer.test.ts

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
Comment thread apps/geolibre-desktop/src/lib/layer-refresh.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • refreshArcGISLayer silently falls back to the unbounded where=1=1 download for a viewport-loading layer whenever reloadArcGISViewportLayer returns null — which happens not only for non-viewport layers but also during the async window between project load and restoreArcGISViewportLayers attaching a loader, or permanently if that restore fetch ever fails. This is exactly the "swap the whole service in behind the user's back" behavior the surrounding docstring says must never happen (apps/geolibre-desktop/src/lib/layer-refresh.ts:576-585). Confidence: high.
  • In the antimeridian two-envelope path, if one envelope's fetch fails with a genuine (non-abort) error while the other succeeds, Promise.all rejects and reports the layer as failed, but the surviving envelope keeps running and can still publish partial data into the layer afterward — leaving an error banner shown alongside silently-updated map data with no clean recovery (packages/plugins/src/plugins/arcgis-layer.ts:505-532). Confidence: medium (narrow trigger: dateline-crossing viewport + a partial network failure).

Security

  • None found. Query params are built via URLSearchParams/object composition rather than string concatenation, and the access token is deliberately kept out of persisted state.

Performance

  • collect()/mergeArcGISViewportFeatures re-scans and re-merges all pages across all envelopes on every single page arrival (from either envelope), which is O(total buffered features) per publish. Given viewport loading is meant to bound feature counts, this is likely fine in practice, but could be mildly wasteful on a dense polygon layer with large pages. Confidence: low.

Quality

  • The new code is well-documented with clear "why" comments consistent with the surrounding style (paging plan rationale, antimeridian handling, cleanup subscription reasoning). No significant readability or naming concerns.

CLAUDE.md

  • No violations found: no new external map/tile hosts, no user-facing strings needing i18n beyond existing console.error/console.warn conventions, and no changes to files governed by the auto-generated catalog/mirrored-constant rules.

I also looked for a stale-loader risk if the MapLibre map instance were ever recreated while a viewport layer is active (since arcgisFeatureLoaders is a module-level map keyed only by layer id, and restoreArcGISViewportLayers skips rebinding when an entry already exists). I couldn't confirm a reachable path to a genuine map-instance swap in MapCanvas/DesktopShell (the map is initialized once via an effect with [] deps, and basemap style changes reuse the same instance), so I did not file this as a finding.

- Ignore every error from a superseded viewport query, not just AbortError: a
  request that failed just before its abort landed was still setting
  connection.lastError and failing a concurrent refresh.
- Await both antimeridian halves with `allSettled` before reporting, so a
  surviving half can no longer publish pages over an already-reported error
  with nothing left to clear it.
- Trim the merged split-viewport result to `maxFeatures`, which each envelope
  request was otherwise honoring on its own — a limit of N could leave the
  layer holding 2N.
- Rebind a restored loader when the map instance changes instead of skipping
  on the mere presence of a loader, which left the listener on a dead map.
- Refusing rather than falling through: a viewport-loading layer with no live
  loader yet (a just-reopened project still resolving service metadata) now
  reports that it is not ready, instead of silently replaying the unbounded
  download.
- Tests for the stale non-abort failure and for the split-viewport cap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/plugins/src/plugins/arcgis-layer.ts (1)

615-625: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent an obsolete restore request from replacing the current map loader.

When the map changes while fetchArcGISJson is pending, both restore calls pass the layer-exists check. If the older request resolves last, Line 623 stops the loader bound to the current map and creates a loader on the obsolete map. Later moveend events and reloads then use stale bounds.

Before starting the loader, require app.getMap?.() === map. Apply the same guard in the rejection handler so an obsolete request cannot set connection.lastError on the current layer. Add a deferred-metadata test that resolves the newer map request before the older one.

Proposed fix
       .then((layerInfo) => {
-        if (!useAppStore.getState().layers.some((entry) => entry.id === layerId)) return;
+        if (
+          app.getMap?.() !== map ||
+          !useAppStore.getState().layers.some((entry) => entry.id === layerId)
+        ) {
+          return;
+        }
         startArcGISViewportLoader(layerId, map, queryUrl, options, layerInfo);
       })
-      .catch((error: unknown) => handleArcGISViewportError(layerId, error));
+      .catch((error: unknown) => {
+        if (app.getMap?.() === map) handleArcGISViewportError(layerId, error);
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/plugins/src/plugins/arcgis-layer.ts` around lines 615 - 625, Update
the fetchArcGISJson completion and rejection handlers around
startArcGISViewportLoader to require both the layer’s continued existence and
useAppStore.getState().app.getMap?.() === map before proceeding. Apply the same
map guard before handleArcGISViewportError so obsolete requests cannot affect
the current layer, and add a deferred-metadata test that resolves the newer map
request before the older one.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/plugins/src/plugins/arcgis-layer.ts`:
- Around line 615-625: Update the fetchArcGISJson completion and rejection
handlers around startArcGISViewportLoader to require both the layer’s continued
existence and useAppStore.getState().app.getMap?.() === map before proceeding.
Apply the same map guard before handleArcGISViewportError so obsolete requests
cannot affect the current layer, and add a deferred-metadata test that resolves
the newer map request before the older one.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3d28fd79-f0f9-4e0e-a160-b455511cbc2f

📥 Commits

Reviewing files that changed from the base of the PR and between edcc102 and 1c13c0d.

📒 Files selected for processing (3)
  • apps/geolibre-desktop/src/lib/layer-refresh.ts
  • packages/plugins/src/plugins/arcgis-layer.ts
  • tests/arcgis-feature-layer.test.ts

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • restoreArcGISViewportLayers's metadata re-fetch has no retry: if it fails once (e.g. a transient error right after reopening a project), no viewport loader ever gets registered for that layer, and refreshArcGISLayer will then permanently report "This layer is still binding to the map viewport. Try again in a moment." even though nothing ever retries the fetch. Medium confidence — narrow edge case (transient failure during project restore), but leaves the layer stuck with a misleading recovery message until the user removes and re-adds it. (packages/plugins/src/plugins/arcgis-layer.ts:614-626)

Security

  • None found. URL/query construction goes through existing appendArcGISParams/fetch helpers unchanged by this PR; no new injection or unsafe-input surfaces were introduced.

Performance

  • None found. The abort-on-supersede design (each moveend cancels the prior in-flight request via AbortController) correctly prevents request pile-up during rapid panning, and the antimeridian split only doubles requests when the viewport genuinely straddles it.

Quality

  • No test exercises restoreArcGISViewportLayers (project-reload rebind) or reloadArcGISViewportLayer/the new layer-refresh.ts viewport branch (the actual "Refresh" button path for these layers) — exactly the paths with the subtlest new logic. Medium confidence this is a real coverage gap worth closing. (packages/plugins/src/plugins/arcgis-layer.ts:569)
  • The signal?.aborted rethrow in fetchArcGISFeatureCount's catch block appears unreachable with current call sites, since every caller that passes a signal also passes params, which short-circuits the count fetch entirely before this code runs. Low confidence, cosmetic. (packages/plugins/src/plugins/arcgis-layer.ts:1301-1304)

CLAUDE.md

  • No violations found. The new code reads map.getBounds()/isMoving() and attaches/detaches its own moveend listener but never mutates MapLibre sources/layers directly — all rendering changes still flow through store.updateLayer/addGeoJsonLayer, consistent with the store-driven architecture rule.

Overall the antimeridian-splitting, request-supersession, and error-reporting logic is carefully reasoned through and the accompanying tests cover the tricky race conditions well; the concerns above are edge cases in the project-restore path rather than issues with the core viewport-loading feature.

- Register a restored loader synchronously and fetch the service metadata
  lazily on its first query, memoized with the memo cleared on failure. A
  metadata fetch that failed once left the layer permanently unbound, with
  refresh reporting a transient-sounding error that nothing ever retried;
  now the next pan or refresh retries it, and there is no window in which a
  refresh finds the layer unbound at all.
- Reword the refresh error accordingly: no loader now means no map, not a
  race with startup.
- Drop the unreachable abort branch in fetchArcGISFeatureCount — a viewport
  query is the only cancellable caller and planArcGISPaging skips the count
  for those, so the signal was always undefined where it ran.
- Test restoreArcGISViewportLayers end to end: synchronous binding, a failed
  metadata read reported and retried on the next pan, rebinding when the map
  instance changes, and reloadArcGISViewportLayer re-querying the viewport.
Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Both inline comments posted successfully.

Code review

This PR adds viewport-bound loading for ArcGIS FeatureServer layers: features load progressively for the current map extent, stale requests are cancelled on pan/zoom, restored projects re-bind their loaders, and headless/API consumers keep the full paged download. The implementation is unusually well-documented and defensively coded (sequence numbers to guard against out-of-order responses, Promise.allSettled for antimeridian splits, memoized metadata fetches with retry-on-failure, careful moveend listener lifecycle management). I traced the request-sequencing, abort/supersession logic, antimeridian envelope splitting, store metadata merging, and the refresh/restore interplay with layer-refresh.ts and found no correctness bugs, race conditions, or security issues.

Bugs: None found.

Security: None found. URL construction goes through URL/URLSearchParams (no injection risk), tokens are kept out of persisted URLs consistent with the existing pattern, and ObjectID-range where clauses are built only from numeric values already validated by typeof id === "number".

Performance:

  • Low confidence: when a viewport straddles the antimeridian, each of the two envelope queries independently honors the full maxFeatures cap before the merge step trims the combined result, so up to 2× the intended feature count can be fetched and discarded on a single pan. This trade-off is explicitly called out in a code comment, so it may be an accepted design choice rather than an oversight.
  • Low confidence: the metadata re-fetch used when restoring a saved project (fetchArcGISJson inside restoreArcGISViewportLayers) takes no abort signal, so it can't be cancelled by a subsequent pan the way the paged feature queries can. The memoization and sequence-number checks make this harmless for correctness, but a slow/unreachable service delays the first real viewport query and burns bandwidth on a request nothing will use.

Quality: The new viewportLoading metadata flag, the ArcGISViewportLoader map, and the shared store-subscription cleanup pattern mirror the existing arcgisLayerInstances/ensureArcGISStoreCleanup conventions well. Test coverage (tests/arcgis-feature-layer.test.ts) is thorough — supersession, antimeridian splitting, error recovery, restore-with-retry, and the split-viewport maxFeatures cap are all exercised.

CLAUDE.md: No violations found. No new external tile/map hosts, no hand-edited generated catalogs, and the new user-facing error string ("This layer is not bound to a map viewport...") follows the same untranslated raw-Error.message convention already used elsewhere in layer-refresh.ts, so it isn't a new deviation introduced by this PR.

Reported against Vicmap_Parcel: the layer was added but never showed any
features, with only a console error to say why.

The service answers a geometry-bearing query over a metro-scale envelope in
20-35s when healthy, and exceeds its own ~56s timeout when busy. It reports
that timeout as HTTP 200 carrying an error envelope whose code is 400 and
whose detail blames the query parameters — which are correct; the identical
request succeeds on a retry. The viewport loader treated it as a hard
failure, so the layer stayed empty.

- Throw a typed ArcGISQueryError carrying the HTTP status and the envelope
  code, so a timeout is distinguishable from an auth or network fault.
- Retry a transient failure once per envelope walk, skipped when a pan has
  already aborted the request.
- Report guidance the user can act on when the retry fails too, instead of
  ArcGIS's own wording, which sends them to check parameters that are fine.
- Clear connection.lastError on the first published page rather than at the
  end of the walk: a dense extent pages for tens of seconds, and a stale
  error sitting on screen while features stream in reads as a live failure.

Verified against the live service, not only the mocked tests: the metro
extent loads (2000 features, no error), a suburb extent loads (6000), and no
stale error survives either. The timeout path is covered by regression tests
built from the real error envelope, both confirmed to fail without the fix.
Comment on lines +459 to +515
function startArcGISViewportLoader(
layerId: string,
map: maplibregl.Map,
queryUrl: string,
options: ArcGISLayerOptions,
resolveLayerInfo: () => Promise<ArcGISFeatureLayerInfo>,
): void {
let abort: AbortController | null = null;
let requestSequence = 0;
// The Add Data dialog's progress callback belongs to the initial download. It
// is unmounted well before the first viewport query lands, so carrying it for
// the layer's lifetime would keep that closure alive to no purpose.
const queryOptions: ArcGISLayerOptions = { ...options, onProgress: undefined };
const maxFeatures = positiveInteger(options.maxFeatures);
const loader: ArcGISViewportLoader = {
abort: null,
load: () => Promise.resolve({ type: "FeatureCollection", features: [] }),
map,
move: () => undefined,
};

/**
* Query the map's current extent, publishing each page as it lands.
*
* Resolves with whatever the layer holds — never rejects, whatever the
* error — when a newer viewport supersedes the call: the replacement is
* already publishing, so a failure the superseded request happened to hit
* (an abort, or a request that failed just before its abort landed) is not
* this layer's problem. A refresh running concurrently with a pan must not be
* reported as a failed refresh (and, under an `onFailure` of `"clear"`, wipe
* a layer that is perfectly healthy).
*/
const load = async (): Promise<FeatureCollection> => {
abort?.abort();
const controller = new AbortController();
abort = controller;
loader.abort = controller;
const sequence = ++requestSequence;
let layerInfo: ArcGISFeatureLayerInfo;
try {
layerInfo = await resolveLayerInfo();
} catch (error) {
if (sequence !== requestSequence) return currentArcGISLayerGeojson(layerId);
throw error;
}
const envelopes = arcgisViewportEnvelopes(map.getBounds());
// One bucket per envelope, so a viewport split across the antimeridian
// publishes both halves together instead of each replacing the other.
const pages: Feature[][] = envelopes.map(() => []);
const collect = (): FeatureCollection => {
const features = mergeArcGISViewportFeatures(pages, layerInfo.objectIdField);
// A split viewport issues one request per envelope, each honoring
// `maxFeatures` on its own, so the merge is trimmed to keep the option a
// bound on what the layer holds for one viewport rather than per half.
return {
type: "FeatureCollection",
features: maxFeatures === null ? features : features.slice(0, maxFeatures),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: viewport queries have no ceiling when maxFeatures is unset (the default), and arcgisViewportEnvelopes falls back to the full [-180, south, 180, north] world envelope whenever the viewport spans ≥360° of longitude (e.g. the user zooms out past the whole world, which is easy to do after fitBounds if the layer's own extent is large). In that case remainingArcGISFeatures never caps the walk (plan.maxFeatures === nullwanted = pageSize), so the "bounded" viewport query degenerates into essentially the same unbounded where=1=1 download that this PR (and #1745) exists to avoid — and it re-runs on every subsequent moveend while the viewport stays that wide, not just once.

Since the Add Data dialog leaves maxFeatures optional with no default (ArcGISSource.tsx), this is reachable without any unusual configuration, just panning/zooming out on a layer with a large extent. Consider clamping viewport queries to a page or two when no maxFeatures is set and the envelope is very wide (or gating the query on a minimum zoom level), rather than letting MAX_ARCGIS_PAGES (5000 pages) be the only backstop.

Confidence: medium — the mechanism is real, but I can't verify how often users actually reach a whole-world/near-whole-world viewport on one of these layers in practice.

): Promise<number | null> {
try {
const response = await fetch(
appendArcGISParams(queryUrl, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: fetchArcGISFeatureCount now receives the full paging params (including outFields: "*" and returnGeometry: "true") instead of just the token, and spreads them into the returnCountOnly=true request. ArcGIS should ignore these for a count-only query, so this is likely harmless, but it's dead weight on every count request and slightly obscures that only the token (and, for split-viewport calls, the geometry filter) actually matter here. Worth a { token: params.token, geometry: params.geometry, ... } pick instead of spreading everything, if only for clarity.

Confidence: low — cosmetic, not a functional issue as far as I can tell.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found with high confidence. The sequence-number/abort bookkeeping in startArcGISViewportLoader, the antimeridian split/merge/dedup logic, the transient-timeout retry, and the restore/refresh wiring (restoreArcGISViewportLayers, reloadArcGISViewportLayer, layer-refresh.ts) all hold up under close reading and are backed by targeted tests that match the implementation's actual behavior.

Security

  • No issues found. Query parameters built from map bounds are numeric only; no new injection or credential-leak surface. The existing "token never persisted" design is preserved consistently across the new restore/refresh paths.

Performance

  • Medium confidence: viewport queries have no built-in ceiling when maxFeatures is left unset (the default in the Add Data dialog). When the visible viewport spans ≥360° of longitude — easy to reach by zooming out after fitBounds, especially on a layer with a large extent — arcgisViewportEnvelopes falls back to a full world envelope, and remainingArcGISFeatures has nothing to cap the walk. That collapses the "bounded per-viewport query" back into essentially the same unbounded download the PR (and ArcGIS Feature Service does not work with large datasets #1745/[Bug]: ArcGIS Feature Service loads all features before display #1756) is meant to avoid, repeating on every moveend while the view stays wide. See inline comment on arcgis-layer.ts around startArcGISViewportLoader/arcgisViewportEnvelopes.
  • Low confidence / cosmetic: fetchArcGISFeatureCount now spreads the entire paging params object (including outFields, returnGeometry) into its returnCountOnly=true request instead of just the token; likely harmless since ArcGIS should ignore the extra fields for a count-only query, but it's unnecessary noise.

Quality

  • The new code is heavily and usefully commented, and the test suite (tests/arcgis-feature-layer.test.ts) exercises the tricky concurrency, antimeridian, retry, and restore-after-reopen paths directly. No structural or naming concerns.

CLAUDE.md

  • No violations noticed: no new external tile/map hosts were added (so no CSP allowlist change needed), no i18n-facing strings were introduced by this diff, and the change doesn't touch any of the mirrored-constant files called out in CLAUDE.md.

Posted two inline comments: one on the unbounded-viewport-query performance concern, one a minor nit on the count-request params.

@giswqs
giswqs merged commit 7bfa962 into main Aug 8, 2026
16 checks passed
@giswqs
giswqs deleted the fix/issue-1756-arcgis-viewport-loading branch August 8, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: ArcGIS Feature Service loads all features before display

2 participants