ArcGIS JS SDK → Honua JS SDK migration punch list

This is an honest accounting of where the Honua JS SDK's two public-facing claims stand:

  1. "Parity with the ArcGIS JS SDK" — the symbol surface app authors can import and call against.
  2. "Automated app conversion" — the src/migration codemod that rewrites ArcGIS imports/constructors to Honua compat shims.

It is intentionally written so a reader can tell what is shipped, what is partial, and what is not started. None of the entries below are aspirational — they are concrete work items.


What is true today

Parity surface (src/esri-compat/)

The compat module ships shims for the constructors enumerated in src/migration/codemod.ts::SUPPORTED_ARCGIS_MODULE_KIND_BY_PATH. Every kind in that map has a row in parity-matrix.ts, and every row is asserted by test/migration-parity-matrix.test.ts. The shims back onto real Honua surfaces in src/core/ (HonuaClient, HonuaFeatureService, HonuaImageService, HonuaMapService, …) rather than being throwaway no-ops. Specifically:

The MapViewLayerViewCompat returned from view.whenLayerView() now carries filter / effect / visible state and forwards filter predicates through queryFeatures / queryFeatureCount / queryObjectIds, merging where, objectIds, geometry, spatialRelationship, and timeExtent with any per-call options (layer-view filter ∧ caller-provided options).

Sketch and Editor now accept ArcGIS-shaped snappingOptions (enabled, distance, featureEnabled, selfEnabled, featureSources) and map them onto the renderer-neutral SnappingConfig from @honua/sdk-js/contract (snappingOptionsToSnappingConfig). The contract snapping engine (SnapIndex + resolveSnapCandidate) resolves vertex / edge / feature candidates deterministically over a packed-grid spatial index, invalidates on optimistic edit apply/rollback (createSnapIndexEditSessionHooks), and the MapLibre runtime binding (bindEditSketchSnapping in @honua/sdk-js/runtime) wires pointer-move, snap/unsnap events, and a default indicator layer. selfEnabled is stored for parity but self-snapping needs the host to index the active sketch source; 3D snapping is out of scope with the rest of the SceneView surface.

Automated app conversion (src/migration/codemod.ts)

The codemod is a real AST rewriter built on the TypeScript Compiler API (it does not use regex). It:

Everything in this section is covered by tests under test/ (migration-codemod.test.ts is 75 cases; migration-report.test.ts, migration-gating.test.ts, and migration-parity-matrix.test.ts hold the surface stable).


What the claim does not yet cover

Parity gaps

  1. 3D / SceneView. SceneView exists as a compat class but it shares the 2D MapView behavior under the hood. Anything that requires a WebGL/CesiumJS path (environment, viewingMode: "global", goTo with altitude, Ground, ElevationLayer, scene layers, Camera, qualityProfile) is not implemented. The codemod sets the scene-3d-detected blocking flag when it sees such imports, so the gate fails closed.

  2. Scene layers. SceneLayer, BuildingSceneLayer, IntegratedMeshLayer, PointCloudLayer, MeshLayer, ElevationLayer, VoxelLayer have no shims. These all need server-side glTF/I3S support (honua-server ticket).

  3. Geoprocessor / NetworkAnalyst (beyond RouteTask). Locator is no longer in this bucket: LocatorCompat shims addressToLocations, addressesToLocations, locationToAddress, and suggestLocations onto the provider-pluggable geocoding contract in src/geocoding/, and LocatorSearchSourceCompat is the Search widget's address backend — client-side, with no honua-server surface. The caller supplies the endpoint (Nominatim / Photon / Pelias / a Honua GeocodeServer), so the codemod rewrites the constructor but cannot choose the provider; that one wiring step stays assisted, and @arcgis/core/rest/locator function imports are rewritten with a TODO for the URL-to-LocatorCompat swap. Provider capability misses (no suggest on Nominatim) throw HonuaCapabilityNotSupportedError instead of degrading to a forward geocode. Batch geocoding is client-side — one provider request per address, not a server batch endpoint. Still unshimmed: service-area, OD-cost-matrix, closest-facility, and non-network geoprocessing, which do need their own surfaces under src/core/ and matching compat classes.

  4. Query argument depth. Shipped (Task E). The codemod now deep-transforms new Query({...}) argument literals into the Honua QueryFeaturesRequest shape: renames startresultOffset, numresultRecordCount, outSpatialReferenceoutSr, spatialRelationshipspatialRel; splits geometry: { type, ...rest } into a sibling geometryType field with the matching esriGeometry* enum value; and lowercases outStatistics[i].statisticType against the STATISTIC_TYPE_MAP keyset (count/sum/min/max/avg/stddev/var). Divergent / dynamic shapes emit a manual TODO with a precise reason — specifically: timeExtent (no Honua field; use extraParams.time), quantizationParameters (server-side optimization with no Honua equivalent), relationParam (no Honua support), geometry without a string-literal type discriminator, and outStatistics items with a non-literal statisticType or an unknown enum value. The shim QueryCompat accepts either ArcGIS or Honua property spellings (spatialRelationship / spatialRel, outSpatialReference / outSr, num / resultRecordCount, start / resultOffset) so codemod output and hand-written callers both validate.

  5. Popup actions / popup templates with actions, outFields: ["*"] expansions, and fieldInfos formatters. These rewrite cleanly only for the simple case; arrow-function field-info format callbacks fall through to manual TODO.

  6. Widget UI behavior. The widget shims accept the same constructor options ArcGIS does, but only LegendCompat and LayerListCompat render, through the Honua widget host (HonuaWidgetHost) and only after the app registers the web-component kit — see widget kit registration. Their visual parity (icons, ARIA, CSS class names that downstream apps style against) is not byte-identical; apps that rely on calcite-action---prefixed selectors will need style work. Every other container-bearing shim is state-model-only and needs the application to render it.

  7. Typed-surface divergences from the emulated ArcGIS shape. The shims are structurally compatible with the ArcGIS surface they emulate except where this list says otherwise. The audit of array-valued properties (#1013) settled as follows.

    Array mutability — fixed, no divergence. ArcGIS declares these as mutable arrays and the shims now do too: FeatureFilterCompat.objectIds, GeoJSONLayerCompat.outFields, GeoJSONLayerCompat.fields, ImageryLayerCompat.bandIds, WFSLayerCompat.outFields. Each is copied out of the caller's input on construct/update, so layer.outFields.push("TYPE") is honored (the shim reads the array it stored) without aliasing the array the caller passed in. The corresponding *Options input properties stay ReadonlyArray on purpose: an input that accepts a readonly array is strictly more permissive than ArcGIS's, so it is not a divergence.

    FeatureFilterCompat.objectIds element type — deliberate, kept. ArcGIS declares number[]; the shim declares Array<number | string>. Honua's non-Esri sources (OGC API Features, GeoJSON, STAC) carry string feature ids, and narrowing the property to number would make the shim unable to hold ids it has to round-trip through queryFeatures / queryObjectIds. Because (number | string)[] is not assignable to number[], handing a compat filter straight to an un-migrated ArcGIS construct still does not typecheck — so the shim exposes FeatureFilterCompat.toEsriProperties(), which projects onto the exact ArcGIS FeatureFilterProperties shape and throws on an id ArcGIS cannot represent rather than silently dropping it and returning a filter that selects a different feature set. The codemod does not create that handoff on its own any more; see codemod gap 7.

  8. reactiveUtils.watch semantics (partial). Compat watch is property-name-based and synchronous. ArcGIS watch accepts an accessor function and returns a WatchHandle with pause() / resume(). Single-property accessors — reactiveUtils.watch(() => view.scale, h) where view is a tracked compat instance — are now rewritten by the codemod to reactiveUtils.watch(view, "scale", h). Multi-property accessors (() => [view.scale, view.zoom]) and complex bodies still fall through to a manual TODO. pause() / resume() on the handle is still unsupported.

Codemod gaps

These are the load-bearing pieces required to turn "rewrites known constructors" into "automated app conversion":

  1. Event-name remap (Task D, partial). The codemod now rewrites well-known divergent event names on .on() and .watch() calls in files that have at least one ArcGIS import being touched. The dictionary is in ARCGIS_TO_COMPAT_EVENT_REMAP in src/migration/codemod.ts and currently covers layerview-create*, layerview-destroy, visibility-change, extent-change, rotation-change, scale-change, zoom-change, center-change, spatial-reference-change, padding-change, constraints-change, highlight-options-change, basemap-change, ground-change, portal-item-change, active-basemap-change, camera-change, quality-profile-change, viewing-mode-change, and refresh. Receiver-aware scoping is now in place: the rewrite only fires when the receiver is an Identifier whose binding came from new XCompat(...) where X was a tracked ArcGIS import (via resolveAssignedIdentifierForNewExpression). Receivers that don't trace back to a tracked compat instance (e.g., someEmitter.on("layerview-create", h)) are left alone even when the file has ArcGIS imports. What is still missing: pointer/keyboard event handlers (click, pointer-down, key-down, …) which compat doesn't emit yet.
  2. Dynamic / CJS imports (Task F, partial). Dynamic ESM imports (import("@arcgis/core/...")) are rewritten through the isArcGisDynamicImportCall pass and have test coverage for SceneView, esriConfig, esriRequest, and IdentityManager. CommonJS require("@arcgis/core/...") is now auto-rewritten for the honua-compat target when constructor options are safe: the codemod emits const { XCompat } = require("@honua/sdk-esri-compat"); and rewrites new X(opts) to new XCompat(opts) in .cjs files and .js files containing CommonJS markers. The original const X = require("@arcgis/core/...") declaration is now pruned when no other references to X remain in the file (constructor calls are the only references in the typical case; the codemod scans for non-constructor uses and preserves the require if it sees any). The esri-leaflet target still emits a manual TODO for CJS require constructors.
  3. Module re-exports beyond the registered set. The codemod already follows localArcGisReExports for one level. Deeper barrel-file chains (./esri.ts./layers/index.ts → …) aren't followed.
  4. view.map.add(layer) argument-shape transformations. When a new FeatureLayer({ ... }) is rewritten, its option literal is passed through verbatim. We do not translate ArcGIS-shaped properties whose Honua-side names differ (e.g., renderer.field casing, popupTemplate.content block shapes). The shim accepts the ArcGIS shape at runtime, but the report should flag known-divergent property values so app authors can confirm intent.
  5. WebMap JSON → MapLibre style derivation (honua-maplibre target). Partially shipped. src/map/webmap-maplibre.ts exposes webmapJsonToMapLibreStyle(webmap, options), a pure converter that delegates the heavy lifting to the existing src/webmap/ pipeline — parseWebMap orchestrates convertBasemap, convertOperationalLayer, convertRenderer, convertPopupInfo, convertLabelingInfo, and convertExtent/convertInitialViewpoint — and returns a HonuaStyleSpecification ready for map.setStyle(...) plus a structured manualGaps: WebMapMapLibreManualGap[] array. manualGaps is the single source of truth for unsupported constructs: every entry carries { kind, reason, path?, layerId?, expression?, context? } and the kind taxonomy covers arcade-expression (popup expressionInfos and complex Arcade label expressions), unsupported-renderer (heatmap, dotDensity, and any other renderer the converter doesn't handle), scene-3d (top-level ground / camera / viewingMode, ArcGISSceneServiceLayer, elevationInfo, heightInfo, sceneProperties), dashboard-reference, experience-builder-reference, custom-widget-reference (applicationProperties.viewing.widgetsOnScreen and the top-level widgets array used by host shells), plus pass-throughs for unsupported-symbol, unsupported-layer-type, unsupported-feature-collection, and unsupported-webmap-version. The migration codemod now wires this converter into its honua-maplibre constructor-rewriting pass: any new WebMap({ ... }) call whose argument is a static WebMap JSON object literal (top-level operationalLayers and/or baseMap, fully literal values, no portalItem) is rewritten to webmapJsonToMapLibreStyle({ ... }) from @honua/sdk-js/map, and the helper's manualGaps array is propagated into the migration report as per-gap web-map manual TODOs (Arcade, unsupported renderer, scene/3D, dashboard shell, …). What still requires manual review: dynamic / portal-loaded WebMaps (new WebMap({ portalItem: { id: someId } }), WebMap.load(), identifier references, computed properties, spreads) still emit a web-map manual TODO with no rewrite — they need a runtime fetch the codemod cannot synthesize. The gap surface is also intentionally honest about its ceiling, since Arcade execution, scene/3D rendering, Dashboard widget hosts, Experience Builder pages, and custom-widget plugins all require runtime support the JS SDK cannot synthesize from JSON alone. Each emitted gap is a // TODO anchor for an app author, not a promise of automated migration.
  6. Compat → ArcGIS seams (detected, not bridged). Shipped as detection (#1012). A file can hold an in-scope producer whose only consumer is out of scope — the shape the OSS-corpus deep lane hit in lujoh/owls_of_bavaria, where a FeatureFilter feeds an @arcgis/core/layers/support/FeatureEffect the codemod does not migrate. Rewriting only the producer leaves a compat value in an un-migrated ArcGIS constructor's hands: a seam that does not typecheck, previously scored as a clean auto-migration. The codemod now detects the handoff syntactically — new X() directly in an out-of-scope ArcGIS consumer's argument, a local bound to one, or a local function that returns one, plus assignment onto an out-of-scope instance's property — holds the rewrite back, and emits a manual TODO naming both sides. Seam call sites are counted in manualCallSites and broken out as metrics.seamCallSites / JsMigrationReport.seamCallSites; they are never counted as auto-migrated. What is not done: the codemod does not bridge the seam (no inserted toEsriProperties() call, no consumer rewrite), and the analysis is file-local and bounded by design — a value that crosses a module boundary or moves through a data structure it cannot follow is not reported.
  7. End-to-end demo conversion. Shipped at examples/arcgis-source-app/ + test/migration-e2e.test.ts. The sample is a hand-written parcel viewer exercising FeatureLayer (with outFields + popupTemplate), Map, MapView, an untouched view.on("click", …) handler, and an event-name-remapped layer.on("layerview-create", …) handler. The e2e test copies it into a tempdir, runs the codemod with target: "honua-compat", asserts every expected rewrite landed and the readiness gate is "ready", and then runs tsc --noEmit against the migrated source with paths resolving @honua/sdk-esri-compat to the workspace src/esri-compat/ entry. The CI job runs this harness immediately after the unit test step. Remaining work: extend the sample to additional layer kinds (Imagery / VectorTile / WMS / WFS / GeoJSON) so the harness exercises the full surface, not just the parcel viewer slice.

What's required to make the claim defensibly true

In rough effort order:

Bucket Effort Notes
Codemod event-name remap (Task D) 1–2 days Pure JS-side work; needs an event-name dictionary plus AST pass.
Codemod dynamic-import + CJS (Task F) 1–2 days Mirror the static-ESM path; care needed for awaited dynamic imports' destructuring.
Query argument deep-transform (Task E) 3–5 days Shipped: codemod renames start/num/outSpatialReference/spatialRelationship, splits geometry into geometry+geometryType, normalizes outStatistics[i].statisticType against STATISTIC_TYPE_MAP, and emits precise manual TODOs for timeExtent, quantizationParameters, relationParam, dynamic statisticType, and geometry without a literal type. QueryCompat accepts both ArcGIS and Honua property spellings.
Locator compat (Task C, geocoding half) 1 week Shipped: LocatorCompat + LocatorSearchSourceCompat ride the provider-pluggable geocoding contract, so no HonuaGeocodeService was needed. The provider endpoint is caller-configured, which keeps the wiring step assisted.
Geoprocessor compat (Task C, remainder) 1–2 weeks Requires a HonuaGeoprocessService surface (server work); service-area / OD-cost-matrix / closest-facility likewise.
Scene-layer compat (Task A-rest) 4–8 weeks Requires I3S/glTF pipeline on honua-server; not pure-JS work.
E2E demo conversion harness (Task I) 1 week Shipped: examples/arcgis-source-app/ + test/migration-e2e.test.ts, wired into the JS SDK CI workflow after the unit-test step.

With Tasks D/F/E and the geocoding half of Task C now shipped, "automated app conversion" reaches deterministic constructor rewrite + event-name remap + dynamic/CJS imports + Query argument deep-transform + Locator/Search address backends, plus a manual-TODO report for everything else. The remaining rows (Geoprocessor/NetworkAnalyst and Scene-layer compat) still gate the unqualified "parity with ArcGIS JS SDK" claim — that should remain qualified as "deterministic 2D parity; scene/3D and advanced tasks remain assisted migration" until they land.