From 54d701fd8a0c0dd181330e7b2492afb5b3196360 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 14:54:09 +0200 Subject: [PATCH] fix(offline): cold-boot offline from the persisted session + profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An installed PWA relaunched with the network off hung on the splash: C1's precache served the shell, but bootstrap() adopted the session and fetched the profile over the network, which hung. Persist the profile (on every online adopt + refresh, cleared on logout) and short-circuit bootstrap when the deliberate offline flag is sticky-on: with a cached session + profile, skip the network and land straight in the offline lobby. Without a cached profile (never online), drop the sticky flag and boot online — the first launch must be online. - session.ts: saveProfile/loadProfile/clearProfile (mirror saveSession). - offline.ts: shouldBootOffline decision (unit-tested). - app.svelte.ts: persist in adoptSession + refreshProfile, clear on logout, the offline boot short-circuit in bootstrap. Docs: ARCHITECTURE. Online cold-start unaffected (e2e 196); the offline boot is contour-verified (the mock e2e cannot enter sticky-offline). --- docs/ARCHITECTURE.md | 6 +++++- ui/src/lib/app.svelte.ts | 28 ++++++++++++++++++++++++++++ ui/src/lib/offline.test.ts | 9 ++++++++- ui/src/lib/offline.ts | 10 ++++++++++ ui/src/lib/session.ts | 21 ++++++++++++++++++++- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 18ca0e9..c7d8d85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads* — 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 +the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and +profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session +adoption and profile fetch that would otherwise hang with no network, and lands straight in the +offline lobby; without a cached profile (an install that was never online) it drops the sticky flag +and boots online instead. The gateway registers the `.webmanifest` MIME type 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 `no-cache` so a new deploy is picked up — both containers apply the same caching. An diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 1c0a784..a4041fe 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink'; import { clearOnboarding, clearSession, + clearProfile, + loadProfile, + saveProfile, loadOnboarding, loadPrefs, loadSession, @@ -53,6 +56,8 @@ import { } from './session'; import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; +import { offlineMode, setOfflineMode } from './offline.svelte'; +import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { advanceCached } from './gamedelta'; @@ -460,6 +465,7 @@ export async function refreshProfile(): Promise { if (!app.session) return; try { app.profile = await gateway.profileGet(); + void saveProfile(app.profile); // keep the offline-boot cache fresh } catch { // Best-effort; the banner just stays as it was until the next fetch. } @@ -517,6 +523,9 @@ async function adoptSession(s: Session): Promise { await saveSession(s); try { app.profile = await gateway.profileGet(); + // Persist the profile so a later offline cold start can launch from it (its variant + // preferences, dictionary versions and display name) without a network fetch. + void saveProfile(app.profile); // The live interface language follows the device — the explicit local choice (saved in // prefs) or the system guess made at bootstrap — and is no longer overridden from the // account here: the Telegram bot a user signs in through must not dictate the UI, so a @@ -790,6 +799,24 @@ export async function bootstrap(): Promise { // and skipped in the mock build; see lib/pwa.svelte. registerServiceWorker(); + // Deliberate offline mode carried over from a prior session: with no network the session adoption + // + profile fetch below would hang the splash, so skip them and launch straight from the cached + // session + profile into the offline lobby. Without both (e.g. cleared storage, or an install that + // was never online), drop the sticky flag and fall through to the normal online flow — the first + // launch must be online. + if (offlineMode.active) { + const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]); + if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) { + gateway.setToken(offlineSession!.token); + app.session = offlineSession!; + app.profile = offlineProfile!; + if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); + app.ready = true; + return; + } + setOfflineMode(false); + } + const saved = await loadSession(); // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // from VK; capture and clear it here (it needs the restored session to link against). @@ -1023,6 +1050,7 @@ export async function logout(): Promise { app.splashDone = false; gateway.setToken(null); await clearSession(); + await clearProfile(); app.session = null; app.profile = null; navigate('/login'); diff --git a/ui/src/lib/offline.test.ts b/ui/src/lib/offline.test.ts index 172fbf3..00c48ab 100644 --- a/ui/src/lib/offline.test.ts +++ b/ui/src/lib/offline.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline'; +import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline'; import type { Variant } from './model'; // A minimal in-memory localStorage for the persistence tests (node has none). @@ -45,4 +45,11 @@ describe('offline mode helpers', () => { expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false); expect(offlinePreloadEligible({ ...base, online: false })).toBe(false); }); + + it('shouldBootOffline requires the offline flag plus a cached session and profile', () => { + expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true); + expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false); + expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false); + expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false); + }); }); diff --git a/ui/src/lib/offline.ts b/ui/src/lib/offline.ts index 6506243..e461e6e 100644 --- a/ui/src/lib/offline.ts +++ b/ui/src/lib/offline.ts @@ -58,3 +58,13 @@ export function offlinePreloadEligible(opts: { }): boolean { return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online; } + +/** + * shouldBootOffline decides whether a cold start skips the network and launches straight into + * offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached + * session and profile to start from. Without both (e.g. cleared storage, or a never-online install), + * the app must boot online once first — the caller then drops the sticky flag and continues online. + */ +export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean { + return opts.offlineActive && opts.hasSession && opts.hasProfile; +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index 229aa34..734da34 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -3,7 +3,7 @@ // re-login), with a localStorage fallback. Losing the store just means re-login — // acceptable, and for a guest it simply mints a fresh guest. -import type { Session } from './model'; +import type { Session, Profile } from './model'; import type { ThemePref } from './theme'; import type { Locale } from './i18n/catalog'; import type { BoardLabelMode } from './boardlabels'; @@ -119,6 +119,25 @@ export function clearSession(): Promise { return kvDel(SESSION_KEY); } +const PROFILE_KEY = 'profile'; + +/** loadProfile reads the last persisted profile (or null). It backs the offline cold-boot: with no + * network, bootstrap uses it instead of fetching a fresh one. */ +export function loadProfile(): Promise { + return kvGet(PROFILE_KEY); +} + +/** saveProfile persists the caller's own profile so a later offline launch can start from it (its + * variant preferences, dictionary versions and display name), best-effort. */ +export function saveProfile(p: Profile): Promise { + return kvSet(PROFILE_KEY, p); +} + +/** clearProfile drops the persisted profile (on logout, with the session). */ +export function clearProfile(): Promise { + return kvDel(PROFILE_KEY); +} + export interface Prefs { theme: ThemePref; locale: Locale;