Skip to content

>= v3.21 Symbol layer without feature-state expression crash the map #13714

Description

@enersis-pst

mapbox-gl-js version

3.28.1 (since 3.21)

Browser and version

151.0.7922.77

Expected behavior

setFeatureState followed by setPaintProperty will not throw an error and crash the map when a symbol layer did not have any feature-state expression.

Actual behavior

Map is not responsible and throws this error

symbol_property_binder_ubo.ts:770 Uncaught TypeError: Cannot read properties of undefined (reading 'paint')
at Ud.hasStateDependentPaint (symbol_property_binder_ubo.ts:770:29)
at b (symbol_bucket.ts:1459:95)
at Array.filter ()
at q_.update (symbol_bucket.ts:1459:76)
at fs.updateBuckets (tile.ts:795:24)
at fs.prepare (tile.ts:567:14)
at gs.prepare (source_cache.ts:222:18)
at La.render (painter.ts:936:29)
at Map._render (map.ts:4622:26)
at paintStartTimeStamp (map.ts:5071:26)

Link to the demonstration

https://jsbin.com/zesarepibo/1/edit?html,console,output

Steps to trigger the unexpected behavior

  • (add your mapbox token)
  • runs the example
  • see error on the debug log

you can fix it by

  • uncomment paint: { "icon-halo-width": ["case", ["boolean", ["feature-state", "__probe"], false], 0, 0] },

Use Case

We had two layers which shares one source (line, symbol). Only line has feature-states, symbol layer did not.
Feature-State rule was not added on init but later.

Technical description (from claude)

SymbolBucket.stateDependentLayerIds is computed from StyleLayer.isStateDependent() (src/style/style_layer.ts:396), which reports true only if some paint property holds a state-dependent source/composite expression:

// src/data/bucket/symbol_bucket.ts:754
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);

For a symbol layer with no feature-state expression in its paint this yields [], so bucket.stateDependentLayers is an empty array.

In Tile.updateBuckets, the layer list handed to bucket.update() is switched on withStateUpdates alone, while the guard below it accepts three additional, unrelated reasons to enter the block:

// src/source/tile.ts:789-795
const withStateUpdates = Object.keys(sourceLayerStates).length > 0 && !isBrightnessChanged;
...
const layers = withStateUpdates ? bucket.stateDependentLayers : bucket.layers;
if ((withStateUpdates && bucket.stateDependentLayers.length !== 0) || isBrightnessChanged || hasPaintUpdate || needsSymbolUBOUpdate) {
    ...
    bucket.update(sourceLayerStates, sourceLayer, images, imagePositions, layers, isBrightnessChanged, brightness, this.tileID.canonical);

Note that withStateUpdates is derived purely from Object.keys(sourceLayerStates).length > 0. The states are read from the source cache (src/source/tile.ts:785), so any setFeatureState call on the source makes it true regardless of whether a layer consumes that state.

When stateDependentLayers is empty but hasPaintUpdate or needsSymbolUBOUpdate is true, the first operand of the || chain is false while one of the others is true: the block executes, yet layers is still the empty stateDependentLayers array. SymbolBucket.update then dereferences its first element unconditionally:

// src/data/bucket/symbol_bucket.ts:1452-1459
} else if (Object.keys(states).length > 0) {
    // Update specific features when feature-state changes.
    // Skip when no paint property reads feature-state — ...
    const symbolLayer = layers[0] as SymbolStyleLayer;
    const binders = [this.text.uboBinder, this.icon.uboBinder].filter(b => b && b.hasStateDependentPaint(symbolLayer));

layers[0] is undefined, and hasStateDependentPaint reads layer.paint on its first line:

// src/data/bucket/symbol_property_binder_ubo.ts:769-770
hasStateDependentPaint(layer: SymbolStyleLayer): boolean {
    const paint = layer.paint;

The b && short-circuit does not help: SymbolBucket.createArrays always constructs both UBO binders, so at least one is truthy for every symbol bucket.

The pure feature-state path is not affected: Tile.refreshFeatureState calls updateBuckets with needsSymbolUBOUpdate and updatedPaintProps undefined (src/source/tile.ts:747), so the guard correctly keeps the block from running for an empty stateDependentLayers. Only the Tile.prepare path (src/source/tile.ts:567), which passes hasImageCountChanged || hasPaintUpdate || hasTransition, reaches the crash.

Suggested fix

Make the layer list agree with the guard, at src/source/tile.ts:791:

-const layers = withStateUpdates ? bucket.stateDependentLayers : bucket.layers;
+const layers = withStateUpdates && bucket.stateDependentLayers.length !== 0 ? bucket.stateDependentLayers : bucket.layers;

This is strictly narrowing: whenever stateDependentLayers is non-empty the behavior is unchanged. In the previously crashing case, layers[0] becomes the real layer, hasStateDependentPaint returns false, binders is empty, and the feature-state branch is skipped — the outcome the source comment already describes.

A guard in SymbolBucket.update (if (!symbolLayer) return;) would also stop the crash, but leaves the empty array flowing into the other consumers of that parameter.

One related thing that may be worth checking separately: non-symbol buckets receive the same layers argument and pass it to programConfigurations.updatePaintArrays. With an empty array that call becomes a no-op, so a data-driven paint change on a non-state-dependent fill/line/circle layer may be silently dropped while feature state exists on its source. We have not reproduced that, only observed the shared code path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions