feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s

Opening a game from the lobby for the first time this session showed a brief
loading flash, and every open showed a two-step rack->board jump: the saved
draft (pending composition) was fetched separately and applied only after the
board had already painted the full rack.

Both stem from the full state and the draft not being available synchronously at
first paint. Cache the draft alongside view+history (CachedGame.draft), make
applyDraft take the already-fetched JSON so it runs synchronously, and fetch the
draft in the same Promise.all as state+history. setCachedGame preserves the
cached draft when the delta path omits it and clears it on a committed move
(mirroring the server). A new preload module warms the per-game cache (state,
history, draft) for the lobby's ongoing games with bounded concurrency, so
opening any of them is instant.

Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units;
existing draft-restore e2e still green.
This commit is contained in:
Ilia Denisov
2026-06-14 17:53:03 +02:00
parent 4f2fc795ec
commit c9021fc070
7 changed files with 271 additions and 30 deletions
+4 -1
View File
@@ -585,7 +585,10 @@ and **invitation-update** carry the full invitation, and the client upserts a st
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` / too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover `exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby
**preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved
**draft** — into that same per-game cache, so opening one from the lobby is instant and a saved
composition paints already on the board (no rack→board step). The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload (fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters
synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
+26 -20
View File
@@ -19,7 +19,7 @@
import { variantNameKey } from '../lib/variants'; import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
@@ -136,18 +136,21 @@
// Ask for the alphabet table only on a per-variant cache miss (the first open of a // Ask for the alphabet table only on a per-variant cache miss (the first open of a
// game whose variant the client has not cached yet); steady-state polls omit it. // game whose variant the client has not cached yet); steady-state polls omit it.
const includeAlphabet = !view || !hasAlphabet(view.game.variant); const includeAlphabet = !view || !hasAlphabet(view.game.variant);
const [st, hist] = await Promise.all([ // Fetch the saved draft alongside state and history (best-effort) so the composition is
// applied in the same tick the board appears — never as a second, visible rack→board step.
const [st, hist, draft] = await Promise.all([
gateway.gameState(id, includeAlphabet), gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id), gateway.gameHistory(id),
gateway.draftGet(id).catch(() => ''),
]); ]);
view = st; view = st;
moves = hist.moves; moves = hist.moves;
setCachedGame(id, st, hist.moves); setCachedGame(id, st, hist.moves, draft);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a // 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. // stale flash before the lobby's own background refresh lands.
patchLobbyGame(st.game); patchLobbyGame(st.game);
selected = null; selected = null;
await applyDraft(st); applyDraft(st, draft);
recompute(); recompute();
refreshRecent(); refreshRecent();
} catch (e) { } catch (e) {
@@ -156,28 +159,28 @@
} }
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null; let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short // scheduleDraftSave persists the composition (rack order + pending tiles) after a short
// debounce; best-effort, so a failed save never interrupts play. // debounce; best-effort, so a failed save never interrupts play. The cache is updated at once
// so leaving and re-entering the game in the same session paints the latest composition.
function scheduleDraftSave() { function scheduleDraftSave() {
const json = serializeDraft(rackIds, placement.pending);
setCachedDraft(id, json);
if (draftSaveTimer) clearTimeout(draftSaveTimer); if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => { draftSaveTimer = setTimeout(() => {
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void gateway.draftSave(id, json).catch(() => {});
}, 500); }, 500);
} }
// applyDraft restores the player's saved composition over a freshly loaded state: the rack // applyDraft restores the player's saved composition over a freshly loaded state: the rack
// order (when still a valid permutation of the rack) and the board tiles whose cell is still // order (when still a valid permutation of the rack) and the board tiles whose cell is still
// free. Best-effort — a draft fetch never blocks opening the game. // free. It takes the already-fetched draft JSON (load and the warm onMount supply it), so the
async function applyDraft(st: StateView) { // composition is applied synchronously — no extra fetch, no second rack→board step.
function applyDraft(st: StateView, draftJson: string) {
let order = st.rack.map((_, i) => i); let order = st.rack.map((_, i) => i);
let tiles: Tile[] = []; let tiles: Tile[] = [];
try { const parsed = parseDraft(draftJson);
const parsed = parseDraft(await gateway.draftGet(id)); if (parsed) {
if (parsed) { order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order;
order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order; const committed = replay(moves);
const committed = replay(moves); tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
}
} catch {
/* best-effort */
} }
rackIds = order; rackIds = order;
const rack = order.map((i) => st.rack[i]); const rack = order.map((i) => st.rack[i]);
@@ -192,8 +195,9 @@
if (cached) { if (cached) {
view = cached.view; view = cached.view;
moves = cached.moves; moves = cached.moves;
placement = newPlacement(cached.view.rack); // Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
rackIds = cached.view.rack.map((_, i) => i); // already on the board (no full-rack-then-jump). load() then refreshes in the background.
applyDraft(cached.view, cached.draft ?? '');
refreshRecent(); refreshRecent();
} }
void load(); void load();
@@ -576,7 +580,9 @@
function applyMoveResult(r: MoveResult) { function applyMoveResult(r: MoveResult) {
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
moves = [...moves, r.move]; moves = [...moves, r.move];
setCachedGame(id, view, moves); // A committed move clears the actor's draft on the server, so clear the cached draft too;
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
setCachedGame(id, view, moves, '');
patchLobbyGame(r.game); patchLobbyGame(r.game);
rackIds = r.rack.map((_, i) => i); rackIds = r.rack.map((_, i) => i);
placement = newPlacement(r.rack); placement = newPlacement(r.rack);
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { clearGameCache, getCachedGame, setCachedDraft, setCachedGame } from './gamecache';
import type { GameView, MoveRecord, StateView } from './model';
function gameView(id: string): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats: [],
};
}
function view(id: string, rack: string[] = ['A', 'B']): StateView {
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
}
function move(player: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
}
beforeEach(() => clearGameCache());
describe('setCachedGame', () => {
it('stores state, history and draft', () => {
setCachedGame('g1', view('g1'), [move(0)], '{"x":1}');
const c = getCachedGame('g1');
expect(c?.view.game.id).toBe('g1');
expect(c?.moves).toHaveLength(1);
expect(c?.draft).toBe('{"x":1}');
});
it('leaves draft undefined when none is given for a new entry', () => {
setCachedGame('g1', view('g1'), []);
expect(getCachedGame('g1')?.draft).toBeUndefined();
});
it('preserves the cached draft when draft is omitted (the live-event delta path)', () => {
setCachedGame('g1', view('g1'), [], 'DRAFT');
// A delta advances view+moves without refetching the draft; it must not be dropped.
setCachedGame('g1', view('g1', ['C', 'D']), [move(1)]);
const c = getCachedGame('g1');
expect(c?.draft).toBe('DRAFT'); // preserved
expect(c?.view.rack).toEqual(['C', 'D']); // view advanced
expect(c?.moves).toHaveLength(1);
});
it('clears the draft when passed an empty string (a committed move)', () => {
setCachedGame('g1', view('g1'), [], 'DRAFT');
setCachedGame('g1', view('g1'), [move(0)], '');
expect(getCachedGame('g1')?.draft).toBe('');
});
});
describe('setCachedDraft', () => {
it('updates only the draft of an already-cached game', () => {
setCachedGame('g1', view('g1', ['A']), [move(0)], 'OLD');
setCachedDraft('g1', 'NEW');
const c = getCachedGame('g1');
expect(c?.draft).toBe('NEW');
expect(c?.view.rack).toEqual(['A']); // unchanged
expect(c?.moves).toHaveLength(1);
});
it('is a no-op when the game is not cached', () => {
setCachedDraft('absent', 'X');
expect(getCachedGame('absent')).toBeUndefined();
});
});
describe('clearGameCache', () => {
it('drops every cached game', () => {
setCachedGame('g1', view('g1'), []);
setCachedGame('g2', view('g2'), []);
clearGameCache();
expect(getCachedGame('g1')).toBeUndefined();
expect(getCachedGame('g2')).toBeUndefined();
});
});
+28 -9
View File
@@ -1,15 +1,21 @@
// In-memory per-game cache. A game the player has opened once is kept here so a // In-memory per-game cache. A game the player has opened once — or one warmed ahead of time
// later re-entry renders instantly from the cache while a fresh fetch updates it in // by the lobby preload (see lib/preload) — is kept here so opening it renders instantly from
// the background removing the blank "loading" flash and the full redraw on every // the cache while a fresh fetch updates it in the background, removing the blank "loading"
// lobby <-> game navigation. It is intentionally process-memory only (no persistence): // flash and the full redraw on every lobby <-> game navigation. It is intentionally
// stale entries are corrected by the background refresh, and the cache is cleared on // process-memory only (no persistence): stale entries are corrected by the background
// logout. // refresh, and the cache is cleared on logout.
import type { MoveRecord, StateView } from './model'; import type { MoveRecord, StateView } from './model';
export interface CachedGame { export interface CachedGame {
view: StateView; view: StateView;
moves: MoveRecord[]; moves: MoveRecord[];
/**
* The player's saved draft (composition) as the opaque JSON the server stores, so a warm
* open paints the pending tiles already on the board with no rack→board jump. An empty
* string means no draft; undefined means it has not been fetched for this entry yet.
*/
draft?: string;
} }
const cache = new Map<string, CachedGame>(); const cache = new Map<string, CachedGame>();
@@ -19,9 +25,22 @@ export function getCachedGame(id: string): CachedGame | undefined {
return cache.get(id); return cache.get(id);
} }
/** setCachedGame stores the latest state+history for a game. */ /**
export function setCachedGame(id: string, view: StateView, moves: MoveRecord[]): void { * setCachedGame stores the latest state+history for a game. Omitting draft preserves the
cache.set(id, { view, moves }); * cached composition: the live-event delta path advances view+moves without refetching the
* draft and must not drop it, while passing an empty string clears it (a committed move does,
* mirroring the server).
*/
export function setCachedGame(id: string, view: StateView, moves: MoveRecord[], draft?: string): void {
const prev = cache.get(id);
cache.set(id, { view, moves, draft: draft ?? prev?.draft });
}
/** setCachedDraft updates only the saved draft of an already-cached game (a no-op otherwise),
* keeping the cache coherent with the draft persisted while composing. */
export function setCachedDraft(id: string, draft: string): void {
const prev = cache.get(id);
if (prev) cache.set(id, { ...prev, draft });
} }
/** clearGameCache drops every cached game (called on logout). */ /** clearGameCache drops every cached game (called on logout). */
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameView, StateView } from './model';
// Mock the gateway singleton so preload's fan-out is observed without a transport.
const mocks = vi.hoisted(() => ({
gameState: vi.fn(),
gameHistory: vi.fn(),
draftGet: vi.fn(),
}));
vi.mock('./gateway', () => ({
gateway: { gameState: mocks.gameState, gameHistory: mocks.gameHistory, draftGet: mocks.draftGet },
}));
import { preloadGames } from './preload';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
function gameView(id: string, status: GameView['status'] = 'active'): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
status,
players: 2,
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats: [],
};
}
function stateView(id: string): StateView {
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 };
}
beforeEach(() => {
clearGameCache();
mocks.gameState.mockReset().mockImplementation((id: string) => Promise.resolve(stateView(id)));
mocks.gameHistory.mockReset().mockImplementation((id: string) => Promise.resolve({ gameId: id, moves: [] }));
mocks.draftGet.mockReset().mockImplementation((id: string) => Promise.resolve(id === 'g1' ? 'DRAFT1' : ''));
});
describe('preloadGames', () => {
it('warms ongoing, uncached games with state, history and draft', async () => {
await preloadGames([gameView('g1'), gameView('g2')]);
expect(getCachedGame('g1')?.view.game.id).toBe('g1');
expect(getCachedGame('g1')?.draft).toBe('DRAFT1');
expect(getCachedGame('g2')?.draft).toBe('');
});
it('skips finished games', async () => {
await preloadGames([gameView('done', 'finished')]);
expect(getCachedGame('done')).toBeUndefined();
expect(mocks.gameState).not.toHaveBeenCalled();
});
it('skips games already in the cache (kept fresh by the live stream)', async () => {
setCachedGame('g1', stateView('g1'), [], 'KEEP');
await preloadGames([gameView('g1'), gameView('g2')]);
expect(mocks.gameState).toHaveBeenCalledTimes(1);
expect(mocks.gameState).toHaveBeenCalledWith('g2', expect.any(Boolean));
expect(getCachedGame('g1')?.draft).toBe('KEEP'); // untouched
});
it('does nothing for an empty list', async () => {
await preloadGames([]);
expect(mocks.gameState).not.toHaveBeenCalled();
});
});
+50
View File
@@ -0,0 +1,50 @@
// Lobby preload: warm the per-game cache for the player's ongoing games ahead of time, so
// opening one from the lobby renders instantly — no "loading" flash and no rack→board jump
// (the saved draft is fetched and cached alongside state and history). It is best-effort and
// bounded; finished games are skipped (rarely reopened, no draft), as are games already cached
// (the live stream keeps those fresh) or currently being warmed.
import { gateway } from './gateway';
import { getCachedGame, setCachedGame } from './gamecache';
import { hasAlphabet } from './alphabet';
import type { GameView } from './model';
// Cap on games warmed at once: enough to cover a typical lobby quickly without a request burst.
const MAX_CONCURRENT = 4;
// Games being warmed right now, so overlapping calls (the lobby refreshes on every live event)
// never fetch the same game twice.
const inflight = new Set<string>();
/**
* preloadGames warms the cache for the given ongoing games: it fetches each game's state,
* history and saved draft in parallel and stores them, skipping finished games and any already
* cached or in flight. Concurrency is bounded and failures are swallowed (the game just
* cold-loads on open). It resolves once every queued game has settled.
*/
export async function preloadGames(games: GameView[]): Promise<void> {
const queue = games.filter(
(g) => g.status !== 'finished' && !getCachedGame(g.id) && !inflight.has(g.id),
);
if (queue.length === 0) return;
for (const g of queue) inflight.add(g.id);
let next = 0;
const worker = async (): Promise<void> => {
while (next < queue.length) {
const g = queue[next++];
try {
const [st, hist, draft] = await Promise.all([
gateway.gameState(g.id, !hasAlphabet(g.variant)),
gateway.gameHistory(g.id),
gateway.draftGet(g.id).catch(() => ''),
]);
// A live event may have seeded or advanced this entry while we fetched; don't clobber it.
if (!getCachedGame(g.id)) setCachedGame(g.id, st, hist.moves, draft);
} catch {
/* best-effort: leave the game to cold-load on open */
} finally {
inflight.delete(g.id);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, queue.length) }, worker));
}
+5
View File
@@ -9,6 +9,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result'; import { resultBadge } from '../lib/result';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { groupGames } from '../lib/lobbysort'; import { groupGames } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
@@ -28,6 +29,10 @@
app.notifications = incoming.length; app.notifications = incoming.length;
} }
setLobby({ games, invitations, incoming }); setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) void preloadGames(games);
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }