From d57dbbab4f919379e096d2a7df5438678f84f5ef Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 21:31:23 +0200 Subject: [PATCH] perf(ui): deprioritise the dictionary download; drop the lobby prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/UI_DESIGN.md | 7 +++-- ui/scripts/bundle-size.mjs | 6 ++-- ui/src/lib/dict/loader.ts | 6 ++-- ui/src/lib/dict/prefetch.ts | 55 ------------------------------------- ui/src/lib/dict/store.ts | 18 ++++++++++++ ui/src/lib/transport.ts | 6 +++- ui/src/screens/Lobby.svelte | 9 +----- 7 files changed, 36 insertions(+), 71 deletions(-) delete mode 100644 ui/src/lib/dict/prefetch.ts diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 03bba76..c6bc65d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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. diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 76046ef..61c0519 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -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. diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index fde66d6..4deab86 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -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(); - -/** - * 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 { - 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(); - 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 => { - 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)); -} diff --git a/ui/src/lib/dict/store.ts b/ui/src/lib/dict/store.ts index e0dc21d..f79f7ac 100644 --- a/ui/src/lib/dict/store.ts +++ b/ui/src/lib/dict/store.ts @@ -65,6 +65,24 @@ export async function idbPutDawg(key: string, data: ArrayBuffer): Promise } } +/** 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 { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + /* best-effort */ + } +} + let persistRequested = false; /** diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index bb9ee65..40f7bca 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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(); }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index a2cf065..3a5f3a3 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -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 {