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.
mapbox-gl-js version
3.28.1 (since 3.21)
Browser and version
151.0.7922.77
Expected behavior
setFeatureStatefollowed bysetPaintPropertywill not throw an error and crash the map when asymbollayer did not have anyfeature-stateexpression.Actual behavior
Map is not responsible and throws this error
Link to the demonstration
https://jsbin.com/zesarepibo/1/edit?html,console,output
Steps to trigger the unexpected behavior
you can fix it by
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.stateDependentLayerIdsis computed fromStyleLayer.isStateDependent()(src/style/style_layer.ts:396), which reportstrueonly if some paint property holds a state-dependent source/composite expression:For a symbol layer with no
feature-stateexpression in its paint this yields[], sobucket.stateDependentLayersis an empty array.In
Tile.updateBuckets, the layer list handed tobucket.update()is switched onwithStateUpdatesalone, while the guard below it accepts three additional, unrelated reasons to enter the block:Note that
withStateUpdatesis derived purely fromObject.keys(sourceLayerStates).length > 0. The states are read from the source cache (src/source/tile.ts:785), so anysetFeatureStatecall on the source makes ittrueregardless of whether a layer consumes that state.When
stateDependentLayersis empty buthasPaintUpdateorneedsSymbolUBOUpdateistrue, the first operand of the||chain isfalsewhile one of the others istrue: the block executes, yetlayersis still the emptystateDependentLayersarray.SymbolBucket.updatethen dereferences its first element unconditionally:layers[0]isundefined, andhasStateDependentPaintreadslayer.painton its first line:The
b &&short-circuit does not help:SymbolBucket.createArraysalways constructs both UBO binders, so at least one is truthy for every symbol bucket.The pure feature-state path is not affected:
Tile.refreshFeatureStatecallsupdateBucketswithneedsSymbolUBOUpdateandupdatedPaintPropsundefined(src/source/tile.ts:747), so the guard correctly keeps the block from running for an emptystateDependentLayers. Only theTile.preparepath (src/source/tile.ts:567), which passeshasImageCountChanged || hasPaintUpdate || hasTransition, reaches the crash.Suggested fix
Make the layer list agree with the guard, at
src/source/tile.ts:791:This is strictly narrowing: whenever
stateDependentLayersis non-empty the behavior is unchanged. In the previously crashing case,layers[0]becomes the real layer,hasStateDependentPaintreturnsfalse,bindersis 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
layersargument and pass it toprogramConfigurations.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.