3429dbec5f
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.
230 lines
11 KiB
TypeScript
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: [] };
|
|
}
|