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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "2026-06-04-fix-white-screen-context-restore",
"version": "0.9.4",
"date": "2026-06-04",
"category": "fix",
"title": "Fix intermittent white viewport",
"summary": "The on-demand viewport now reliably paints the current scene on load, after edits, and on tab refocus, and recovers a lost or failed WebGL context by remounting — instead of leaving a blank white canvas until the next interaction.",
"features": ["viewport"]
}
20 changes: 17 additions & 3 deletions packages/app/src/components/SceneMesh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ export function ImportedMesh({ mesh, materialKey }: ImportedMeshProps) {
const [geoReady, setGeoReady] = useState(false);
const showWireframe = useUiStore((s) => s.showWireframe);
const isOrbiting = useUiStore((s) => s.isOrbiting);
const invalidate = useThree((s) => s.invalidate);
const materials = useDocumentStore((s) => s.document.materials);

// Resolve material from document, falling back to the preset library.
Expand Down Expand Up @@ -438,13 +439,16 @@ export function ImportedMesh({ mesh, materialKey }: ImportedMeshProps) {
geo.computeBoundingSphere();
geo.computeBoundingBox();
setGeoReady(true);
// Imperative buffer write under demand frameloop — kick a frame so the
// imported mesh actually paints (see SceneMesh for the full rationale).
invalidate();
})();

return () => {
disposed = true;
geo.dispose();
};
}, [mesh]);
}, [mesh, invalidate]);

// Disable raycasting during orbit for performance
const originalRaycastRef = useRef<THREE.Mesh["raycast"] | null>(null);
Expand Down Expand Up @@ -506,6 +510,7 @@ export const SceneMesh = memo(function SceneMesh({
const setHoveredPartId = useUiStore((s) => s.setHoveredPartId);
const camera = useThree((s) => s.camera);
const viewportSize = useThree((s) => s.size);
const invalidate = useThree((s) => s.invalidate);
const materials = useDocumentStore((s) => s.document.materials);
const renamePart = useDocumentStore((s) => s.renamePart);

Expand Down Expand Up @@ -713,10 +718,17 @@ export const SceneMesh = memo(function SceneMesh({
geo.computeBoundingBox();
setGeoReady(true);

// The geometry buffers were just populated imperatively — R3F's
// `frameloop="demand"` doesn't observe this mutation, so without an
// explicit kick the freshly-built mesh never paints until some unrelated
// event (orbit, resize, hover) happens to schedule a frame. That gap is
// the intermittent "white screen": geometry present, no frame drawn.
invalidate();

return () => {
geo.dispose();
};
}, [mesh, partInfo.name]);
}, [mesh, partInfo.name, invalidate]);

// Apply Transform3D to mesh (for assembly instances)
useEffect(() => {
Expand All @@ -743,7 +755,9 @@ export const SceneMesh = memo(function SceneMesh({
m.quaternion.identity();
m.scale.set(1, 1, 1);
}
}, [transform]);
// Imperative transform write — kick a demand frame so it's drawn.
invalidate();
}, [transform, invalidate]);

// Compute name tag position above the part
const labelPosition = useMemo(() => {
Expand Down
65 changes: 63 additions & 2 deletions packages/app/src/components/Viewport.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect, Suspense, lazy } from "react";
import { useRef, useState, useEffect, useCallback, Suspense, lazy } from "react";
import * as THREE from "three";
import { Canvas, useThree } from "@react-three/fiber";
import { XR } from "@react-three/xr";
Expand Down Expand Up @@ -237,6 +237,66 @@ export function Viewport() {
const pcbEditFocus = useCoreElectronicsStore((s) => s.activeBoardNodeId) != null;
const xrPresenting = useXRPresenting();

// Recover from a dead/missing WebGL context that would otherwise leave the
// viewport a blank rectangle. Two distinct failure modes, both fixed by
// remounting the <Canvas> via a changing key (R3F then builds a brand-new
// renderer + context):
//
// 1. Runtime stuck loss — `useWebGLContextLost` (inside the Canvas)
// dispatches `vcad:gl-stuck-lost` when the browser drops the context and
// doesn't fire `webglcontextrestored` within ~1.5s (GPU pressure, too
// many live contexts, tab restore).
// 2. Failed initialization — context creation returns null at mount under
// GPU exhaustion, so three never builds a renderer and `onCreated` never
// fires. No `webglcontextlost` event happens (there was never a context
// to lose), so mode 1 can't see it; we detect it as `onCreated` not
// firing within a short window.
//
// Remounts are capped so a context that genuinely cannot be created can't
// spin forever; the budget is forgiven once an epoch survives a healthy
// stretch, so an unrelated failure later in the session can still recover.
const [glEpoch, setGlEpoch] = useState(0);
const glRemountsRef = useRef(0);
const glCreatedRef = useRef(false);
const requestGlRemount = useCallback(() => {
if (glRemountsRef.current >= 3) return;
glRemountsRef.current += 1;
// Clear the "created" flag for the epoch we're about to mount so the
// watchdog re-arms. Done here (not in the per-epoch effect) because that
// effect is passive and runs *after* the next Canvas's layout-phase
// `onCreated` — resetting it there would clobber a healthy init and force a
// spurious remount on every load.
glCreatedRef.current = false;
setGlEpoch((e) => e + 1);
}, []);
const handleGlCreated = useCallback(() => {
glCreatedRef.current = true;
performance.mark("canvas-ready");
}, []);
// Mode 1: runtime stuck-loss listener.
useEffect(() => {
window.addEventListener("vcad:gl-stuck-lost", requestGlRemount);
return () => window.removeEventListener("vcad:gl-stuck-lost", requestGlRemount);
}, [requestGlRemount]);
// Mode 2: failed-init watchdog, re-armed on every (re)mount via glEpoch. If
// `onCreated` hasn't fired by the deadline the renderer failed to initialize,
// so remount for a fresh attempt. Also forgives the remount budget once an
// epoch survives a healthy stretch (a real remount changes glEpoch and
// restarts this, so a tight create→lose flap never reaches the forgive timer
// and correctly hits the cap instead).
useEffect(() => {
const initWatch = setTimeout(() => {
if (!glCreatedRef.current) requestGlRemount();
}, 2500);
const forgive = setTimeout(() => {
glRemountsRef.current = 0;
}, 12000);
return () => {
clearTimeout(initWatch);
clearTimeout(forgive);
};
}, [glEpoch, requestGlRemount]);

// Run electronics sync when in electronics mode
useElectronicsSync();

Expand Down Expand Up @@ -294,6 +354,7 @@ export function Viewport() {
style={{ touchAction: "none", userSelect: "none", WebkitUserSelect: "none" }}
>
<Canvas
key={glEpoch}
frameloop={xrPresenting ? "always" : "demand"}
camera={{ position: [50, 50, 50], fov: 50, near: 0.1, far: 10000 }}
onPointerMissed={() => {
Expand All @@ -302,7 +363,7 @@ export function Viewport() {
if (!electronicsActive) clearSelection();
clearDfmSelection(null);
}}
onCreated={() => performance.mark("canvas-ready")}
onCreated={handleGlCreated}
shadows
gl={{
alpha: true,
Expand Down
54 changes: 54 additions & 0 deletions packages/app/src/components/ViewportContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,60 @@ export function ViewportContent({
};
}, [invalidate]);

// Initial-paint burst for `frameloop="demand"`. The first meaningful frame
// depends on several pieces that settle across the first few hundred ms of a
// mount/reload, none of which reliably schedules a demand frame on its own:
// - the async engine `scene` arriving from the worker,
// - the EffectComposer (mounts only once `engineReady`) taking over the
// render path,
// - canvas/camera sizing landing via the ResizeObserver,
// - child meshes populating geometry buffers imperatively (invisible to
// R3F's reconciler).
// When the last of these lands without a frame queued, the viewport sits
// populated-but-unpainted — the intermittent "white screen" that only a
// stray orbit/resize would clear. Rather than guess which signal is last,
// pump invalidate() across the first ~600ms so whichever frame the scene
// becomes drawable in actually paints. A single well-timed kick persists, so
// this converges; it's self-terminating and costs nothing after load.
useEffect(() => {
let raf = 0;
let n = 0;
const pump = () => {
invalidate();
if (++n < 36) raf = requestAnimationFrame(pump);
};
pump();
return () => cancelAnimationFrame(raf);
}, [invalidate]);

// Repaint whenever the rendered scene swaps (later edits / re-evaluations) —
// the imperative geometry build in child meshes wouldn't otherwise schedule a
// frame. Cheap: engine re-evaluations are user-paced.
useEffect(() => {
invalidate();
}, [invalidate, scene, previewMesh, engineReady]);

// Repaint on visibility/focus transitions that R3F can't see: returning from
// a backgrounded tab, or switching back from a full-screen overlay (the
// schematic) to the 3D viewport. Also covers any document edit that mutates
// the scene imperatively without going through the engine scene swap above.
useEffect(() => {
const kick = () => invalidate();
const onVisible = () => {
if (document.visibilityState === "visible") invalidate();
};
const unsubDoc = useDocumentStore.subscribe(kick);
window.addEventListener("focus", kick);
window.addEventListener("pageshow", kick);
document.addEventListener("visibilitychange", onVisible);
return () => {
unsubDoc();
window.removeEventListener("focus", kick);
window.removeEventListener("pageshow", kick);
document.removeEventListener("visibilitychange", onVisible);
};
}, [invalidate]);

// Per-frame participant-camera sync (Follow + Lock).
//
// - Lock : hard-copy the followed participant's camera every frame
Expand Down
36 changes: 35 additions & 1 deletion packages/app/src/hooks/useWebGLContextLost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,31 @@ import { useThree } from "@react-three/fiber";
*/
export function useWebGLContextLost(): boolean {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const [lost, setLost] = useState(false);

useEffect(() => {
const canvas = gl.domElement;
if (!canvas) return;

// If the browser doesn't restore within this window after a loss, the
// context is most likely gone for good (GPU resource exhaustion / too many
// live contexts). Rather than leave a dead, white viewport, ask the app to
// remount the <Canvas> for a fresh context (Viewport listens for this).
let stuckTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleRecovery = () => {
clearTimeout(stuckTimer);
stuckTimer = setTimeout(() => {
window.dispatchEvent(new CustomEvent("vcad:gl-stuck-lost"));
}, 1500);
};

// Seed the initial state in case the context was already lost before we
// attached listeners (e.g. created on a backgrounded tab).
// attached listeners (e.g. created on a backgrounded tab). We deliberately
// do NOT schedule a remount here: a freshly-created context can read as
// momentarily lost during a heavy load while the GPU is under pressure, and
// remounting on that transient would churn the canvas. Only a real runtime
// `webglcontextlost` that fails to restore triggers recovery (below).
try {
const ctx = gl.getContext();
if (ctx && typeof ctx.isContextLost === "function" && ctx.isContextLost()) {
Expand All @@ -45,19 +62,36 @@ export function useWebGLContextLost(): boolean {
// Signal intent to recover so the browser fires webglcontextrestored.
event.preventDefault();
setLost(true);
scheduleRecovery();
};
const handleRestored = () => {
clearTimeout(stuckTimer);
setLost(false);
};

canvas.addEventListener("webglcontextlost", handleLost as EventListener, false);
canvas.addEventListener("webglcontextrestored", handleRestored, false);

return () => {
clearTimeout(stuckTimer);
canvas.removeEventListener("webglcontextlost", handleLost as EventListener, false);
canvas.removeEventListener("webglcontextrestored", handleRestored, false);
};
}, [gl]);

// Repaint once the context is back. The viewport runs `frameloop="demand"`
// with a transparent canvas, so a restored context that nobody invalidates
// leaves the canvas unpainted — the page background shows through as a
// "white screen" until the next user interaction. Scheduling a frame here
// (now and on the next rAF, after fragile GPU subtrees have remounted) makes
// the scene reappear on its own. Also covers the case where the context was
// already lost at mount and later restored.
useEffect(() => {
if (lost) return;
invalidate();
const raf = requestAnimationFrame(() => invalidate());
return () => cancelAnimationFrame(raf);
}, [lost, invalidate]);

return lost;
}
41 changes: 41 additions & 0 deletions packages/app/src/test/demand-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,44 @@ describe("No unconditional invalidation loops", () => {
expect(block).toContain("invalidate()");
});
});

// ---------------------------------------------------------------------------
// 6. White-screen guards: imperative geometry builds + initial paint
// ---------------------------------------------------------------------------
// Under frameloop="demand", meshes that populate their geometry buffers in an
// effect (geo.setAttribute(...)) mutate the scene in a way R3F's reconciler
// never sees, so no frame is scheduled and the viewport sits blank until an
// unrelated event (orbit/resize) happens to kick one. These assert the explicit
// invalidate() kicks that keep the scene from white-screening on load/update.
describe("White-screen guards", () => {
it("SceneMesh invalidates after building geometry imperatively", () => {
const src = readSrc("components/SceneMesh.tsx");
// Must pull invalidate off the R3F store...
expect(src).toMatch(/invalidate\s*=\s*useThree/);
// ...and the geometry-build effect (keyed on the incoming mesh) must list
// invalidate as a dep, proving the kick lives in that effect.
expect(src).toMatch(/\[\s*mesh\s*,\s*partInfo\.name\s*,\s*invalidate\s*\]/);
// The imported-mesh path builds geometry imperatively too.
expect(src).toMatch(/\[\s*mesh\s*,\s*invalidate\s*\]/);
});

it("ViewportContent kicks an initial-paint burst on mount", () => {
const src = readSrc("components/ViewportContent.tsx");
// A self-terminating rAF pump that invalidates across the first frames so
// whichever frame the async scene/composer becomes drawable in gets painted.
expect(src).toContain("const pump");
expect(src).toMatch(/requestAnimationFrame\(pump\)/);
// And a reactive kick when the evaluated engine scene swaps.
expect(src).toMatch(/\[\s*invalidate\s*,\s*scene\s*,\s*previewMesh\s*,\s*engineReady\s*\]/);
});

it("Viewport remounts the Canvas to recover a dead WebGL context", () => {
const src = readSrc("components/Viewport.tsx");
// The Canvas key is driven by a remount epoch...
expect(src).toMatch(/key=\{glEpoch\}/);
// ...bumped both on a stuck runtime loss and a failed init (onCreated never
// firing). Recovery must be capped so it can't spin forever.
expect(src).toContain("vcad:gl-stuck-lost");
expect(src).toMatch(/glRemountsRef\.current\s*>=\s*3/);
});
});