Files
scrabble-game/ui/src/lib/netstate.ts
T
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00

230 lines
11 KiB
TypeScript

// The pure net-state machine. A single reducer replaces the old
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).
/**
* NetState is the single detected connectivity/version state.
* - `online` — the gateway is reachable and the client version is accepted; full features. It may
* additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
* leaving this state.
* - `connecting` — a call or probe is currently failing but the anti-flap window has not elapsed;
* the chrome stays online ("Connecting…"). Transient — never a kill switch.
* - `offlineNoNetwork` — sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
* local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
* - `offlineVersionLocked` — the gateway is reachable but the version is below the hard minimum:
* offline mode plus the "Update / Play offline" notice. Sticky — it exits only via an app update
* (a fresh version on the next boot).
*/
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';
/**
* NetEvent is every signal that can drive the machine.
* - `boot` — a fresh app start (the only exit from the sticky version lock).
* - `callOk` / `callFailed` — a foreground call succeeded / failed at the transport.
* - `probeOk` / `probeFailed` — the reachability probe succeeded / failed.
* - `osOnline` / `osOffline` — an OS connectivity hint (navigator / @capacitor/network); a hint only
* triggers a probe, it never decides the state by itself.
* - `versionRejected` — the hard gate refused the client as too old (update_required).
* - `versionRecommended` — the soft gate signalled an available (non-blocking) update.
*/
export type NetEvent =
| 'boot'
| 'callOk'
| 'callFailed'
| 'probeOk'
| 'probeFailed'
| 'osOnline'
| 'osOffline'
| 'versionRejected'
| 'versionRecommended';
/** NetConfig tunes the anti-flap hysteresis. */
export interface NetConfig {
/**
* k is the consecutive-failure threshold that trips `connecting` → `offlineNoNetwork`. It must be
* at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
*/
k: number;
/**
* debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
* whose timestamp is at least this far past the moment `connecting` was entered also trips
* `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
* hold the chrome in "Connecting…".
*/
debounceMs: number;
}
/**
* NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
* bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
* count-based and the time-based anti-flap branches; both are zero outside `connecting`.
*/
export interface NetSnapshot {
/** state is the current detected state. */
state: NetState;
/** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
fails: number;
/**
* connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
* it (0 while not `connecting`).
*/
connectingSince: number;
/**
* provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from
* a real online→offline transition. The first probe confirms it: a success means we were reachable
* all along, so the machine heals to online SILENTLY (no "back online" toast — there was nothing to
* come back from); a failure clears the flag, turning it into a confirmed offline whose later
* recovery does toast. Absent/false in every non-boot snapshot.
*/
provisional?: boolean;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
* reads `at`, and callers stamp `Date.now()`. */
export interface NetInput {
/** type is the event. */
type: NetEvent;
/** at is the event's timestamp in milliseconds. */
at: number;
}
/**
* Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action — the
* reducer never runs it.
* - `toast` — show the offline / back-online toast.
* - `startProbe` — kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
* - `showVersionNotice` — raise the "Update / Play offline" notice (entering the version lock).
* - `setNudge` — latch the dismissible "update available" soft nudge.
*/
export type Effect =
| { kind: 'toast'; toast: 'offline' | 'online' }
| { kind: 'startProbe' }
| { kind: 'showVersionNotice' }
| { kind: 'setNudge' };
/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
export interface NetResult {
/** next is the snapshot after applying the event. */
next: NetSnapshot;
/** effects are the side-effects the O2 store should run, in order. */
effects: Effect[];
}
/**
* INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
* `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
*/
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
function onlineSnapshot(): NetSnapshot {
return { state: 'online', fails: 0, connectingSince: 0 };
}
function enterConnecting(at: number, fails: number): NetResult {
return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
}
/**
* reduce applies one event to the machine and returns the next snapshot plus the effects the store
* should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
*
* `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
* machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
* supersedes into `offlineVersionLocked`.
*/
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
// A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
// version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
// transition that leaves the sticky version lock (a possibly-newer build).
if (ev.type === 'boot') {
return enterConnecting(ev.at, 0);
}
// The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
// a repeated rejection while already locked changes nothing.
if (ev.type === 'versionRejected') {
const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
}
switch (prev.state) {
case 'offlineVersionLocked':
// Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
// version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
return { next: prev, effects: [] };
case 'online':
switch (ev.type) {
case 'callFailed':
case 'probeFailed':
// A real failure opens the hysteresis window (probe + timer); this first failure counts.
return enterConnecting(ev.at, 1);
case 'osOffline':
// An OS hint only opens the window and a probe — it never decides offline by itself, so it
// enters `connecting` with no failure counted.
return enterConnecting(ev.at, 0);
case 'versionRecommended':
// The soft tier nudges only from online; play continues.
return { next: prev, effects: [{ kind: 'setNudge' }] };
default:
// callOk / probeOk / osOnline: already online — idempotent, no effect.
return { next: prev, effects: [] };
}
case 'connecting':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// Recovery is immediate on the first success — a blip resolves silently (no toast).
return { next: onlineSnapshot(), effects: [] };
case 'callFailed':
case 'probeFailed': {
const fails = prev.fails + 1;
const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
if (sustained) {
return {
next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
effects: [{ kind: 'toast', toast: 'offline' }],
};
}
return { next: { ...prev, fails }, effects: [] };
}
case 'osOnline':
// A positive hint triggers an immediate probe but never flips to online by itself.
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
return { next: prev, effects: [] };
}
case 'offlineNoNetwork':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
// that is reachable on its very first probe was never really offline, so heal SILENTLY; a
// confirmed offline gets the "back online" toast.
return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] };
case 'callFailed':
case 'probeFailed':
// A failed probe confirms a boot-assumed offline as real (its eventual recovery will then
// toast); a non-provisional offline is unchanged.
return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
// Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
return { next: prev, effects: [] };
}