fix(offline): persist a plain profile snapshot; keep the sticky offline flag
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
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 1m40s

The offline cold-boot never engaged: it persisted app.profile — a Svelte
$state proxy — which is not structured-cloneable, so the IndexedDB write threw
and fell back to a localStorage entry that loadProfile (IDB-only on a
successful-empty read) never reads. loadProfile returned null, the boot
short-circuit missed, and it even cleared the sticky offline flag — so offline
mode stopped persisting across relaunches (owner-observed on the contour).

- Persist $state.snapshot(app.profile) (a plain object) at both persist sites,
  so the IndexedDB write succeeds and loadProfile round-trips.
- Drop the setOfflineMode(false) fallback: keep the deliberate offline flag on a
  cache miss (the mode is the player's choice; an online boot re-persists the
  profile so the next launch goes offline). A truly offline launch with no
  cached profile is unreachable (enabling offline needs a prior online session).
This commit is contained in:
Ilia Denisov
2026-07-06 15:20:15 +02:00
parent 54d701fd8a
commit 19e7ea5da0
+10 -8
View File
@@ -56,7 +56,7 @@ import {
} from './session'; } from './session';
import type { CoachSeries } from './coachmark'; import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte'; import { offlineMode } from './offline.svelte';
import { shouldBootOffline } from './offline'; import { shouldBootOffline } from './offline';
import { isConnectionCode } from './retry'; import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
@@ -465,7 +465,7 @@ export async function refreshProfile(): Promise<void> {
if (!app.session) return; if (!app.session) return;
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
void saveProfile(app.profile); // keep the offline-boot cache fresh void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
} catch { } catch {
// Best-effort; the banner just stays as it was until the next fetch. // Best-effort; the banner just stays as it was until the next fetch.
} }
@@ -524,8 +524,10 @@ async function adoptSession(s: Session): Promise<void> {
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// Persist the profile so a later offline cold start can launch from it (its variant // 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. // preferences, dictionary versions and display name) without a network fetch. Snapshot to a
void saveProfile(app.profile); // plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
void saveProfile($state.snapshot(app.profile));
// The live interface language follows the device — the explicit local choice (saved in // 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 // 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 // account here: the Telegram bot a user signs in through must not dictate the UI, so a
@@ -801,9 +803,10 @@ export async function bootstrap(): Promise<void> {
// Deliberate offline mode carried over from a prior session: with no network the session adoption // 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 // + 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 // session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// was never online), drop the sticky flag and fall through to the normal online flow — the first // through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// launch must be online. // an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) { if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]); const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) { if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
@@ -814,7 +817,6 @@ export async function bootstrap(): Promise<void> {
app.ready = true; app.ready = true;
return; return;
} }
setOfflineMode(false);
} }
const saved = await loadSession(); const saved = await loadSession();