push updated state to clients over SSE (ISS-129)
CI / build + unit + e2e (pull_request) Successful in 11m7s

This commit is contained in:
2026-09-22 20:44:13 +00:00
parent 9b2938a5b0
commit 1983d17099
13 changed files with 354 additions and 21 deletions
+55 -6
View File
@@ -35,8 +35,14 @@ export async function fetchState(): Promise<StateSnapshot> {
return snapshot;
}
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
// so every pane renders from the same snapshot instead of running its own poll loops.
// The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane
// renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary
// transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an
// automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh
// events flow. EventSource reconnects natively, so there is no custom reconnect logic here.
const SSE_URL = '/api/events';
const SSE_SILENCE_MS = 5_000;
export function useGameState(intervalMs = 1000): GameStateResult {
const [state, setState] = useState<StateSnapshot | null>(null);
const [loading, setLoading] = useState(true);
@@ -70,10 +76,53 @@ export function useGameState(intervalMs = 1000): GameStateResult {
}, []);
useEffect(() => {
refresh(); // fetch immediately on mount, don't wait for the first interval tick
const id = setInterval(refresh, intervalMs);
return () => clearInterval(id);
}, [intervalMs, refresh]);
let pollId: ReturnType<typeof setInterval> | null = null;
let silenceTimer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const stopPolling = () => {
if (pollId !== null) clearInterval(pollId);
pollId = null;
};
// Fallback loop: immediate catch-up fetch, then the old interval.
const startPolling = () => {
if (closed || pollId !== null) return;
refresh();
pollId = setInterval(refresh, intervalMs);
};
let source: EventSource | null = null;
try {
source = new EventSource(SSE_URL);
} catch {
source = null; // no EventSource support — pure polling below
}
if (source !== null) {
source.onmessage = (event) => {
try {
applySnapshot(JSON.parse(event.data));
} catch {
// malformed event — the next one overwrites it anyway
}
stopPolling(); // fresh push flow → polling no longer needed
if (silenceTimer !== null) clearTimeout(silenceTimer); // connect check served its purpose
};
source.onerror = () => startPolling(); // stream trouble → poll until pushes resume
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll
} else {
startPolling();
}
refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot)
return () => {
closed = true;
source?.close();
stopPolling();
if (silenceTimer !== null) clearTimeout(silenceTimer);
};
}, [intervalMs, refresh, applySnapshot]);
return { state, loading, error, refresh, applySnapshot };
}