From dc9ac4066cb4427dce733713e01789532fb661e3 Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 17:35:57 +0200 Subject: [PATCH] Fix: harden renderer recovery invariants and input handling --- docs/design/realm-renderer-recovery.md | 21 +++++---- src/components/realm/RealmMapScreen.tsx | 38 +++++++++------- src/components/realm/createRealmScene.ts | 44 +++++++++++++++++++ .../realm/realmMapPresentationHelpers.ts | 5 ++- src/components/realm/realmRendererRecovery.ts | 22 +++++++++- tests/realmMapScreen.test.tsx | 5 ++- tests/realmRendererRecovery.test.ts | 33 ++++++++++++++ tests/realmSceneCleanup.test.ts | 40 +++++++++++++++++ 8 files changed, 180 insertions(+), 28 deletions(-) diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md index dffb189c..241af468 100644 --- a/docs/design/realm-renderer-recovery.md +++ b/docs/design/realm-renderer-recovery.md @@ -12,16 +12,21 @@ explicit loading/recovery/failed state instead of silently replacing a real world with a full-world SVG. Context loss calls `preventDefault`, pauses ambient work and rendering, and -retains React selection, camera intent, and the scene attestation. The restored -event starts a bounded scene rebuild. If the browser does not restore the -context in time, the user sees an explicit retry surface. No code intentionally -calls `WEBGL_lose_context` during capability probing. +retains React selection, camera intent, and the scene attestation. Pointer, +wheel, label-click, and camera input are synchronously suspended while the +context is lost so a partially disposed scene cannot consume a gesture. The +restored event starts a bounded scene rebuild and records loss/restore counts on +the canvas for DOM diagnostics. If the browser does not restore the context in +time, the user sees an explicit retry surface. No code intentionally calls +`WEBGL_lose_context` during capability probing; the probe is WebGL2-only. Castle loading is staged: Compact is mandatory and retried once for transient -transport failures; Balanced and High are optional upgrades. A missing optional -LOD records the active quality in `data-realm-castle-active-lod` and continues -with Compact. Pairing, integrity, and Compact failures are reported with stable -failure codes for telemetry and QA. +transport failures after a deterministic short yield; Balanced and High are +optional upgrades. A missing optional LOD records the active quality in +`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity, +and Compact failures are reported with stable failure codes for telemetry and +QA. Each controlled load is assigned a monotonic renderer generation, and a +ready renderer can never transition into static compatibility mode. The recovery contract is intentionally frontend-only. Durable world state, authorization, and SpacetimeDB subscriptions remain outside the renderer and diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 1308d694..bcff9bf2 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -615,7 +615,6 @@ function CanonicalRealmMapScreen({ const rendererRecoveryTimerRef = useRef(null); const rendererRecoveryNonceRef = useRef(0); const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); - const rendererEverReadyRef = useRef(false); const rendererAttestationRef = useRef | null>(null); const [cameraMode, setCameraMode] = useState('realm'); const [interaction, dispatchInteraction] = useReducer( @@ -899,12 +898,17 @@ function CanonicalRealmMapScreen({ ? failureInput as RealmRendererFailure : classifyRealmRendererFailure(failureInput, current.state); if (failure.code === 'webgl-unavailable') { + rendererModeRef.current = current.everReady ? 'loading' : 'fallback'; setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'webgl-unsupported', failure })); return; } + // Stop accepting pointer/camera mutations synchronously, before React has + // committed the loading/recovering state to the DOM. The scene itself + // applies the same guard while a WebGL context is lost. + rendererModeRef.current = 'loading'; if (shouldRetryRealmRenderer(current, failure)) { const nextAttempt = current.attempt + 1; setRendererLifecycle(transitionRealmRendererLifecycle(current, { @@ -943,12 +947,11 @@ function CanonicalRealmMapScreen({ window.clearTimeout(rendererRecoveryTimerRef.current); rendererRecoveryTimerRef.current = null; } - const next: RealmRendererLifecycle = { - state: 'loading', - attempt: 0, - everReady: rendererEverReadyRef.current - }; - setRendererLifecycle(next); + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: 0 + })); + rendererModeRef.current = 'loading'; rendererRecoveryNonceRef.current += 1; setRendererRecoveryNonce(rendererRecoveryNonceRef.current); }, []); @@ -1383,7 +1386,6 @@ function CanonicalRealmMapScreen({ return; } rendererModeRef.current = 'webgl'; - rendererEverReadyRef.current = true; const activeLod = canvas.dataset.realmCastleActiveLod; setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { type: 'ready', @@ -1603,6 +1605,9 @@ function CanonicalRealmMapScreen({ if (isPlayableRealmCoord(surface, next)) selectCoord(next); }; + // The scene mirrors these counters onto the canvas so a context event can + // be diagnosed without exposing implementation details in user copy. + const canvasTelemetry = canvasRef.current?.dataset; return (
WebGL is unavailable on this device. Showing a bounded, accessible view of the canonical Genesis 001 region around your keep. -

) : null} @@ -1883,20 +1889,20 @@ function CanonicalRealmMapScreen({ >
{rendererLifecycle.state === 'failed' ? ( - The 3D realm needs another attempt + THE REALM COULD NOT BE RESTORED ) : rendererLifecycle.state === 'recovering' ? ( - Recovering the 3D realm… + RESTORING THE REALM… ) : ( Surveying the bright lowlands… )} {rendererLifecycle.state === 'failed' - ? 'The renderer stopped before the world was ready. Your world state is safe.' + ? 'Warpkeep could not restart 3D rendering. Your server-owned progress was not changed.' : rendererLifecycle.state === 'recovering' - ? 'Restoring the graphics context without replacing the realm with a flat map.' + ? 'The graphics device was interrupted. Your game state is safe while the realm is restored.' : 'Preparing every canonical castle before the realm is revealed.'} - {rendererLifecycle.state === 'failed' ? ( + {rendererLifecycle.state === 'failed' && rendererLifecycle.failure?.retryable !== false ? ( ) : null}