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
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache';
import type { AccountRef, GameView } from './model';
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
status,
players: 2,
toMove,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats: [],
};
}
beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
});
it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a'));
expect(getLobby()).toBeNull();
});
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
});