>= v3.21 Symbol layer without feature-state expression crash the map
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 12.4k
- Forks
- 2.4k
- PR merge metrics
- No merged PRs in 30d
Description
### 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:
```ts
// 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:
```ts
// 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:
```ts
// 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:
```ts
// 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`:
```diff
-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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/source/tile.ts around updateBuckets and compare the layer selection with its update guard. Read the related SymbolBucket.update path in src/data/bucket/symbol_bucket.ts and the state-dependent paint handling in symbol_property_binder_ubo.ts. Reproduce the linked example, then verify that setFeatureState followed by setPaintProperty no longer crashes a symbol layer without a feature-state expression.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- computer-graphics, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100