Package: @thatopen/components-front 3.4.4 — packages/front/src/fragments/Outliner/index.ts
What happens
Outliner holds mutable group state and executes it concurrently.
Calling Outliner.clean(group) + Outliner.addItems(map, group) concurrently result in a race condition. Outlines of elements are left on screen, and some never go away — not even after clean(group).
Why
A group is updated from several places, and nothing serializes them:
addItems / removeItems / updateFromStyles call updateGroup(name);
bindModelTileEvents calls updateGroup(groupName) fire-and-forget on every model.tiles.onItemSet / onItemDeleted — continuously while the camera moves.
updateGroup then:
- copies
state.attached into previouslyAttached before await Promise.all(queries);
- after the await, attaches every returned tile, detaches
previouslyAttached minus the new result, and assigns state.attached = nextAttached.
Two overlapping updates U1 (older map) and U2 (newer map) both copy the same attachments. If U1 lands after U2, it attaches the older map's tiles and replaces state.attached with its own result — so U2's tiles are still attached to the pass but no longer tracked. clean / remove go through detachAll, which detaches only state.attached, so those proxies stay drawn indefinitely. Separately, an update still awaiting its queries when clean(group) runs re-attaches the map it started with (it read state.map before the clean replaced it).
Reproduction (deterministic, no WebGL)
With a stub outline pass that records attach/detach, and a model whose getItemDrawChunks the test resolves by hand (one tile per item):
addItems({ chunk: {1} }, "g"), answer the query → tile 1 drawn.
- Fire the model's
tiles.onItemSet listener → an update for {1} is waiting on its query.
clean("g"), then addItems({ chunk: {2} }, "g"), answer the second query → tile 2 drawn.
- Answer the tile update's query → tile 1 is drawn again.
clean("g") → tile 2 is still drawn, and nothing tracks it.
Proposed fix
Per group, run one update at a time and coalesce requests, and have a clean invalidate an update in flight:
updateGroup(name): if an update is running, set rerun and return the running promise; otherwise loop syncGroup(name, state) while rerun was set during the run. Joining rather than superseding keeps the outline following tiles that stream continuously — superseding would drop every result for as long as they do.
syncGroup: remember state.epoch before the queries; if it changed when they return, attach nothing; otherwise diff against state.attached as it is then (updates no longer overlap, so it is exactly what is drawn).
detachAll (used by clean and remove): increment state.epoch.
Package:
@thatopen/components-front3.4.4 —packages/front/src/fragments/Outliner/index.tsWhat happens
Outliner holds mutable group state and executes it concurrently.
Calling
Outliner.clean(group)+Outliner.addItems(map, group)concurrently result in a race condition. Outlines of elements are left on screen, and some never go away — not even afterclean(group).Why
A group is updated from several places, and nothing serializes them:
addItems/removeItems/updateFromStylescallupdateGroup(name);bindModelTileEventscallsupdateGroup(groupName)fire-and-forget on everymodel.tiles.onItemSet/onItemDeleted— continuously while the camera moves.updateGroupthen:state.attachedintopreviouslyAttachedbeforeawait Promise.all(queries);previouslyAttachedminus the new result, and assignsstate.attached = nextAttached.Two overlapping updates U1 (older map) and U2 (newer map) both copy the same attachments. If U1 lands after U2, it attaches the older map's tiles and replaces
state.attachedwith its own result — so U2's tiles are still attached to the pass but no longer tracked.clean/removego throughdetachAll, which detaches onlystate.attached, so those proxies stay drawn indefinitely. Separately, an update still awaiting its queries whenclean(group)runs re-attaches the map it started with (it readstate.mapbefore the clean replaced it).Reproduction (deterministic, no WebGL)
With a stub outline pass that records attach/detach, and a model whose
getItemDrawChunksthe test resolves by hand (one tile per item):addItems({ chunk: {1} }, "g"), answer the query → tile 1 drawn.tiles.onItemSetlistener → an update for{1}is waiting on its query.clean("g"), thenaddItems({ chunk: {2} }, "g"), answer the second query → tile 2 drawn.clean("g")→ tile 2 is still drawn, and nothing tracks it.Proposed fix
Per group, run one update at a time and coalesce requests, and have a clean invalidate an update in flight:
updateGroup(name): if an update is running, setrerunand return the running promise; otherwise loopsyncGroup(name, state)whilererunwas set during the run. Joining rather than superseding keeps the outline following tiles that stream continuously — superseding would drop every result for as long as they do.syncGroup: rememberstate.epochbefore the queries; if it changed when they return, attach nothing; otherwise diff againststate.attachedas it is then (updates no longer overlap, so it is exactly what is drawn).detachAll(used bycleanandremove): incrementstate.epoch.