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
+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();
});
});