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
+45 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, StateView } from './model';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
@@ -98,3 +98,46 @@ describe('applyGameOver', () => {
expect(res.cache?.view.game.moveCount).toBe(10);
});
});
function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}
function gameOverEvent(moveCount: number): PushEvent {
return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) };
}
describe('advanceCached', () => {
it("advances the cache from an opponent's move for a game not on screen", () => {
const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42));
expect(res?.view.game.moveCount).toBe(4);
expect(res?.view.bagLen).toBe(42);
expect(res?.moves).toHaveLength(1);
});
it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => {
expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined();
});
it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => {
expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined();
});
it('settles a game_over event when the cached board is current', () => {
const res = advanceCached(cache(10, 0), gameOverEvent(10));
expect(res?.view.game.status).toBe('finished');
});
it('returns undefined for game_over when the cached board is behind the final count', () => {
expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined();
});
it('returns undefined when nothing is cached for the game', () => {
expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined();
});
it('ignores event kinds that do not advance a game board', () => {
const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, moveCount: 4 };
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});