From fffc6030ceb3186f3646ff2376f22cb7627514c6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 18:50:10 +0200 Subject: [PATCH] fix(offline): poll navigator.onLine to return online (the online event is unreliable) The auto-offline -> online return still did not fire: the retry hung on the 'online' event, which an installed PWA often does not deliver. Replace it with a lightweight poll while in auto-offline that reads navigator.onLine (a reliable live flag, unlike the event) and only hits the network for a reachability check when the interface is actually up - so flight mode ON costs no radio, and flight mode OFF is detected within ~4s and returns online. The 'online' event, when it does fire, just kicks an immediate check. Runs only in auto-offline (deliberate offline is the player's choice); wired for both the mid-session and cold-start auto-offline paths. --- ui/src/lib/app.svelte.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index df4731a..49ab6aa 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -587,28 +587,42 @@ function promptOfflineChoice(): Promise { * cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no * battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly). */ -// tryReturnOnline is fired from the online event while in auto-offline: the interface being back does -// not mean the gateway is reachable yet (DNS/routing settle for a moment after flight mode), so it -// re-checks a few times with backoff before returning online, stopping if the player takes over the -// state meanwhile. This bounded retry is what actually gets the app back online after flight mode off. -async function tryReturnOnline(): Promise { - for (let attempt = 0; attempt < 4; attempt++) { +// While in auto-offline, poll for the network to return so the app comes back online — robust to the +// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it +// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the +// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in +// auto-offline (a deliberate offline is the player's choice) and stops on returning online. +let recoveryTimer: ReturnType | null = null; +export function scheduleRecovery(delayMs: number): void { + if (recoveryTimer) { + clearTimeout(recoveryTimer); + recoveryTimer = null; + } + if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return; + recoveryTimer = setTimeout(async () => { + recoveryTimer = null; if (!offlineMode.active || !offlineMode.auto) return; - if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) { + const interfaceUp = typeof navigator === 'undefined' || navigator.onLine; + if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) { if (offlineMode.active && offlineMode.auto) setOfflineMode(false); return; } - await new Promise((r) => setTimeout(r, 600 * (attempt + 1))); - } + scheduleRecovery(4000); + }, delayMs); } export function initNetworkReactivity(): void { if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; window.addEventListener('offline', () => { - if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below + if (!offlineMode.active) { + setOfflineMode(true, false); // auto (session) + scheduleRecovery(4000); // poll for the network to return + } }); + // The online event, when it fires, just kicks an immediate reachability check; the poll above is + // the reliable fallback for platforms where it does not fire. window.addEventListener('online', () => { - if (offlineMode.active && offlineMode.auto) void tryReturnOnline(); + if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); }); } @@ -905,6 +919,7 @@ export async function bootstrap(): Promise { if (goOffline) { // A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only. setOfflineMode(true, !interfaceOffline); + if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return app.session = saved; app.profile = cachedProfile; if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');