perf(ui): cancel the in-flight move preview when tiles move; debug cache-bust
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

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.
This commit is contained in:
Ilia Denisov
2026-07-01 22:42:41 +02:00
parent 6a6fdffd7f
commit 38644f2a59
7 changed files with 45 additions and 18 deletions
+1
View File
@@ -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);
+13 -3
View File
@@ -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<typeof setTimeout> | 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);
}
+5 -4
View File
@@ -91,7 +91,7 @@ export interface GatewayClient {
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
/** 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<ArrayBuffer>;
* 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<ArrayBuffer>;
// --- draft ---
/** The player's server-persisted client-side composition (rack order + board tiles), so a
+1 -1
View File
@@ -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';
+13 -1
View File
@@ -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
}
+2 -2
View File
@@ -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<ArrayBuffer> {
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// 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<EvalResult> {
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise<EvalResult> {
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);
+10 -7
View File
@@ -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<Uint8Array> {
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
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)));