Skip to content

Commit aa52080

Browse files
fix(client): detect and recover from stale game state
When using unmanagedGame mode, stage/round hooks can permanently return null after a websocket reconnection, leaving participants stuck on a loading screen. Add a 5-second timeout that reports to Sentry and reloads the page once to recover. Also removes the broken useStageTimer check (was checking the function reference, not the return value). Fixes #1150 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 639fdab commit aa52080

1 file changed

Lines changed: 61 additions & 4 deletions

File tree

client/src/Game.jsx

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,90 @@
1-
import React from "react";
1+
import React, { useEffect, useRef } from "react";
2+
import * as Sentry from "@sentry/react";
23
import {
34
useGame,
45
useStage,
56
usePlayer,
67
useRound,
7-
useStageTimer,
88
} from "@empirica/core/player/classic/react";
99
import { Loading } from "@empirica/core/player/react";
1010
import { Profile } from "./Profile";
1111
import { Stage } from "./Stage";
1212
import { Lobby } from "./intro-exit/Lobby";
1313
import { ConfirmLeave } from "./components/ConfirmLeave";
1414

15+
const STALE_STATE_TIMEOUT = 5000; // 5 seconds
16+
const RELOAD_SESSION_KEY = "gameStaleStateReload";
17+
1518
export function Game() {
1619
const game = useGame();
1720
const stage = useStage();
1821
const player = usePlayer();
1922
const round = useRound();
23+
const staleTimerRef = useRef(null);
24+
25+
const assigned = player?.get("assigned");
26+
const gameReady = !!(game && stage && round);
27+
28+
// Detect stale state: player is assigned but game hooks haven't populated.
29+
// This can happen if the websocket misses a stage/round update from the server.
30+
// Report to Sentry and reload once to recover.
31+
useEffect(() => {
32+
if (!assigned) return undefined;
33+
34+
if (gameReady) {
35+
// State arrived — clear any pending timer and reset the reload flag
36+
if (staleTimerRef.current) {
37+
clearTimeout(staleTimerRef.current);
38+
staleTimerRef.current = null;
39+
}
40+
sessionStorage.removeItem(RELOAD_SESSION_KEY);
41+
return undefined;
42+
}
43+
44+
// Game state is missing — start a timer if one isn't already running
45+
if (!staleTimerRef.current) {
46+
staleTimerRef.current = setTimeout(() => {
47+
const alreadyReloaded = sessionStorage.getItem(RELOAD_SESSION_KEY);
48+
49+
Sentry.captureMessage("Game state stale: stage/round not received", {
50+
level: "error",
51+
extra: {
52+
hasGame: !!game,
53+
hasStage: !!stage,
54+
hasRound: !!round,
55+
playerId: player?.id,
56+
gameId: game?.id,
57+
alreadyReloaded: !!alreadyReloaded,
58+
},
59+
});
60+
61+
if (!alreadyReloaded) {
62+
sessionStorage.setItem(RELOAD_SESSION_KEY, Date.now().toString());
63+
window.location.reload();
64+
}
65+
// If we already reloaded once and it didn't help, stay on Loading
66+
// rather than looping. The Sentry report will alert us.
67+
}, STALE_STATE_TIMEOUT);
68+
}
69+
70+
return () => {
71+
if (staleTimerRef.current) {
72+
clearTimeout(staleTimerRef.current);
73+
staleTimerRef.current = null;
74+
}
75+
};
76+
}, [assigned, gameReady, game, stage, round, player]);
2077

2178
// if the player is not ready, we show a loading screen
2279
if (!player) return <Loading />;
2380

2481
// game gets rendered after the player completes the intro steps, even if they haven't been
2582
// assigned to a game. In that case, we show the lobby.
26-
if (!player.get("assigned")) return <Lobby />;
83+
if (!assigned) return <Lobby />;
2784

2885
// with the unmanagedGame flag set on EmpiricaContext, we need
2986
// to manually check that the game and stage are ready before rendering
30-
if (!game || !stage || !round || !useStageTimer) return <Loading />;
87+
if (!gameReady) return <Loading />;
3188

3289
return (
3390
<>

0 commit comments

Comments
 (0)