Files
scrabble-game/ui/src/lib/preload.ts
T
Ilia Denisov c9021fc070
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
feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
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.
2026-06-14 17:53:03 +02:00

51 lines
2.3 KiB
TypeScript

// 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));
}