perf(ui): deprioritise the dictionary download; drop the lobby prefetch
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s

On a slow link (EDGE) the background dictionary download starved the game's own
traffic — the board and the move preview stalled behind a 450KB fetch. Fetch the
dawg at low priority (Priority Hints; ignored on iOS WebKit, harmless) so the
browser schedules it behind the game's requests, and load it only on game open —
the lobby no longer prefetches, which was its main competitor for the channel.
Evict a cached blob the reader rejects (a stale or partial entry) so it self-heals
instead of lingering and re-fetches. Raise the app-entry bundle budget to 110KB
(the feature's small in-entry game/debug wiring; the heavy dict code stays lazy).
This commit is contained in:
Ilia Denisov
2026-07-01 21:31:23 +02:00
parent 2776ef83c2
commit d57dbbab4f
7 changed files with 36 additions and 71 deletions
+4 -3
View File
@@ -429,9 +429,10 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
## Dictionary warm-up overlay (`components/DictWarmup.svelte`)
Opening a game whose local move-preview dictionary is not yet in memory shows a
brief, non-dismissable warm-up overlay while it loads (the lobby pre-warms the
player's ongoing games, so it rarely appears). A darker-than-onboarding scrim covers
Opening a game whose local move-preview dictionary is not yet cached shows a brief,
non-dismissable warm-up overlay while it downloads (a returning player's cached
dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then).
A darker-than-onboarding scrim covers
the board; centred on it, a large emoji cycles through a fixed playful sequence — one
per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph
fading out and the next fading in), looping — under a constant "Loading…" caption.
+4 -2
View File
@@ -19,8 +19,10 @@ import { join } from 'node:path';
const DIST = 'dist';
// Per-chunk gzip budgets in KB.
const BUDGET = { app: 100, shared: 30, landing: 5 };
// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local
// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this
// covers the small in-entry game/debug wiring it needs).
const BUDGET = { app: 110, shared: 30, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+4 -2
View File
@@ -6,7 +6,7 @@
// same dictionary share one in-flight load.
import { Dawg } from './dawg';
import { dictKey, idbGetDawg, idbPutDawg, requestPersist } from './store';
import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store';
import { gateway } from '../gateway';
import type { Variant } from '../model';
@@ -61,7 +61,9 @@ async function load(variant: Variant, version: string, key: string): Promise<Daw
return reader;
}
} catch {
/* fall through to the network */
// A cached blob the reader rejected (a stale or partial entry): evict it so the next
// load re-fetches instead of failing on it forever, then fall through to the network.
void idbDelDawg(key);
}
// Tier 2: the session-gated download — gated by the bad-connection breaker.
-55
View File
@@ -1,55 +0,0 @@
// Lobby-time warm-up of the local move-preview dictionaries: after the lobby
// loads, fetch the DAWG of each of the player's unfinished games in the
// background, so opening one previews moves locally with no wait. It mirrors
// preloadGames — bounded, best-effort, dedup'd — and warms "your turn" games
// first, since those are the ones the player is most likely to open and play.
// Each distinct (variant, version) is fetched once.
import { getDawg, hasDawg } from './loader';
import { dictKey } from './store';
import { isMyTurn } from '../lobbysort';
import type { GameView, Variant } from '../model';
// Cap on dictionaries warmed at once. A player rarely spans more than a couple of
// (variant, version) pairs, so this is mostly a safety bound on a burst.
const MAX_CONCURRENT = 3;
// Dictionaries being warmed right now, so the lobby's repeated refreshes never
// queue the same one twice.
const inflight = new Set<string>();
/**
* preloadDicts warms the local dictionary cache for the player's unfinished games,
* your-turn games first (myId identifies the viewer). It is best-effort and
* bounded; finished games and already-loaded or in-flight dictionaries are
* skipped. It resolves once every queued dictionary has settled.
*/
export async function preloadDicts(games: GameView[], myId: string): Promise<void> {
const ordered = [...games].sort((a, b) => Number(isMyTurn(b, myId)) - Number(isMyTurn(a, myId)));
const targets: Array<{ variant: Variant; version: string; key: string }> = [];
const seen = new Set<string>();
for (const g of ordered) {
if (g.status === 'finished' || !g.dictVersion) continue;
const key = dictKey(g.variant, g.dictVersion);
if (seen.has(key) || inflight.has(key) || hasDawg(g.variant, g.dictVersion)) continue;
seen.add(key);
targets.push({ variant: g.variant, version: g.dictVersion, key });
}
if (targets.length === 0) return;
for (const t of targets) inflight.add(t.key);
let next = 0;
const worker = async (): Promise<void> => {
while (next < targets.length) {
const t = targets[next++];
try {
await getDawg(t.variant, t.version);
} catch {
/* best-effort: the game falls back to the network preview on open */
} finally {
inflight.delete(t.key);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, targets.length) }, worker));
}
+18
View File
@@ -65,6 +65,24 @@ export async function idbPutDawg(key: string, data: ArrayBuffer): Promise<void>
}
}
/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so
* the next load re-fetches instead of failing on it forever. Best-effort. */
export async function idbDelDawg(key: string): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
/* best-effort */
}
}
let persistRequested = false;
/**
+5 -1
View File
@@ -64,9 +64,13 @@ export function createTransport(baseUrl: string): GatewayClient {
async fetchDict(variant, version) {
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.
const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, {
headers: { authorization: `Bearer ${token}` },
});
priority: 'low',
} as RequestInit);
if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`);
return res.arrayBuffer();
},
+1 -8
View File
@@ -51,14 +51,7 @@
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) {
void preloadGames(games);
// Warm the local move-preview dictionaries (your-turn first). The dict subsystem is
// dynamically imported so it stays out of the initial app bundle — a progressive
// enhancement; without it a game simply falls back to the network preview.
const myId = app.session?.userId ?? '';
void import('../lib/dict/prefetch').then((m) => m.preloadDicts(games, myId));
}
if (connection.online) void preloadGames(games);
} catch (e) {
handleError(e);
} finally {