fix(offline): cold-boot offline from the persisted session + profile #199

Merged
developer merged 5 commits from feature/offline-boot into development 2026-07-06 14:25:11 +00:00
Owner

Part of the PWA offline mode (Phase C). Fixes the last of the three contour bugs: an installed PWA relaunched with the network off hung on the splash.

Root cause

C1 (#197) precaches the app shell, so the bundle loads offline. But bootstrap() then adopted the persisted session and fetched the profile over the network (gateway.profileGet()) — which hangs with no network. And the profile was never persisted, so there was nothing to fall back to.

Fix

  • Persist the profilesession.ts saveProfile/loadProfile/clearProfile (mirroring saveSession), written on every online adopt + refresh, cleared on logout.
  • Boot short-circuit — when the deliberate offline flag is sticky-on, bootstrap() skips the session adoption + profile fetch and launches from the cached session + profile straight into the offline lobby. Without a cached profile (an install that was never online) it drops the sticky flag and boots online — the first launch must be online. The decision is the pure, unit-tested shouldBootOffline.

Tests

  • Unit: shouldBootOffline truth table (477 total).
  • ⚠️ Online cold-start is unaffected — e2e 196 green (the short-circuit is inert when offlineMode is off).
  • The offline boot itself is contour-verified — the mock e2e cannot enter sticky-offline (the toggle is PWA-gated). Please try it: install the PWA, toggle Offline, kill the network, relaunch → it should land in the offline lobby, not hang.

Docs: ARCHITECTURE.

This closes all three contour bugs (word-check + lobby refresh landed in #198; cold-boot here). Remaining offline fast-follows — Settings Friends/Profile/Feedback gating, flip-to-offline readiness wait, B4 hint UI, the offline mock e2e — are tracked in the plan.

Part of the PWA **offline mode** (Phase C). Fixes the last of the three contour bugs: **an installed PWA relaunched with the network off hung on the splash.** ## Root cause C1 (#197) precaches the app shell, so the bundle loads offline. But `bootstrap()` then adopted the persisted session and fetched the profile over the network (`gateway.profileGet()`) — which hangs with no network. And the profile was never persisted, so there was nothing to fall back to. ## Fix - **Persist the profile** — `session.ts` `saveProfile`/`loadProfile`/`clearProfile` (mirroring `saveSession`), written on every online adopt + refresh, cleared on logout. - **Boot short-circuit** — when the deliberate offline flag is sticky-on, `bootstrap()` skips the session adoption + profile fetch and launches from the cached session + profile straight into the offline lobby. Without a cached profile (an install that was never online) it drops the sticky flag and boots online — the first launch must be online. The decision is the pure, unit-tested `shouldBootOffline`. ## Tests - Unit: `shouldBootOffline` truth table (477 total). - ⚠️ **Online cold-start is unaffected** — e2e 196 green (the short-circuit is inert when `offlineMode` is off). - The **offline boot itself is contour-verified** — the mock e2e cannot enter sticky-offline (the toggle is PWA-gated). Please try it: install the PWA, toggle Offline, kill the network, relaunch → it should land in the offline lobby, not hang. Docs: ARCHITECTURE. This closes all three contour bugs (word-check + lobby refresh landed in #198; cold-boot here). Remaining offline fast-follows — Settings Friends/Profile/Feedback gating, flip-to-offline readiness wait, B4 hint UI, the offline mock e2e — are tracked in the plan.
developer added 1 commit 2026-07-06 12:54:48 +00:00
fix(offline): cold-boot offline from the persisted session + profile
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 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
54d701fd8a
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).
developer added 1 commit 2026-07-06 13:20:16 +00:00
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
19e7ea5da0
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).
developer added 1 commit 2026-07-06 13:36:52 +00:00
fix(offline): give the local human seat the real account id
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 1m41s
2a045a5b37
In a local game the human seat's account id was a synthetic 'local:human:0',
so seatName's `accountId === session.userId` check never matched: the game
header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's
groupGames could not find the viewer's seat, so a human's turn read as
'Their turn' with the hourglass. Carry the real account id on the human seat
(create -> record -> GameView); the robot keeps its synthetic id.

Local games created before this fix keep the old display (no migration).
developer added 1 commit 2026-07-06 13:46:23 +00:00
feat(offline): transport kill switch + gate the network-requiring UI
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 1m43s
1a456c4847
Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.

Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
  unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
  the reachability probe — is refused before it leaves the device. Offline
  truly means offline regardless of any UI that slips through. The local
  (device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
  (About) are disabled offline, and the hub falls back to Settings (which holds
  the online/offline toggle); the lobby Stats tab was already disabled. In-game
  social/export are already hidden for a vs_ai game.

Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
developer added 1 commit 2026-07-06 14:12:50 +00:00
fix(offline): skip the NewGame friend fetch offline
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
fdf14d5897
NewGame's onMount fetched the friend list (gateway.friendsList) for a non-guest;
offline the transport kill switch refuses it, so it toasted on entering the New
Game screen (the local game still created fine). The friends section is hidden
offline anyway — skip the fetch when offline.
owner approved these changes 2026-07-06 14:22:54 +00:00
developer merged commit fd225564a3 into development 2026-07-06 14:25:11 +00:00
developer deleted branch feature/offline-boot 2026-07-06 14:25:11 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#199