From 38644f2a5900617b146c530d35db59ddb83413fe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 22:42:41 +0200 Subject: [PATCH] perf(ui): cancel the in-flight move preview when tiles move; debug cache-bust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placing or recalling a tile fires the network preview; on a slow link the requests piled up and an out-of-order response could overwrite the newest with a stale score. Cancel the previous in-flight evaluate on each change — evaluate is non-mutating, so aborting is safe (only submit_play and the debounced draft save persist), so only the latest request runs and the newest result wins; the game also aborts it on unmount. The abort threads an AbortSignal through the Connect exec (an intentional cancel does not retry). The debug reset now also bypasses the browser HTTP cache on the next dictionary load, so a "clear" forces a genuinely cold test — clearing IndexedDB alone left the immutable blob served straight from the HTTP cache. --- ui/src/components/DebugPanel.svelte | 1 + ui/src/game/Game.svelte | 16 +++++++++++++--- ui/src/lib/client.ts | 9 +++++---- ui/src/lib/dict/index.ts | 2 +- ui/src/lib/dict/loader.ts | 14 +++++++++++++- ui/src/lib/mock/client.ts | 4 ++-- ui/src/lib/transport.ts | 17 ++++++++++------- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index b38e695..b9f580b 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -59,6 +59,7 @@ void import('../lib/dict').then((m) => { m.clearDictCache(); m.clearDictInstances(); + m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test) }); resetLabel = 'Cleared — relaunch'; setTimeout(() => (resetLabel = 'Reset visited'), 1800); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 140ea17..6424bed 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -597,8 +597,10 @@ clearReorder(); } onDestroy(() => { - // Leaving the game: abort a still-downloading dictionary so it stops holding the channel. + // Leaving the game: abort a still-downloading dictionary and any in-flight preview so they + // stop holding the channel. warmCtrl?.abort(); + evalCtrl?.abort(); window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); @@ -702,9 +704,15 @@ } let previewTimer: ReturnType | null = null; + let evalCtrl: AbortController | null = null; function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); + // The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so + // aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and + // rapid placements do not pile up requests on a slow link. + evalCtrl?.abort(); + evalCtrl = null; // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; const sub = toSubmit(placement); @@ -723,11 +731,13 @@ } } } + const ctrl = new AbortController(); + evalCtrl = ctrl; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); } catch { - /* best-effort */ + /* best-effort (or aborted) */ } }, 250); } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index dfbb3b6..3f0d82b 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -91,7 +91,7 @@ export interface GatewayClient { exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; - evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ @@ -99,9 +99,10 @@ export interface GatewayClient { // --- dictionary (local move preview) --- /** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated; - * lets the client validate and score a move locally instead of a network round trip. The - * optional signal aborts a stalled download so it stops holding the channel. */ - fetchDict(variant: Variant, version: string, signal?: AbortSignal): Promise; + * lets the client validate and score a move locally instead of a network round trip. + * opts.signal aborts a stalled download; opts.reload bypasses the browser HTTP cache (the + * debug reset, for testing a cold load). */ + fetchDict(variant: Variant, version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise; // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts index 9e23769..e41ddb3 100644 --- a/ui/src/lib/dict/index.ts +++ b/ui/src/lib/dict/index.ts @@ -3,6 +3,6 @@ // of the initial app bundle — it is a progressive enhancement over the network // preview, which remains the fallback. export { evaluateLocal } from './eval'; -export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances } from './loader'; +export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; export { clearDictCache, listCachedDicts } from './store'; export { LOCAL_EVAL_ENABLED } from './config'; diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index 3564796..15a64db 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -39,6 +39,16 @@ export function dictLoadingDisabled(): boolean { return dictDisabled; } +// One-shot: the debug reset sets this so the next network fetch bypasses the browser HTTP +// cache. The immutable dict blob is otherwise cached for a year, so clearing IndexedDB alone +// still reloads it instantly from the HTTP cache — this forces a genuinely cold load. +let bustNext = false; + +/** bustDictHttpCache makes the next dictionary download bypass the browser HTTP cache. */ +export function bustDictHttpCache(): void { + bustNext = true; +} + /** * getDawg resolves the reader for the (variant, version) dictionary, or null when * it cannot be obtained (no session, offline, storage or decode failure) — the @@ -76,8 +86,10 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo // download does not keep starving the channel on a slow link; the caller counts the miss. if (dictDisabled) return null; let buf: ArrayBuffer; + const reload = bustNext; + bustNext = false; try { - buf = await gateway.fetchDict(variant, version, signal); + buf = await gateway.fetchDict(variant, version, { signal, reload }); } catch { return null; // network error or aborted } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index cf6a964..d08656f 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -161,7 +161,7 @@ export class MockGateway implements GatewayClient { // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. return { blocked: false, permanent: false, until: '', reason: '' }; } - async fetchDict(_variant: Variant, _version: string, _signal?: AbortSignal): Promise { + async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise { // No local dictionary in mock mode; the caller falls back to the mock evaluate. throw new Error('fetchDict unsupported in mock'); } @@ -437,7 +437,7 @@ export class MockGateway implements GatewayClient { }; } - async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { + async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise { const g = this.game(gameId); if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' }; let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 7e93bb7..892b982 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -36,12 +36,13 @@ export function createTransport(baseUrl: string): GatewayClient { // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. - async function exec(messageType: string, payload: Uint8Array): Promise { + async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { for (let attempt = 0; ; attempt++) { let res; try { - res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); + res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal }); } catch (e) { + if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry const err = toGatewayError(e); if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { reportOffline(); @@ -62,16 +63,18 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, - async fetchDict(variant, version, signal) { + async fetchDict(variant, version, opts) { if (!token) throw new Error('fetchDict: no session'); // Low priority so the browser schedules the (large) dictionary behind the game's own // requests — the move eval, state and live events — on a slow link (EDGE). Ignored // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The - // signal lets the caller abort a stalled download so it stops holding the channel. + // signal aborts a stalled download so it stops holding the channel; reload (the debug + // "clear") bypasses the browser HTTP cache to force a fresh copy. const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, { headers: { authorization: `Bearer ${token}` }, priority: 'low', - signal, + signal: opts?.signal, + cache: opts?.reload ? 'reload' : undefined, } as RequestInit); if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`); return res.arrayBuffer(); @@ -134,8 +137,8 @@ export function createTransport(baseUrl: string): GatewayClient { async hint(id) { return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); }, - async evaluate(id, tiles, variant) { - return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant))); + async evaluate(id, tiles, variant, signal) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant), signal)); }, async checkWord(id, word, variant) { return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));