mapbox / mapbox/mapbox-gl-js

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

Open Beginner friendly
#13,714 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auto-triaged bug :lady_beetle:
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.