Skip to main content

Layer prop flow and identity stability

This is an architecture note for anyone working on layer rendering inside @spatialdata/vis or writing extension layers that plug into SpatialCanvas. It exists because we have repeatedly tried to "fix" cosmetic-prop-triggered network activity by adding bespoke caches, global stores, and visual-vs-structural enumerations — all of which were the wrong answer. This page documents the right answer once.

TL;DR

  1. Layers are pure functions of their props. renderLayers() constructs layer instances from props; it never does async work, never reads globals, never maintains side-channel state.
  2. Identity stability is the producer's job. useLayerData must hand the same object reference for loader, selections, etc. across renders when the value hasn't changed. Cosmetic prop identity may churn freely.
  3. updateTriggers is the only mechanism that declares "structural". When a prop's change should invalidate downstream async work (tile fetching, re-loading data), it goes into updateTriggers on the layer that owns the side effect. There is no parallel registry.
  4. Viv image layers are an adapter boundary, not an exception. Images still follow the same deck rule. Viv constructs the actual image layers, and Viv 0.21's multiscale image tile layer declares [loader, selections] as the getTileData trigger set.

If you follow those rules, deck.gl's existing layer matching + prop diffing handles the rest. Cosmetic prop tweaks repaint without touching the tileset cache; structural changes invalidate via updateTriggers and refetch. Nothing more is needed.

Why this matters

TileLayer.updateState in deck.gl only calls tileset.reloadAll() when changeFlags.dataChanged is true, and dataChanged is computed only from changeFlags.updateTriggersChanged.getTileData (or the data prop changing its dataComparator-equality). Every other prop change at most clears tile.layers so the lightweight sub-layer wrappers regenerate — the tileset itself, and all its cached tile data, is preserved.

This means cosmetic prop changes already "just work" — provided the producer is not feeding deck a fresh loader reference or fresh selections array on every render. When it is, deck sees the structural prop change and dutifully refetches. The fix is upstream stability, not downstream caching.

The contract

For useLayerData (the producer)

  • The public ordered input is moving toward RenderStack (renderStack.entries) from @spatialdata/layers. The resolver may normalize that into lookup maps internally, but entry order and identity come from the stack, not a parallel public layerOrder array.
  • loader: same object reference until the underlying element changes.
  • selections: same array reference until the selected values change. Memoize with useMemo keyed on a stable signature of the selection values.
  • Layer caches for derived visual state must be keyed by layer identity when the value can differ between two layers that point at the same element. A single shapes element may appear in multiple layer configs with different opacity, filtering, table-driven colour encodings, or other visual properties.
  • Cosmetic props (colors, contrastLimits, channelsVisible, opacity, per-channel arrays, modelMatrix): identity may churn freely. Deck diffs them efficiently and updates uniforms without disturbing tile loading.
  • Channel control values that are derived from LayerConfig.channels and a loaded fallback should still be memoized to keep render output cheap, but identity stability is not required for correctness.
  • World bounds are structural too. Computing polygon bounds can be O(n-vertices), so bounds must be cached by loaded data reference plus transform reference. Opacity, color, visibility toggles inside the properties pane, and other cosmetic layer edits must not re-run boundsFromPolygons / accumulatePolygonBounds.
  • Keep expensive fitting work behind command/effect boundaries. Render should ask cheap questions such as "is this layer visible and renderable?" rather than computing bounds just to decide whether a button looks enabled. The actual bounds lookup belongs in the button handler or the guarded auto-fit effect.

For image layers through Viv

SpatialCanvas image rendering currently routes through Viv DetailView.getLayers(), which creates Viv ImageLayer / MultiscaleImageLayer instances. That means @spatialdata/vis does not directly own the image tile layer class, but it still owns the props it passes to Viv.

  • Treat Viv's image layer as the tile-loading owner. In Viv 0.21, MultiscaleImageLayer sets updateTriggers.getTileData to [loader, selections].
  • Keep loader and selections identity-stable in useLayerData. Cosmetic image props (colors, contrastLimits, channelsVisible, opacity, modelMatrix) can flow through as normal props.
  • VivSpatialViewer may call detailView.getLayers() on each render. The important requirements are stable layer ids, passing the complete prop bag into Viv/deck, and avoiding any viewer-local classification of prop names.
  • Do not patch Viv-created layers by spreading layer.props after creation. Some Viv/deck props, including extension defaults, are not safe to preserve with object spread. Pass props into Viv up front, then use layer.clone() only for identity-neutral deck props such as the final layer id.
  • If a future Viv version changes the image tile trigger set, update this note and add or adjust an image behavioral test in the same PR.

For layer authors (LabelsLayer, future custom layers)

  • Pass cosmetic props through getSubLayerProps; do not enumerate them by name in a "visual props" helper.
  • Declare structural dependencies on the tile-loading sublayer with updateTriggers: { getTileData: [loader, selectionsKey] }. This is the single, declarative source of truth for "what counts as structural here."
  • Do not maintain module-level caches keyed by stringified layer ids.
  • Do not override initializeState / finalizeState / updateState / getSubLayerPropsByTile to read from globals. Cosmetic state flows through React → layer props → deck diff, end of story.
  • Accept arbitrary extra sub-layer overrides via the standard deck.gl _subLayerProps escape hatch. New extension props "just work" without changes to the layer or the producer.

For VivSpatialViewer

  • Composes vivLayerProps and extraLayers from the producer.
  • Adds the Viv viewport-id suffix to layer ids for the layer-filter machinery. That is the only structural change it should make to incoming layers.
  • Must not extract, classify, or re-route props by name. The viewer is transparent to whatever props the producer or extensions chose to pass.
  • It should not maintain viewer-local layer caches unless there is runtime evidence that deck's layer matching cannot preserve the relevant Viv layer state. If such a cache becomes necessary, the cache key must be structural only (loader, selectionsRef, and any future Viv-declared tile trigger), and cosmetic updates must still flow through deck-native props.

Anti-patterns (do not reintroduce)

These have been tried, do not work, and add design debt that obscures the actual problem. If you see yourself reaching for any of them, stop.

Anti-patternWhy it's wrong
layerPropGroups.ts-style files enumerating "visual" vs "structural" propsHand-rolled; silently breaks for any new prop a user-supplied extension might add.
Global stores keyed by layer id (e.g. labelsVisualStore)Bypasses React/deck; two SpatialCanvases sharing the same data can't have independent state; lifetimes become unmanageable.
Module-level Maps caching getTileData, layer instances, or tile shellsSingletons across all canvases; memory leaks; racy cleanup; keyed by ad-hoc strings like [object Object]\x00....
Hand-rolled layer matchers in viewer componentsReimplements LayerManager._transferLayerState.
Lifecycle overrides reading from globalsNot idiomatic deck; couples layer correctness to module-level state.
Cache-key unit tests in place of behavioral testsThey keep passing while the user still sees network activity.

Behavioral test pattern

The acceptance criterion for any change in this area is runtime evidence, not cache-hit counts. Set up a mock loader that increments a counter on getTile, render the canvas, drive a cosmetic prop change (opacity slider, channel color), and assert the counter did not change.

const fetchCount = { value: 0 };
const mockLoader = makeMockMultiscaleLoader({
onGetTile: () => { fetchCount.value += 1; },
});
const { rerender } = render(<SpatialCanvas {...propsWithLoader(mockLoader)} />);
await waitForTilesLoaded();
const before = fetchCount.value;
rerender(<SpatialCanvas {...propsWithLoader(mockLoader, { opacity: 0.5 })} />);
await flushUpdates();
expect(fetchCount.value).toBe(before);

One such test per layer type (image, labels, future custom layers) is enough to keep the contract honest.

Current audit checklist

Use this checklist when changing images, labels, shapes, or future layer types.

  1. Identify the tile-loading owner. For labels, that is LabelsLayer / its inner TileLayer. For images, that is Viv's MultiscaleImageLayer.
  2. Read the owner's updateTriggers.getTileData. The trigger list is the structural contract. Mirror it in the producer's identity-stability work; do not invent a second visual-vs-structural table.
  3. Keep adapter components transparent. Viewers may normalize ids and compose layers, but should not sort props into structural and cosmetic buckets.
  4. Test behavior, not cache mechanics. A cosmetic opacity/color/channel change should not produce new tile reads. A real selection/loader change should.
  5. Profile non-fetch structural work too. A cosmetic prop change should not rebuild precomputed shape arrays, re-decode geometry, or re-scan polygon vertices for world bounds. Tile fetches are only one symptom of a structural leak.
  6. Check render-time UI state for hidden geometry work. Buttons and panels should not call getWorldBoundsForLayer() unless they are executing a user command.

Shapes feature state and deck.gl performance

Shapes layers can carry hundreds of thousands to millions of features. Most deck.gl update cost on large layers is accessor re-execution (see deck.gl performance — optimize accessors), not React reconciliation. Treat shapes rendering with the same discipline as tile layers: minimize how often per-feature work runs, and prefer uniform props (opacity, radiusScale, etc.) for cosmetic changes.

What is structural vs cosmetic for shapes

ChangeCategoryExpected work
opacity, fillColor / strokeColor defaults, stroke width clampsCosmeticDeck uniform / attribute invalidation only; no geometry or feature-state rebuild
hiddenFeatureIds, fadedFeatureIds, per-feature colour maps, table-driven fill columnStructuralRebuild shapePrebuiltData and/or ShapeFeatureStateRuntime
Geometry load, transform, element keyStructuralReload / re-decode geometry

Cosmetic edits must not re-run RecordMap conversion, re-filter the feature list, or change updateTriggers keys that point at fresh objects every frame.

Producer contract (useLayerData)

  • shapePrebuiltData (keyed by layer id): built when geometry loads and when hiddenFeatureIds changes — not on opacity tweaks.
  • stableShapeFeatureStateRef (keyed by layer id): holds a ShapeFeatureStateRuntime (Maps/Sets) rebuilt only when the feature-state signature changes (hidden/faded ids, opacity multiplier, manual colour record identity, table fill-colour signature). Pass this into renderShapesLayer({ featureStateRuntime }) so getLayers() does not allocate on every render.
  • Merged feature-state objects must not be allocated each frame when only cosmetic layer props change. Table-driven fill colours are merged inside getStableShapeFeatureStateRuntime only when the signature changes.
  • Table-driven fill colours should be computed from @spatialdata/core-resolved rowIndexByFeatureIndex and the shared @spatialdata/layers colour encoder. Do not add local feature-to-row precedence rules in SpatialCanvas or layer modules.

Runtime attachments are not layer props

renderStack.entries[*].props is for serializable renderer input. Runtime functions and objects must stay outside that bag:

  • host overlay factories belong in hostLayerResolver
  • precomputed per-feature colour bytes belong in featureColorResolver — a Uint8Array in entry.props would be a lie about what a saved config is
  • feature listeners belong in onFeatureHover / onFeatureClick
  • raw deck listeners belong in onHover / onClick or deckProps
  • tooltip portals belong in tooltipContainer

These runtime attachments may read the resolved feature payload, including the same SpatialFeatureTooltipData used by built-in tooltips, but they must not be part of resource cache keys. Changing a listener should not reload images, geometry, bounds, shape prebuilds, or feature-state runtimes.

Layer contract (@spatialdata/layers)

  • buildShapeFeatureStateRuntime: converts serializable featureState records to Maps/Sets once. Accepts an existing runtime and returns it unchanged (isShapeFeatureStateRuntime).
  • normalizeShapeFeatureState: thin wrapper; use at layer boundaries when the caller may still pass records (tests, SpatialLayer).
  • updateTriggers for getFillColor / getLineColor: list the specific Maps/Sets/scalars that affect colour, not the whole featureState object.
  • buildShapeFillColorByFeatureId: converts a table column plus resolved row alignment into per-feature colours. It is renderer-agnostic and does not load tables or decide SpatialData feature/table association semantics.
  • Prefer constant accessors and layer opacity over per-feature callbacks when the visual change is uniform.

Anti-patterns (shapes)

Anti-patternWhy it's wrong
new Map(Object.entries(fillColorByFeatureId)) on every getLayers()O(n) allocation and GC pressure on large feature sets
Spreading featureState each render for table fill coloursDefeats WeakMap identity caches; triggers full accessor rebuilds
Resolving feature/table row precedence inside a colour encoderDuplicates @spatialdata/core association semantics and drifts from Python spatialdata
Putting featureState wholesale in updateTriggersAny new object identity invalidates all colour attributes
Per-feature accessors for layer-wide opacityUse deck opacity on the layer instead

Shapes render path: vertex-pulling FlatPolygonLayer

Circles/points still use ScatterplotLayer with per-feature colour accessors (their counts are modest). Polygon shapes — up to millions of features (Visium HD square_002um ≈ 2.7M) — render through a hand-rolled FlatPolygonLayer (@spatialdata/layers) instead of deck's SolidPolygonLayer/PolygonLayer, because accessor re-execution and a separate outline layer do not scale to that count.

  • Non-blocking, off-thread decode + tessellation. VShapesSource decodes the WKB geometry column into flat buffers and tessellates it into render topology inside the geometry worker (shapesPolygonTessellate), transferring both back zero-copy. Shapes never gate first paint (ShapesResolver.blockingResources = []).
  • Vertex pulling — no per-vertex attributes. The layer draws an attribute-less triangle list: gl_VertexID selects the triangle + corner, and the vertex shader fetches the topology and shared ring positions from two data textures, computing each vertex's position and a boundary edge-distance on the fly. This keeps geometry memory to two shared textures instead of large de-indexed attribute buffers, and imputes an anti-aliased outline with fwidth in the fragment shader — no separate outline layer.
  • Feature state = "table column → buffer". Colour-by-column, hide, and fade live in a small per-feature colour texture indexed by feature; a feature-state change re-uploads only that texture, never the (large) geometry textures. This is the reusable primitive for column-driven encodings; picking colours are computed in-shader from the feature index.

Keep hot paths allocation-free across cosmetic renders: the geometry textures and the per-feature colour buffer keep stable identities, so deck re-uploads only when the geometry or the feature-state runtime actually changes.

Deferred: emitting texture-ready (padded) buffers from the worker to shrink the main-thread GPU-upload cost; a WGSL variant (WebGPU can use storage buffers instead of texture-packing); a true polygon SDF path. See docs/plans/shapes-nonblocking-tiled-loading.md.

Labels render path: a label-id-indexed LUT

Labels reuse the primitive above, with the one substitution their data forces. A shape's colour texture is indexed by feature index; a label has no geometry — it is a raster pixel value — so its table is indexed by the label's own integer instance id, and the bitmask fragment shader samples the instance-id raster and looks the integer up.

What carries over is the property that matters: a feature-state change re-uploads only the small table, never the tiles.

  • The LUT texture is owned by LabelsLayer and shared across tile sublayers. Per-tile textures would multiply a table that is already megabytes for a large segmentation.
  • Picking consults the same table, so a hidden label can never be picked.
  • Label 0 is background and never drawn, but is still filled in the table, so the shader reads it without a bounds branch per fragment.
  • LabelsResolver.fillColors is keyed by element and column — keying by element alone let two layers colouring one element by different columns evict each other on every plan.

Hover highlight: a uniform, not a table entry

The label under the cursor is drawn highlighted, matching what autoHighlight gives shapes. Deck's own machinery cannot do it here: a labels tile's picking colour covers the whole quad, so there is no per-label deck object for picking_filterHighlightColor to act on, and enabling autoHighlight would light up the entire tile. The highlight is resolved per fragment instead — the shader compares the sampled instance id against a highlightedLabelId uniform, rather than a LUT entry, so a pointer move re-uploads nothing.

  • Both canvas surfaces resolve the pick through one function. SpatialCanvasViewer and the full-UI SpatialCanvas each own a handleHover; the highlight shipped working in the first and dead in the second precisely because of that. resolveHoveredLabel in featureTooltipHover.ts is the single implementation both must call — when adding hover behaviour, add it there, not in one handleHover.
  • Hover is runtime render state, never Render Stack config. useLayerData keeps it on a ref plus a version counter (setHoveredLabel) so a saved view can never carry it, and so pointer motion within one label schedules no re-render. Points already carry their highlight the same way.
  • One slot, not a per-layer map. Only one thing is under the cursor at a time, so "moved to a different labels layer" and "moved off" are the same transition.
  • resolveHighlightedLabel refuses background and hidden labels before the value reaches the shader, so an id that went stale between the pick and the frame cannot light up something the filter hides.
  • The tint reuses deck's highlightColor, with the same meaning (alpha is the blend weight), so one prop name covers shapes and labels. The labels default is declared in defaultProps; see the anti-patterns below for why it cannot be a use-site fallback.

Anti-patterns (labels)

Anti-patternWhy it's wrong
Building the LUT per tile sublayerDuplicates a multi-megabyte table across every tile
Returning a fresh FeatureColorBuffer wrapper each renderRe-uploads the texture every frame; keep the colors identity stable
Keying a colour resource by element aloneTwo layers, two columns, one element → eviction ping-pong that never settles
Carrying the hovered label in the colour LUTRe-uploads a multi-megabyte texture on every pointer move; use the uniform
Defaulting a prop deck also defines with props.x ?? MY_DEFAULTdeck fills its own default in, so the prop is never absent and your fallback never runs. This drew every labels hover in deck's navy highlightColor. Redefine it in the layer's defaultProps, which does override deck's

Shape/table annotation controls

Shape UI controls that expose table-backed values should not be limited to associated table obs columns forever. They should also be able to use extra annotation columns carried by the shapes element itself. Future work should extend the same concept to entries corresponding to var values in X / layers, once the core table/annotation API exposes those data sources cleanly.

When adding these controls, keep the visual encoding layer-specific: choosing one fill-colour column for a shapes layer must not affect another layer that renders the same shapes element.

The fill is the specified colour (a layer default or a data-column encoding); the outline is derived from it. @spatialdata/layers (deriveStrokeColor) lightens each feature's resolved fill for its outline, so adjacent shapes read as distinct — an outline the same colour as the fill is invisible at the thin default width, which is why shapes previously did not read as shapes. A genuine per-feature stroke override still wins over the derivation, so the vis layer must not pre-mirror the fill into strokeColorByFeatureId (that would look like an explicit override and defeat the derivation). On the object (ScatterplotLayer) path this is a per-feature accessor. On the polygon (FlatPolygonLayer) path the outline is imputed in the fragment shader from the per-vertex boundary edge-distance — no separate outline layer — and lightens the fill there; its width is capped to a fraction of each shape's on-screen size and fades out entirely for sub-pixel shapes, so it stays clear when zoomed in but never dominates (or aliases into moiré) when zoomed out. A genuine per-feature stroke override on the polygon path is a follow-up; today it always lightens the fill.

See also