From 0e4f6ce1ac551027c3deaa744eab306da2e83c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:34:05 +0000 Subject: [PATCH] fix(viewport): recover from WebGL context loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postprocessing EffectComposer reads `renderer.getContext().getContextAttributes().alpha` when its renderer or passes are set. When the browser drops the WebGL context — common on Safari/WebKit under GPU pressure, with many live contexts, or on tab restore — `getContextAttributes()` returns null and the access throws "null is not an object", crashing the viewport. Add a useWebGLContextLost hook that tracks webglcontextlost/restored on the R3F canvas (calling preventDefault so the browser attempts restoration), and gate the EffectComposer on it so post-processing tears down while the context is gone and remounts cleanly on restore. Nudge a repaint on restore since the viewport uses a demand frameloop. https://claude.ai/code/session_019HGPK77WwjeXuD7wFdNF1W --- .../2026-06-01-webgl-context-loss.json | 13 ++++ .../app/src/components/ViewportContent.tsx | 15 ++++- packages/app/src/hooks/useWebGLContextLost.ts | 63 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 changelog/entries/2026-06-01-webgl-context-loss.json create mode 100644 packages/app/src/hooks/useWebGLContextLost.ts diff --git a/changelog/entries/2026-06-01-webgl-context-loss.json b/changelog/entries/2026-06-01-webgl-context-loss.json new file mode 100644 index 00000000..0a2d2dc9 --- /dev/null +++ b/changelog/entries/2026-06-01-webgl-context-loss.json @@ -0,0 +1,13 @@ +{ + "id": "2026-06-01-webgl-context-loss", + "version": "0.9.4", + "date": "2026-06-01", + "category": "fix", + "title": "Recover from WebGL context loss", + "summary": "Fixed a viewport crash when the browser dropped the WebGL context (common on Safari); post-processing now tears down and the scene repaints on restore.", + "features": [ + "viewport", + "rendering", + "safari" + ] +} diff --git a/packages/app/src/components/ViewportContent.tsx b/packages/app/src/components/ViewportContent.tsx index 7ae9561b..1089ba36 100644 --- a/packages/app/src/components/ViewportContent.tsx +++ b/packages/app/src/components/ViewportContent.tsx @@ -50,6 +50,7 @@ import { } from "@vcad/core"; import type { PartInfo, CameraGoal } from "@vcad/core"; import { useCameraControls } from "@/hooks/useCameraControls"; +import { useWebGLContextLost } from "@/hooks/useWebGLContextLost"; import { useTheme } from "@/hooks/useTheme"; import { useInputDeviceDetection } from "@/hooks/useInputDeviceDetection"; import { usePhysicsSimulation } from "@/hooks/usePhysicsSimulation"; @@ -1235,6 +1236,18 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { const gl = useThree((s) => s.gl); const r3fScene = useThree((s) => s.scene); const viewportSize = useThree((s) => s.size); + // When the browser drops the WebGL context (Safari under GPU pressure, tab + // restore, too many live contexts) the postprocessing EffectComposer crashes + // reading `renderer.getContext().getContextAttributes().alpha` off the dead + // context. Tear post-processing down while the context is lost and let it + // remount once the browser restores it. + const contextLost = useWebGLContextLost(); + // The viewport runs on a "demand" frameloop, so nudge a repaint once the + // context comes back — otherwise the restored scene sits blank until the + // next user interaction. + useEffect(() => { + if (!contextLost) invalidate(); + }, [contextLost, invalidate]); useEffect(() => { const capture = (goal: CameraGoal): HTMLCanvasElement | null => { const w = Math.max(1, viewportSize.width); @@ -1812,7 +1825,7 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { framebuffer, so in VR/AR the scene would go black and only objects rendered directly by WebXRManager (hands, controllers) would show. */} - {engineReady && !xrPresenting && (() => { + {engineReady && !xrPresenting && !contextLost && (() => { const aoEnabled = sceneSettings.postProcessing.ambientOcclusion?.enabled !== false; const vignetteEnabled = sceneSettings.postProcessing.vignette?.enabled !== false; if (!aoEnabled && !vignetteEnabled) return null; diff --git a/packages/app/src/hooks/useWebGLContextLost.ts b/packages/app/src/hooks/useWebGLContextLost.ts new file mode 100644 index 00000000..bb68a048 --- /dev/null +++ b/packages/app/src/hooks/useWebGLContextLost.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { useThree } from "@react-three/fiber"; + +/** + * Tracks whether the viewport's WebGL context is currently lost. + * + * Browsers can drop a WebGL context at any time — Safari/WebKit is especially + * aggressive about it under GPU memory pressure, when too many live contexts + * exist, or when a tab is backgrounded and restored. When that happens, + * `gl.getContext().getContextAttributes()` returns `null`, and anything that + * reads off it (notably the `postprocessing` EffectComposer, which does + * `renderer.getContext().getContextAttributes().alpha`) throws + * `null is not an object`. + * + * Calling `preventDefault()` on the `webglcontextlost` event tells the browser + * we intend to recover, which is what triggers the matching + * `webglcontextrestored` event. Three.js's WebGLRenderer re-initializes its own + * GL state on restore; consumers can use this flag to unmount fragile + * GPU-dependent subtrees (e.g. post-processing) while the context is gone and + * remount them cleanly once it returns. + * + * Must be called from within an R3F `` (it relies on `useThree`). + */ +export function useWebGLContextLost(): boolean { + const gl = useThree((state) => state.gl); + const [lost, setLost] = useState(false); + + useEffect(() => { + const canvas = gl.domElement; + if (!canvas) return; + + // Seed the initial state in case the context was already lost before we + // attached listeners (e.g. created on a backgrounded tab). + try { + const ctx = gl.getContext(); + if (ctx && typeof ctx.isContextLost === "function" && ctx.isContextLost()) { + setLost(true); + } + } catch { + // getContext can itself throw on a half-torn-down renderer; treat as lost. + setLost(true); + } + + const handleLost = (event: Event) => { + // Signal intent to recover so the browser fires webglcontextrestored. + event.preventDefault(); + setLost(true); + }; + const handleRestored = () => { + setLost(false); + }; + + canvas.addEventListener("webglcontextlost", handleLost as EventListener, false); + canvas.addEventListener("webglcontextrestored", handleRestored, false); + + return () => { + canvas.removeEventListener("webglcontextlost", handleLost as EventListener, false); + canvas.removeEventListener("webglcontextrestored", handleRestored, false); + }; + }, [gl]); + + return lost; +}