Skip to content

fix(arcgis): load feature layers as GeoJSON so labels can be styled#868

Merged
giswqs merged 4 commits into
mainfrom
fix/issue-867-arcgis-feature-labels
Jun 25, 2026
Merged

fix(arcgis): load feature layers as GeoJSON so labels can be styled#868
giswqs merged 4 commits into
mainfrom
fix/issue-867-arcgis-feature-labels

Conversation

@giswqs

@giswqs giswqs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

Fixes #867. Label formatting options (uppercase, offset, rotation, and labeling in general) were not applicable to ArcGIS vector layers added from the Add Data Load sample data dropdown (e.g. "US major cities (feature)").

Root cause

ArcGIS feature layers were registered as external-native layers (externalNativeLayer: true) whose GeoJSON source was a remote /query?f=geojson URL. The features were never stored on the layer record, so:

  1. getAttributePropertyNames() in the Style panel found no attributes → the Label field dropdown was disabled showing "No attributes found", so no field could be picked.
  2. syncLayer() returns early for external-native layers (packages/map/src/layer-sync.ts), so the label symbol layer that carries text-transform / text-offset / text-rotate was never created.

Inline-GeoJSON suggested layers (WFS, Delimited Text, GPX) store layer.geojson and were unaffected.

Fix

A feature layer is just attributed vector data, so fetch the /query?f=geojson result up front and hand it to the store's GeoJSON layer path instead of building an external-native runtime layer. Feature layers now render as first-class type: "geojson" vector layers with their attributes available, which unlocks labels (and their uppercase/offset/rotation formatting), the attribute table, identify, symbology, and export. Vector tile layers keep the external-native runtime path. The now-dead fallback helpers were removed.

Verification

Reproduced and verified in the real app with Playwright using the ArcGIS "US major cities (feature)" sample:

  • Before: the layer's badge was arcgis and the Label field dropdown was disabled ("No attributes found").
  • After: the badge is vector, the Label field lists the real attributes (NAME, POPULATION, STATE_ABBR, …), labels render, and UPPERCASE + 90° rotation visibly apply. Confirmed in both light and dark themes.

Added tests/arcgis-feature-layer.test.ts covering the GeoJSON conversion (attributes preserved, bounds fitted) and the non-GeoJSON error path. Full frontend suite (1608 tests), npm run build, and pre-commit all pass.

Summary by CodeRabbit

  • New Features
    • ArcGIS FeatureServer feature layers are now loaded via a GeoJSON flow for consistent in-app rendering.
    • Optional map fitting is applied from the ArcGIS-provided layer extent.
  • Bug Fixes
    • Added stricter handling/validation for ArcGIS /query?f=geojson responses, including clear errors for non-GeoJSON (e.g., HTML/token issues).
    • When results are truncated, the app warns and still loads partial data.
    • GeoJSON layer attribution is now preserved during synchronization for both clustered and non-clustered maps.
  • Tests
    • Added coverage for success, invalid responses, truncation warnings, token behavior, and portal-item source resolution.

ArcGIS feature layers added from the Add Data sample dropdown were
registered as external-native layers whose GeoJSON source was a remote
`/query?f=geojson` URL. Their features were never stored on the layer, so
the Style panel's label field showed "No attributes found" and the
label sync path is skipped entirely for external-native layers. That left
the label formatting options (uppercase, offset, rotation) inapplicable to
these suggested vector layers.

Feature layers are just attributed vector data, so fetch the query result
up front and hand it to the store's GeoJSON layer path instead. They now
render as first-class vector layers with their attributes available, which
unlocks labels (and their formatting), the attribute table, identify,
symbology, and export. Vector tile layers keep the external-native runtime
path.

Fixes #867
@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit d075ab2
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a3d42c17b8337000877626e
😎 Deploy Preview https://deploy-preview-868--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 72fdbd6e-9f76-48ec-81f7-a63fb3f0f26f

📥 Commits

Reviewing files that changed from the base of the PR and between 83ba44a and d075ab2.

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

📝 Walkthrough

Walkthrough

ArcGIS feature layers now load through a GeoJSON query path with metadata lookup, response validation, store insertion, and optional extent-based bounds fitting. Inline GeoJSON source creation now preserves source attribution.

Changes

ArcGIS feature-layer GeoJSON path

Layer / File(s) Summary
Routing and fallback removal
packages/plugins/src/plugins/arcgis-layer.ts
addArcGISLayer routes feature layers to the GeoJSON path before map-readiness checks, createArcGISHostedLayer only creates vector-tile runtime layers, and the static fallback helpers are deleted.
GeoJSON feature loading
packages/plugins/src/plugins/arcgis-layer.ts, tests/arcgis-feature-layer.test.ts
addArcGISFeatureLayerAsGeoJson resolves the feature service, fetches metadata, validates geometryType, requests /query?f=geojson, accepts FeatureCollection responses or ArcGIS error envelopes, warns on truncated results, adds the layer to the store, applies fitBounds from the extent, and is covered by success/error/truncation/portal-item tests.
GeoJSON source attribution
packages/map/src/layer-sync.ts
syncGeoJsonLayer reads layer.source.attribution and includes it in map.addSource for both clustered and non-clustered GeoJSON sources.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • opengeos/GeoLibre#356: Touches the same inline GeoJSON sync path in packages/map/src/layer-sync.ts, with changes to GeoJSON source handling in the same area.

Poem

I nibbled GeoJSON by moonlit glow,
and watched the ArcGIS currents flow.
Bounds hugged close and attributions stayed,
while old fallback burrows gently decayed. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% 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 main change: ArcGIS feature layers are now loaded as GeoJSON to support styling and labels.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 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-867-arcgis-feature-labels

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 Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://9ac0c9b3.geolibre-preview.pages.dev
Demo app https://9ac0c9b3.geolibre-preview.pages.dev/demo/
Commit d293642

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

Copy link
Copy Markdown
Contributor

Code review

Bugs

Finding Location Confidence
exceededTransferLimit silently accepted — ArcGIS caps f=geojson results at the service's maxRecordCount (often 1 000–2 000). When the result set is larger the server still returns a valid FeatureCollection but sets exceededTransferLimit: true. The current validation in fetchArcGISGeoJson only checks type and features, so truncated data is accepted without warning. After this PR users can open the attribute table and export; both will silently reflect only a partial dataset. See the inline suggestion for a guard. arcgis-layer.ts line 290–295 Medium

Quality

Finding Location Confidence
Dead branch in createArcGISStoreLayer — Line 517 reads options.layerType === "feature" ? "geojson" : "vector". Feature layers now exit at line 91, so createArcGISStoreLayer is only reachable for vector-tile layers; the "feature" arm is unreachable and the expression always resolves to "vector". It won't cause a bug but could mislead a reader. arcgis-layer.ts line 517 (unchanged line, noted here as it can't be inlined) Low
Test coverage gaps — The new test suite covers URL-sourced layers and an authentication error, but misses (a) exceededTransferLimit: true responses (should reject and leave the store empty once the guard is added) and (b) the portal-item source path, which has its own first fetch (/content/items/<id>) that the URL path never exercises. arcgis-feature-layer.test.ts line 114 Low–medium

Security / Performance / CLAUDE.md

Nothing else worth raising. The token-in-URL pattern is pre-existing across all fetchArcGISJson / fetchArcGISPortalItemInfo calls, not introduced here. Memory and latency trade-offs from eager fetching are appropriate for the stated use case (sample data layers that the old path already fetched with the same URL, same page limit).

What was checked

  • Full diff plus the current state of packages/plugins/src/plugins/arcgis-layer.ts
  • addGeoJsonLayer signature and implementation in packages/core/src/store.ts
  • tests/arcgis-feature-layer.test.ts for coverage completeness
  • ArcGIS REST API behaviour for exceededTransferLimit

- fetchArcGISGeoJson: warn when an ArcGIS query is truncated at the
  service's maxRecordCount (exceededTransferLimit). The partial dataset
  still loads — throwing would make large feature layers unloadable, a
  regression from the prior URL-source path that rendered the same subset.
- Add tests for the exceededTransferLimit warning path and the
  portal-item resolution path (portal item URL -> service URL -> query).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@tests/arcgis-feature-layer.test.ts`:
- Around line 148-156: Strengthen the ArcGIS feature layer test by verifying the
actual fetch flow instead of returning the same fallback response for all
non-query URLs. In the fetch mock inside arcgis-feature-layer.test.ts, capture
requested URLs and assert that the portal-item resolver path hits
/content/items/... and that the resolved service request hits the service /query
URL; use the existing test setup around globalThis.fetch and the
portal-item/serviceUrl assertions to make the test fail if either request is
skipped.
🪄 Autofix (Beta)

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: ae11d4c0-4937-4f98-9c33-d49ac0f7d2aa

📥 Commits

Reviewing files that changed from the base of the PR and between 24f0c2b and 4f9827a.

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

Comment thread tests/arcgis-feature-layer.test.ts Outdated
- Strengthen the portal-item test: capture fetch URLs and assert both
  the portal /content/items/<id> lookup and a query against the resolved
  service URL actually occurred, so the test cannot pass if the resolver
  skips the portal path.
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

Copy link
Copy Markdown
Contributor

Code review

This PR replaces the opaque external-native fallback path for ArcGIS feature layers with a clean GeoJSON fetch + `addGeoJsonLayer` store call, which correctly unlocks labels, symbology, and the attribute table. The approach is sound and the tests cover the main flows well. Three issues need attention before merge.


Bugs

1. New ArcGIS GeoJSON layers are incorrectly treated as "refreshable" (high confidence)

`addGeoJsonLayer` always produces `metadata: {}` — no `sourceKind`, no `externalNativeLayer`. `refreshSourceUrl` in `layer-refresh.ts` returns the raw `FeatureServer/0` URL as the refresh endpoint because the `sourceKind` guard short-circuits on `undefined` and the `externalNativeLayer` guard is absent. When the user triggers a refresh (or a scheduled auto-refresh runs), `fetchGeoJsonFeatureCollection` is called with the service-metadata URL and throws `"The response is not a GeoJSON FeatureCollection."` The Refresh button should not appear for these layers at all. Fix: call `useAppStore.getState().updateLayer(id, { metadata: { sourceKind: "arcgis-feature" } })` immediately after `addGeoJsonLayer`. (Inline comment on line 263.)

2. `response.json()` in `fetchArcGISGeoJson` throws an uninformative `SyntaxError` for 200 + HTML responses (high confidence)

ArcGIS Enterprise and many WAFs return HTTP 200 with an HTML login page when a token is missing or expired. `response.ok` is `true` so the HTTP-error guard does not fire, then `response.json()` throws `SyntaxError: Unexpected token '<'` rather than the helpful message one line below. The pattern used by `fetchGeoJsonFeatureCollection` in `layer-refresh.ts` — read as text, sniff for `<`, then parse — should be applied here too. (Inline comment on line 287.)


Performance

3. Attribution (`copyrightText`) is silently dropped (medium confidence)

The old code set `attribution: layerInfo.copyrightText || "ArcGIS Feature Service"` on the MapLibre source spec. `addGeoJsonLayer` constructs `source: { type: "geojson" }` with no attribution, and `syncGeoJsonLayer` does not inject one. The copyright watermark disappears from MapLibre's `AttributionControl` for any feature service that declares `copyrightText`. (Inline comment on line 255.)


Quality

4. Dead branch in `createArcGISStoreLayer` (low confidence — nit)

`const sourceType = options.layerType === "feature" ? "geojson" : "vector";` (line 531) — feature layers now exit at line 90–92 and never reach this function, so the `"feature"` arm is unreachable. The ternary should be simplified to `const sourceType = "vector";` to avoid misleading future readers. This line is unchanged in the diff so it could not receive an inline comment, but it is a direct consequence of this PR.

Comment thread packages/plugins/src/plugins/arcgis-layer.ts Outdated
Comment on lines +303 to +308
if (json.exceededTransferLimit) {
console.warn(
`[GeoLibre] ArcGIS feature query was truncated at the service record ` +
`limit; loaded ${json.features.length} features (partial dataset).`,
);
}

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.

Silent truncation is a meaningful UX regression

console.warn is the only signal when exceededTransferLimit is true, so users who add a service with more features than its maxRecordCount (often 1 000–2 000) silently get a partial layer with no in-product indication. In the old URL-backed GeoJSON source the same truncation happened, but the layer was dynamic (MapLibre re-fetched on demand). Now the truncated copy is permanently frozen in the Zustand store and appears complete in the attribute table and any export.

At minimum, either:

  • Surface a non-blocking toast/notification in the UI at the plugin level (if the app API exposes one), or
  • Throw a typed warning object that the Add Data dialog's error boundary can render distinctly from a hard error, so the layer still loads but the user sees "Loaded N features — service limit reached."

A comment on line 302 claims this is "the same subset the previous URL-source path rendered," but that framing misses the key difference: the subset is now static and stored.

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.

Partially pushing back on the framing here. A MapLibre geojson source loads its data once and does not re-fetch on demand, so the previous URL-source path was equally static and equally capped at maxRecordCount — there is no loss of dynamism, and this change actually adds a truncation signal where there was none. A user-facing toast would be a nice improvement, but the plugin app API exposes no notification/toast hook, so surfacing one would require a broader API addition that is out of scope for this fix. Leaving this thread open for your call on whether to add that API separately; happy to do it in a follow-up if you'd like.

Comment thread tests/arcgis-feature-layer.test.ts Outdated
- Refresh: persist the GeoJSON `/query` endpoint (not the FeatureServer
  base URL, which returns the service-description envelope) as the layer's
  sourcePath, so a GeoJSON refresh re-fetches valid features. The access
  token is kept out of the persisted URL and only appended to the one-off
  load request, so it is never written to a saved project.
- Robustness: read the query response as text and reject a leading-`<`
  HTML body (a 200 login/redirect page from ArcGIS Enterprise or a WAF)
  with a clear message instead of a raw JSON.parse SyntaxError.
- Attribution: carry the service's copyrightText onto the layer's
  source.attribution and propagate source.attribution into the MapLibre
  GeoJSON source in syncGeoJsonLayer, restoring the copyright watermark.
- Tests: cover the refresh-URL/token-not-persisted, HTML-200, and
  attribution behaviors.
@github-actions

Copy link
Copy Markdown
Contributor

Code review

This PR replaces the old URL-backed GeoJSON source for ArcGIS feature layers with an upfront fetch, making features first-class GeoJSON layers in the store. That correctly enables labels, the attribute table, and symbology for feature layers. The overall approach is sound and the test coverage is solid.

Bugs

[HIGH] URL-sourced feature layers are incorrectly treated as refreshable (arcgis-layer.ts:263): addGeoJsonLayer stores the FeatureServer URL as sourcePath and leaves metadata empty (no sourceKind, no externalNativeLayer flag). The refresh logic in layer-refresh.ts (refreshSourceUrl) exempts layers by checking for externalNativeLayer: true or a non-refreshable sourceKind, neither of which is set here. So isRefreshableLayer returns true for every URL-sourced feature layer. If a user enables auto-refresh, refreshGeoJsonLayer fetches the raw FeatureServer URL, receives ArcGIS service-metadata JSON (not a FeatureCollection), and the parse throws — potentially clearing the layer on every refresh tick. Fix: tag the layer with a sourceKind outside REFRESHABLE_GEOJSON_SOURCE_KINDS (e.g. "arcgis-feature-url"). Confidence: confirmed.

[MEDIUM] Portal-item sourcePath stores the item ID, not a URL (arcgis-layer.ts:263): For sourceType === "portal-item", input is the raw portal item ID (e.g. "abc123def456"). Passing it as sourcePath stores a non-URL on the layer record. Any path that reads sourcePath expecting a URL will receive a bare hex string. Fix: pass layerUrl (the resolved service URL) for both source types. Confidence: confirmed.

Performance / Correctness

[MEDIUM] Silent truncation when exceededTransferLimit is true (arcgis-layer.ts:303-308): Only a console.warn is issued when the service caps the response at maxRecordCount. The truncated copy is now frozen permanently in the Zustand store and will appear complete in the attribute table and any export — a regression from the old URL-backed source (which was dynamic). A non-blocking UI notification would prevent silent data loss. The inline comment on line 302 also misstates the comparison with the old path. Confidence: confirmed.

Tests

[LOW] Fragile mock discriminator (arcgis-feature-layer.test.ts:38): url.includes("/query") misfires for service paths that contain the substring /query in the URL (e.g. .../services/QueryZones/FeatureServer/0), causing the layer-info mock to return GeoJSON instead of layer metadata, which lacks geometryType and throws the wrong error. Switching to url.includes("f=geojson") is more reliable since that parameter is set exclusively on the query URL. Confidence: confirmed (suggestion included inline).

Checked and found OK

  • Auth token: appended only to ephemeral local fetch variables; not persisted in sourcePath, the store, or the project file.
  • XSS from ArcGIS responses: all rendering paths use DOM text nodes, React JSX escaping, or DOMPurify — no HTML injection surface.
  • Removed helpers (createStaticArcGISRuntimeLayer, arcgisGeometryLayerType, arcgisFallbackPaint): correctly superseded by the GeoJSON layer path.
  • fetchArcGISGeoJson validation: the type !== "FeatureCollection" || !Array.isArray(features) check correctly handles both malformed-type and missing-features cases.

@giswqs giswqs merged commit f83cbdc into main Jun 25, 2026
24 of 25 checks passed
@giswqs giswqs deleted the fix/issue-867-arcgis-feature-labels branch June 25, 2026 15:04
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
Comment thread tests/arcgis-feature-layer.test.ts
Comment thread packages/plugins/src/plugins/arcgis-layer.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed packages/plugins/src/plugins/arcgis-layer.ts, packages/map/src/layer-sync.ts, and tests/arcgis-feature-layer.test.ts. The overall approach is sound — promoting feature layers from opaque external-native layers to first-class GeoJSON layers is the right fix for the label/attribute problem, and the error-handling in fetchArcGISGeoJson (HTML detection, ArcGIS error envelopes, type validation) is notably thorough. The token-in-request-URL / token-excluded-from-sourcePath split is handled correctly and tested. Findings below.


Bugs

  • Attribution timing is fragile (medium confidence) — line 279 addGeoJsonLayer creates the layer with source: { type: "geojson" } and fires a Zustand set(), then updateLayer writes the attribution in a second set(). With React 19 automatic batching both mutations collapse into one render and MapLibre gets the attribution in the same syncGeoJsonLayer call — so the current code works. However syncGeoJsonLayer only writes attribution when creating the source (!map.getSource(src)); a second sync call skips that branch. Any future flushSync usage on a calling path would break batching and silently drop the watermark from MapLibre's attribution control. The fix is to make the write atomic (pass attribution directly into addGeoJsonLayer rather than patching it afterward).

Quality

  • exceededTransferLimit invisible to users (medium confidence) — lines 334–340 console.warn is only visible to someone with DevTools open. A user who exports or analyses a truncated layer has no indication their dataset is partial. Appending " (partial)" to the layer name, or emitting a UI notification, would address this.

  • HTML-detection regex too broad (low confidence) — line 309 /^\s*</ catches any XML/SVG document (including ArcGIS Enterprise SOAP error envelopes), but the error message says "HTML instead of GeoJSON". Tightening to /^\s*<[!?a-zA-Z]/ avoids the mismatch. Suggested in the inline comment.

  • updateLayer shallow-merges and replaces the whole source object (low confidence) — line 279 updateLayer does { ...layer, ...patch }, so passing { source: { type: "geojson", attribution } } overwrites the entire source field. It's harmless right now because the initial source is minimal, but a future addition to addGeoJsonLayer's source shape would be silently discarded by this call.

Tests

  • Attribution asserted in store but not in MapLibre (low confidence) — test line 116 The test checks layer.source.attribution (the store value). A complementary assertion that the same value reaches the MapLibre addSource spec (via a mocked map.addSource) would guard against the timing regression described above.

CLAUDE.md

No violations found. The new code does not mutate MapLibre directly from UI (correct store-driven pattern), does not add hardcoded tile/host URLs that would need CSP additions, and the test file follows the existing node:test + tsx loader pattern.

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 - Label formatting not working on added suggested vector layers

1 participant