Skip to content

fix(viewer): defer geometry disposal to avoid WebGPU dispose race (Sentry MONOREPO-EDITOR-DK/EG/EH)#468

Open
anton-pascal wants to merge 2 commits into
mainfrom
fix/sentry-EDITOR-DK
Open

fix(viewer): defer geometry disposal to avoid WebGPU dispose race (Sentry MONOREPO-EDITOR-DK/EG/EH)#468
anton-pascal wants to merge 2 commits into
mainfrom
fix/sentry-EDITOR-DK

Conversation

@anton-pascal

@anton-pascal anton-pascal commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Defensive fix for a Three.js WebGPU dispose race that is generating the top Sentry error in the editor: MONOREPO-EDITOR-DK (4453 events), plus EG (110) and EH (94) — all the same root cause.

The race

Inside the per-frame rebuild loops of GeometrySystem and RoofSystem, an outgoing live geometry (one currently attached to a rendered mesh) was disposed synchronously mid-useFrame:

  • geometry-system.tsxdisposeChildren() (mesh.geometry.dispose() on swapped-out children)
  • roof-system.tsxupdateRoofSegmentGeometry() (mesh.geometry.dispose() before reassign)
  • roof-system.tsx → the BoxGeometry placeholder swap
  • roof-system.tsxupdateMergedRoofGeometry() (mergedMesh.geometry.dispose() on the two reassign paths)

On the WebGPU backend the renderer still holds a RenderObject referencing that geometry for the frame in flight. BufferGeometry.dispose() synchronously dispatches the dispose event; three's WebGPU onDispose handler then calls RenderObject.getAttributes(), which reads .id / .count off attributes that have already been freed →

Cannot read properties of undefined (reading 'id')   // and 'count'
  THREE.BufferGeometry.dispose
  -> EventDispatcher.dispatchEvent -> three.webgpu.js onDispose
  -> RenderObject.getAttributes

(three@0.184.0, @react-three/fiber@9.6.1)

A try/catch around .dispose() cannot help — the throw happens inside three's own async event dispatch, not in our call frame.

The fix — defensive deferral, behavior identical

New tiny helper packages/viewer/src/lib/deferred-dispose.ts:

  • queueGeometryDispose(geometry) — queue an outgoing live geometry instead of freeing it now.
  • flushGeometryDisposals() — drain the queue (wrapped in try/catch), called once at the start of each system's useFrame, before any rebuild work.

The renderer releases its RenderObject for a geometry once it's no longer drawn (i.e. after the frame in which we swapped in the replacement). So we hand the outgoing geometry to the queue and free it at the top of the next frame, after that render has completed. Every geometry is still disposed — just one frame later, after the renderer let go of it. No geometry leaks, no rendering-behavior change.

Only the live-mesh dispose sites were changed. Transient CSG intermediates (brushes, temporary merge/subtract results that never enter the scene graph) are left disposing synchronously — the renderer never held a RenderObject for them, so there's no race.

Testing

  • bunx tsc --build packages/viewer/tsconfig.json → exit 0 (clean).
  • bun test .../geometry-system.test.ts → 1 pass.
  • biome lint on all three touched files → clean.

Scope

Addresses Sentry MONOREPO-EDITOR-DK / EG / EH. Defensive-only; does not redesign the rebuild pipeline. Do not merge without review.


Note

Medium Risk
Touches core viewer frame ordering and geometry lifecycle for all parametric/roof rebuilds; timing-sensitive but narrowly scoped to dispose sites on rendered meshes with documented ordering constraints.

Overview
Fixes a WebGPU dispose race where synchronous BufferGeometry.dispose() on meshes still referenced by the renderer during useFrame rebuilds triggered Cannot read properties of undefined (reading 'id') (top Sentry errors DK/EG/EH).

Adds deferred-dispose.ts with queueGeometryDispose and flushGeometryDisposals, plus GeometryDisposalFlushSystem mounted in the viewer at useFrame priority -1000 so the queue drains before any rebuild system runs each frame. GeometrySystem and RoofSystem now queue outgoing live mesh geometries instead of disposing them mid-frame; transient CSG intermediates still dispose synchronously.

Geometries are freed one frame later, after the render pass that swapped them out—same cleanup behavior, no pipeline redesign.

Reviewed by Cursor Bugbot for commit cb566be. Bugbot is set up for automated code reviews on this repo. Configure here.

@mintlify

mintlify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
pascal 🔴 Failed Jul 7, 2026, 4:12 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment thread packages/viewer/src/systems/roof/roof-system.tsx Outdated
…priority system

Bugbot flagged that RoofSystem's flushGeometryDisposals() runs after
GeometrySystem's within the same frame, so geometries queued by an earlier
rebuild system could be disposed before the WebGPU render pass — reintroducing
the dispose race deferral is meant to prevent.

Move the flush into a dedicated GeometryDisposalFlushSystem that runs in
useFrame at priority -1000, ahead of every rebuild system regardless of mount
order. Remove the inline flush calls from GeometrySystem and RoofSystem so the
queue only ever holds geometries dropped on a previous frame (already released
by the renderer). Update deferred-dispose docs to note the single-caller
contract.

Addresses Cursor Bugbot review on PR #468 (Sentry MONOREPO-EDITOR-DK/EG/EH).
@anton-pascal

Copy link
Copy Markdown
Contributor Author

Good catch by Bugbot — confirmed and fixed in cb566be.

Root cause: RoofSystem and GeometrySystem each called flushGeometryDisposals() from their own useFrame. Since all systems run at the default priority (0) in mount order, RoofSystem's flush executed after GeometrySystem's rebuild in the same frame, so it could dispose geometries GeometrySystem had just queued this frame — before the render pass — reintroducing exactly the dispose race the deferral prevents.

Fix: Introduced a dedicated GeometryDisposalFlushSystem that flushes once per frame in useFrame at priority -1000, guaranteeing it runs ahead of every rebuild system regardless of mount order. Removed the inline flush calls from GeometrySystem and RoofSystem, and updated the deferred-dispose docs to note that this is the single authoritative caller of flushGeometryDisposals. The queue now only ever contains geometries dropped on a previous frame (already released by the renderer).

Viewer package typechecks clean and Biome passes on the touched files.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cb566be. Configure here.

queue before ANY rebuild system runs this frame, guaranteeing only
geometries dropped on a previous frame (already released by the
renderer) are disposed. See lib/deferred-dispose.ts. */}
<GeometryDisposalFlushSystem />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flush unmount strands disposal queue

Medium Severity

The GeometryDisposalFlushSystem is a React component that drains the module-global pendingGeometryDisposals queue via useFrame. If this component unmounts (e.g., after a scene error or Canvas unmount), the useFrame callback stops, leaving the global queue undrained. This causes GPU resources for queued geometries to leak, persisting until a full page reload.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cb566be. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant