fix(ui): keep lobby/game caches fresh across screens (no stale-status flash)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 58s

The per-screen in-memory caches (lobbycache, gamecache) were refreshed only by
the screen that owns them while it was mounted, so a state change that crossed
screens left the other screen's cache stale and it visibly redrew on the next
navigation:

- game -> lobby: the player's own move advanced the game cache but not the lobby
  snapshot, and an own move carries no self-directed push event, so returning to
  the lobby painted the pre-move status until the background refresh corrected it.
- lobby -> game (and from any other screen): an opponent's move / game-over
  refreshed the lobby (while mounted) but never the per-game cache, so opening
  that game flashed the pre-move board.

Make cache freshness cross-screen, owned by the single global stream handler
that runs for every live event regardless of the mounted screen:

- patchLobbyGame upserts the affected game's GameView into the lobby snapshot;
  the global handler calls it on opponent_moved / game_over / opponent_joined and
  on a match_found / game_started seed (so a game started elsewhere is present
  too). The game board still mirrors the player's own move and its own load() —
  the two updates no live event carries.
- advanceCached (a pure wrapper over the existing delta reducers) advances a
  not-currently-viewed game's cache from opponent_moved / game_over; the game in
  view is skipped so its mounted board stays the sole owner (no double apply).

End-state behaviour is unchanged (the background refresh always reconciled);
this removes the transient stale frame. Unit-tested patchLobbyGame and
advanceCached; docs/UI_DESIGN.md updated.
This commit is contained in:
Ilia Denisov
2026-06-14 10:42:43 +02:00
parent 38fa104f7f
commit cf70e6b1fc
7 changed files with 196 additions and 12 deletions
+40 -8
View File
@@ -25,8 +25,9 @@ import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache, setCachedGame } from './gamecache';
import { clearLobby } from './lobbycache';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -144,6 +145,16 @@ export function handleError(err: unknown): void {
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
/**
* viewingGame reports whether the game board for the given game id is the current route. That screen
* maintains its own cache from live events, so the global stream handler must not also advance it (a
* double application). The chat / dictionary sub-screens leave the board unmounted, so they do not
* count as viewing — there the global handler keeps the cache warm for the return to the board.
*/
function viewingGame(gameId: string): boolean {
return router.route.name === 'game' && router.route.params.id === gameId;
}
function openStream(): void {
closeStream();
app.streamAlive = true;
@@ -168,15 +179,36 @@ function openStream(): void {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'opponent_moved' || e.kind === 'game_over') {
// Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale
// frame on the next navigation. The lobby snapshot is patched from the event's own GameView
// (independent of whether this device has the game cached). The per-game cache is advanced
// only for a game not currently in view — the mounted game board owns its own cache (see
// game/Game.svelte), so skipping it avoids a double apply.
if (e.game) patchLobbyGame(e.game);
if (!viewingGame(e.gameId)) {
const c = advanceCached(getCachedGame(e.gameId), e);
if (c) setCachedGame(e.gameId, c.view, c.moves);
}
} else if (e.kind === 'opponent_joined') {
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot
// from any screen. A mounted game board adopts the change in place itself (game/Game.svelte).
if (e.state) patchLobbyGame(e.state.game);
} else if (e.kind === 'match_found') {
// Seed the cache from the event's initial state so the game renders instantly on arrival,
// then navigate.
if (e.state) setCachedGame(e.state.game.id, e.state, []);
// Seed both caches from the event's initial state so the game renders instantly on arrival
// and the new game is already in the lobby on a later return, then navigate.
if (e.state) {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
navigate(`/game/${e.gameId}`);
} else if (e.kind === 'notify') {
// A started invited game seeds its cache so opening it is instant; the lobby badge stays
// on the authoritative refresh.
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
// A started invited game seeds its cache so opening it is instant and lands in the lobby
// snapshot from any screen; the lobby badge stays on the authoritative refresh.
if (e.sub === 'game_started' && e.state) {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
void refreshNotifications();
}
},