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.
This commit is contained in:
Tanish Manku committed 2026-10-06 16:02:21 -07:00
1 parent 63b2a71c28
commit 802dbd5637
3 files changed
+61 -3

No files matched your search

@@ -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;
@@ -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<typeof vi.fn> }> = [];
vi.stubGlobal(
"ResizeObserver",
class {
disconnect = vi.fn();
constructor() {
observers.push(this);
}
observe() {}
},
);
return observers;
}
@@ -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;
}
}