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.
-
- Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.
-
) : 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}