Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog/entries/2026-06-01-webgl-context-loss.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
15 changes: 14 additions & 1 deletion packages/app/src/components/ViewportContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions packages/app/src/hooks/useWebGLContextLost.ts
Original file line number Diff line number Diff line change
@@ -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 `<Canvas>` (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;
}