fix(ui): keep lobby/game caches fresh across screens (no stale-status flash) #55
+9
-1
@@ -48,7 +48,15 @@ Login uses `Screen`.
|
||||
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||||
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||||
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||||
on lobby ↔ game navigation.
|
||||
on lobby ↔ game navigation. The caches are kept fresh **across** screens, so the instant render
|
||||
is already current and not a stale frame the background refresh then corrects. The single global
|
||||
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
|
||||
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
|
||||
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
|
||||
opponent-joined, and a match/started game (which it also adds) — and advances a not-currently-viewed
|
||||
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted
|
||||
board owns its cache). The game board additionally mirrors the player's **own** move and its own
|
||||
`load()` into the lobby snapshot — the two updates no live event carries.
|
||||
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
|
||||
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
|
||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||
import {
|
||||
@@ -138,6 +139,9 @@
|
||||
view = st;
|
||||
moves = hist.moves;
|
||||
setCachedGame(id, st, hist.moves);
|
||||
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
|
||||
// stale flash before the lobby's own background refresh lands.
|
||||
patchLobbyGame(st.game);
|
||||
selected = null;
|
||||
await applyDraft(st);
|
||||
recompute();
|
||||
@@ -559,6 +563,7 @@
|
||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||
moves = [...moves, r.move];
|
||||
setCachedGame(id, view, moves);
|
||||
patchLobbyGame(r.game);
|
||||
rackIds = r.rack.map((_, i) => i);
|
||||
placement = newPlacement(r.rack);
|
||||
selected = null;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-1
@@ -4,7 +4,7 @@
|
||||
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
|
||||
|
||||
import type { CachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, StateView } from './model';
|
||||
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
|
||||
|
||||
/** The fields an opponent_moved delta carries that advance a cached game. */
|
||||
export interface MoveDelta {
|
||||
@@ -67,3 +67,20 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u
|
||||
const view: StateView = { ...cached.view, game };
|
||||
return { cache: { view, moves: cached.moves }, refetch: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* advanceCached applies a live game event to a cached game off-screen, for the global stream handler
|
||||
* to keep a game the player is not currently viewing warm: opening it from the lobby then renders the
|
||||
* opponent's move (or the final result) with no stale-board flash. It returns the advanced cache, or
|
||||
* undefined when nothing changes — no cache, an idempotent re-delivery, an unrelated event kind, or a
|
||||
* gap / missing payload that the next open cold-loads (off-screen there is no game screen to honour a
|
||||
* refetch, so a fall-back simply leaves the cache for load() to repair). The mounted game screen owns
|
||||
* its own cache, so the caller skips the game currently in view to avoid applying a delta twice.
|
||||
*/
|
||||
export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined {
|
||||
if (e.kind === 'opponent_moved') {
|
||||
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
|
||||
}
|
||||
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,24 @@ export function setLobby(s: LobbySnapshot): void {
|
||||
snapshot = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* patchLobbyGame upserts the given game into the cached lobby — replacing the matching entry, or
|
||||
* prepending it when absent — so a game-state change seen while the lobby is unmounted (the player's
|
||||
* own move, or any live game event the global stream handler applies from another screen) is already
|
||||
* reflected the next time the lobby renders from the snapshot, instead of showing the stale status
|
||||
* until the background refresh lands. Prepending a freshly started game likewise lets it appear at
|
||||
* once. It is a no-op only when there is no snapshot yet (the lobby cold-loads on its first mount).
|
||||
* The lobby re-groups and re-sorts on render, so the insert position carries no meaning.
|
||||
*/
|
||||
export function patchLobbyGame(g: GameView): void {
|
||||
if (!snapshot) return;
|
||||
const i = snapshot.games.findIndex((x) => x.id === g.id);
|
||||
const games = snapshot.games.slice();
|
||||
if (i === -1) games.unshift(g);
|
||||
else games[i] = g;
|
||||
snapshot = { ...snapshot, games };
|
||||
}
|
||||
|
||||
/** clearLobby drops the cached lobby (called on logout). */
|
||||
export function clearLobby(): void {
|
||||
snapshot = null;
|
||||
|
||||
Reference in New Issue
Block a user