From 9c0f707536c31789fb0abf5b291fe7d75ccf3c7c Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 6 Oct 2026 16:02:21 -0700 Subject: [PATCH] pagescene: release detached contexts and stop the viewport observer on unmount Measured in the running webapp (live WebGL objects counted per context over add/delete, New Scene and navigate-away/back cycles), the scene itself is now freed, but every visit to /edit-3d still added two live WebGL contexts with the post-processing render targets and programs inside them. renderer.dispose() does not delete those GL objects, and leaving the page detaches the canvases, so the contexts lingered until garbage collection. Unmount now forces the context loss for a renderer whose canvas has left the DOM. A scene change keeps the canvases mounted and the next Editor reuses their contexts, so those are kept. ViewportController's ResizeObserver was never disconnected: after unmount it kept calling renderScene on the dead editor whenever the container resized, keeping the whole Editor reachable and, once the context was released, throwing. It is now disconnected on unmount. --- .../pagescene/src/lib/engine/editor.ts | 18 +++++++-- .../engine/editor/ViewportController.spec.ts | 38 +++++++++++++++++++ .../lib/engine/editor/ViewportController.ts | 8 ++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.spec.ts diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor.ts b/frontend/libs/components/pagescene/src/lib/engine/editor.ts index 0d1c9158e8..075cb39d43 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor.ts @@ -1127,13 +1127,12 @@ class Editor { this.bus.emit(new SceneLoadedEvent(false)); this.stopRenderLoop(); + this.viewport.dispose(); // Free the scene's GPU resources. EngineProvider serialized the scene // before calling us, and the next mount builds a fresh Editor, so // nothing reads this scene again. The gizmo's dispose also removes its - // pointer listeners from the canvas. No forceContextLoss: a scene - // change mounts the next Editor on these same canvases, which hands it - // this same WebGL context. + // pointer listeners from the canvas. this.entranceAnimator.clear(); this.gizmo.control?.dispose(); this.activeScene.dispose(); @@ -1142,6 +1141,13 @@ class Editor { this.renderer?.dispose(); this.postProcessing.dispose(); this.rawRenderer?.dispose(); + // dispose() leaves the GL objects themselves (render targets, programs) + // in the context. Leaving the page detaches the canvases, and the + // context then lingers until garbage collection, two per visit. A + // scene change keeps the canvases mounted and the next Editor reuses + // their contexts, so only release a context whose canvas is gone. + releaseContextIfDetached(this.renderer); + releaseContextIfDetached(this.rawRenderer); this.isMounted = false; this.bus.emit(new EngineInitializedEvent(false)); @@ -1155,4 +1161,10 @@ class Editor { } } +function releaseContextIfDetached(renderer: THREE.WebGLRenderer | undefined) { + if (renderer && !renderer.domElement.isConnected) { + renderer.forceContextLoss(); + } +} + export default Editor; diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.spec.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.spec.ts new file mode 100644 index 0000000000..7aca5291ad --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.spec.ts @@ -0,0 +1,38 @@ +import { ViewportController } from "./ViewportController"; + +describe("ViewportController", () => { + it("stops observing the container on dispose", () => { + const observers = stubResizeObserver(); + const renderScene = vi.fn(); + const viewport = new ViewportController({ + getCamera: () => null, + getRenderCamera: () => null, + getRenderer: () => ({ setSize: vi.fn(), setPixelRatio: vi.fn() }) as never, + getRenderAspectRatio: () => 1, + resizePostProcessing: vi.fn(), + renderScene, + }); + viewport.container = document.createElement("div"); + + viewport.setupResizeObserver(); + viewport.dispose(); + + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + }); +}); + +// jsdom has no ResizeObserver; record each one the controller creates. +function stubResizeObserver() { + const observers: Array<{ disconnect: ReturnType }> = []; + vi.stubGlobal( + "ResizeObserver", + class { + disconnect = vi.fn(); + constructor() { + observers.push(this); + } + observe() {} + }, + ); + return observers; +} diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts index e23a3184a2..a2ff8280a4 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts @@ -100,4 +100,12 @@ export class ViewportController { }); this.observer.observe(this.container); } + + // Stop observing on unmount. The container can outlive this editor (a + // scene change keeps it mounted), and a live observer would keep + // re-rendering the dead editor's scene on every resize. + dispose() { + this.observer?.disconnect(); + this.observer = undefined; + } }