feat(offline): background-preload dictionaries for offline readiness
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s

An installed PWA (standalone web + confirmed email) warms the dictionaries
for the player's enabled variants while online — on lobby entry and on a
variant-preference change — using the per-variant version the profile now
advertises, so a later switch to offline mode already has the data.

- dict/preload.ts: pure preloadDicts (retry + linear backoff, honours the
  session dictionary miss-breaker); node-tested.
- dict/preloadrun.ts: lazy browser orchestration (real getDawg), imported
  dynamically so the loader and move generator stay out of the main bundle.
- offline.svelte.ts: kickDictPreload, gated by the pure, tested
  offlinePreloadEligible, plus the reactive first-lobby preload warning.
- Header shows a poor-connection notice in the ad-banner slot on a
  first-lobby preload failure (offline.preloadWarning, en/ru).
- App-entry bundle budget 112->113 for the irreducible main-side wiring
  (documented in bundle-size.mjs); the heavy parts remain lazy chunks.

Docs: ARCHITECTURE offline-mode + dict-preload mechanism.
This commit is contained in:
Ilia Denisov
2026-07-06 10:39:55 +02:00
parent a692024b4e
commit 2f867b8e6c
13 changed files with 311 additions and 10 deletions
+10 -1
View File
@@ -1243,7 +1243,16 @@ browser-language detection — crawlers render with arbitrary languages). The fa
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
installability requirement (a registered SW, needed for install on Android) and is the single installability requirement (a registered SW, needed for install on Android) and is the single
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+6 -4
View File
@@ -20,10 +20,12 @@ import { join } from 'node:path';
const DIST = 'dist'; const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview // Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection / // wiring, to 112 for the PWA install feature, then to 113 for the offline-mode wiring: the
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy // dictionary-preload trigger and the offline-state reactives live in the app entry (the Header
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. // reads them, the lobby/profile screens fire the trigger), while the heavy parts — the dict
const BUDGET = { app: 112, shared: 30, landing: 5 }; // loader, the move generator and the preload orchestration — stay in lazy chunks. Scoped CSS
// lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 113, shared: 30, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // 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. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+18 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte'; import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
@@ -58,7 +58,11 @@
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears, dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
per this same condition, once onboarding closes. --> per this same condition, once onboarding closes. -->
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive} {#if dictPreloadWarning.active}
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
<AdBanner <AdBanner
campaigns={app.profile.banner.campaigns} campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings} timings={app.profile.banner.timings}
@@ -142,6 +146,18 @@
border-radius: 999px; border-radius: 999px;
white-space: nowrap; white-space: nowrap;
} }
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
the banner region so the bar does not jump when it appears. */
.preload-warn {
margin: 0;
padding: 7px var(--pad);
font-size: 0.78rem;
line-height: 1.3;
text-align: center;
color: var(--text-muted);
background: var(--surface-2);
border-top: 1px solid var(--border);
}
.back { .back {
background: none; background: none;
border: none; border: none;
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { preloadDicts } from './preload';
import type { Variant } from '../model';
import type { Dawg } from './dawg';
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
const DAWG = {} as Dawg;
const noSleep = (): Promise<void> => Promise.resolve();
describe('preloadDicts', () => {
it('fetches every enabled variant that has a known version', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
getDawg: async (v: Variant, ver: string) => {
calls.push(`${v}@${ver}`);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
expect(res.failed).toEqual([]);
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
});
it('marks a variant with no known version as failed without fetching it', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
getDawg: async (v: Variant) => {
calls.push(v);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(res.failed).toEqual(['scrabble_ru']);
expect(calls).toEqual(['scrabble_en']);
});
it('retries a transient failure with linear backoff, then succeeds', async () => {
let attempts = 0;
const waits: number[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => (++attempts >= 3 ? DAWG : null),
disabled: () => false,
sleep: async (ms: number) => void waits.push(ms),
retries: 3,
backoffMs: 100,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(attempts).toBe(3);
expect(waits).toEqual([100, 200]);
});
it('gives up a persistent failure after the retry budget', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => false,
sleep: noSleep,
retries: 2,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(res.ok).toEqual([]);
expect(attempts).toBe(3);
});
it('stops retrying once the session miss-breaker trips', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => true,
sleep: noSleep,
retries: 5,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(attempts).toBe(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
// warms the dictionaries for the player's enabled variants while online, so a later switch to
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
// generator is pulled into the main bundle.
import type { Variant } from '../model';
import type { Dawg } from './dawg';
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
export interface PreloadDeps {
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
* or null on any miss — mirrors the in-game loader. */
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
* misses this session); when it has, a network fetch will not recover, so retries stop. */
disabled: () => boolean;
/** sleep waits between retries; defaults to a real timer. */
sleep?: (ms: number) => Promise<void>;
/** retries is the number of extra attempts after the first (default 2). */
retries?: number;
/** backoffMs is the base linear backoff between attempts (default 800). */
backoffMs?: number;
}
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
* missing (failed) after the preload — the caller surfaces a warning when failed is non-empty. */
export interface PreloadResult {
ok: Variant[];
failed: Variant[];
}
/**
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
* version, retrying transient misses with linear backoff. A variant with no known version, or one
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
* recover this session — a cached dictionary is still served by getDawg regardless.
*/
export async function preloadDicts(
versions: Partial<Record<Variant, string>>,
enabled: readonly Variant[],
deps: PreloadDeps,
): Promise<PreloadResult> {
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
const retries = deps.retries ?? 2;
const backoffMs = deps.backoffMs ?? 800;
const ok: Variant[] = [];
const failed: Variant[] = [];
for (const variant of enabled) {
const version = versions[variant];
if (!version) {
failed.push(variant);
continue;
}
let dawg: Dawg | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
dawg = await deps.getDawg(variant, version);
if (dawg || deps.disabled()) break;
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
}
(dawg ? ok : failed).push(variant);
}
return { ok, failed };
}
+24
View File
@@ -0,0 +1,24 @@
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { setDictPreloadWarning } from '../offline.svelte';
/**
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
* still missing afterwards, and clears it on a run where every variant is available.
*/
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
});
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
}
+1
View File
@@ -217,6 +217,7 @@ export const en = {
'settings.offlineMode': 'Play mode', 'settings.offlineMode': 'Play mode',
'settings.online': 'Online', 'settings.online': 'Online',
'settings.offline': 'Offline', 'settings.offline': 'Offline',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'about.title': 'About', 'about.title': 'About',
'about.tab': 'Info', 'about.tab': 'Info',
+1
View File
@@ -217,6 +217,7 @@ export const ru: Record<MessageKey, string> = {
'settings.offlineMode': 'Режим игры', 'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн', 'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн', 'settings.offline': 'Оффлайн',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо', 'about.tab': 'Инфо',
+57 -1
View File
@@ -3,7 +3,11 @@
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient // Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts. // gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
import { loadOfflinePref, saveOfflinePref } from './offline'; import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription). // Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref()); let active = $state(loadOfflinePref());
@@ -21,3 +25,55 @@ export function setOfflineMode(on: boolean): void {
active = on; active = on;
saveOfflinePref(on); saveOfflinePref(on);
} }
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
export const dictPreloadWarning = {
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
get active(): boolean {
return preloadWarn;
},
};
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on;
}
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false;
/**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
* browser tab, without a confirmed email, while offline, or when a preload is already running;
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
* and generator are imported dynamically, so neither is pulled into the main bundle.
*/
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return;
const eligible = offlinePreloadEligible({
hasEmail: !!prof.email,
standalone: isStandalone(),
inTelegram: insideTelegram(),
inVK: insideVK(),
online: typeof navigator === 'undefined' || navigator.onLine !== false,
});
if (!eligible) return;
preloadInFlight = true;
void import('./dict/preloadrun')
.then((m) => m.runPreload(prof, warnOnFail))
.catch(() => {
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
})
.finally(() => {
preloadInFlight = false;
});
}
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts } from './offline'; import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline';
import type { Variant } from './model'; import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none). // A minimal in-memory localStorage for the persistence tests (node has none).
@@ -35,4 +35,14 @@ describe('offline mode helpers', () => {
const has = (v: Variant): boolean => v === 'scrabble_en'; const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']); expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
}); });
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
}); });
+17
View File
@@ -41,3 +41,20 @@ export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant)
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] { export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v)); return enabled.filter((v) => !hasDict(v));
} }
/**
* offlinePreloadEligible reports whether a background dictionary preload should run in this
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
* toggle's eligibility so the two never disagree.
*/
export function offlinePreloadEligible(opts: {
hasEmail: boolean;
standalone: boolean;
inTelegram: boolean;
inVK: boolean;
online: boolean;
}): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
}
+8 -1
View File
@@ -13,6 +13,7 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { kickDictPreload } from '../lib/offline.svelte';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -51,7 +52,13 @@
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list // 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 // 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). // lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) void preloadGames(games); if (connection.online) {
void preloadGames(games);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
kickDictPreload(app.profile, true);
}
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
+4
View File
@@ -16,6 +16,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideVK } from '../lib/vk'; import { insideVK } from '../lib/vk';
import { kickDictPreload } from '../lib/offline.svelte';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid'; import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { import {
@@ -158,6 +159,9 @@
notificationsInAppOnly, notificationsInAppOnly,
variantPreferences: variantPrefs, variantPreferences: variantPrefs,
}); });
// A variant-preference change may enable a variant whose dictionary is not yet cached; warm
// it in the background for offline readiness (quietly — no in-lobby notice for a top-up).
kickDictPreload(app.profile, false);
showToast(t('profile.saved')); showToast(t('profile.saved'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);