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