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
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:
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user