feat(offline): auto-detect no network at cold start + 'go offline?' dialog
CI / changes (pull_request) Successful in 1s
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 1m44s

A sticky-online cold start with no network hung the splash on adoptSession's
retrying profile fetch. Now, for an offline-capable web install with a cached
profile:
- No network interface (navigator.onLine === false) -> enter offline mode for
  the session (no dialog; the next launch re-evaluates).
- Interface up but the gateway is unreachable within 3s (a single-attempt
  reachability probe, not the 6-retry loop) -> a 'No connection. Enable offline
  mode?' dialog: Enable -> sticky offline; Keep trying -> the normal online
  adopt (retries, 'Connecting...').

- connection.svelte: checkReachable(timeout) - a bounded single probe.
- offline.svelte: setOfflineMode(on, persist) - auto-offline is session-only, a
  deliberate choice (dialog/toggle) is sticky.
- app.svelte.ts: the cold-start auto-detect in bootstrap + the dialog resolver;
  App.svelte renders the boot dialog. i18n en/ru.
- App-entry bundle budget 113->114 (the boot path cannot be lazy-loaded).

Online cold-start unaffected (auto-detect gated to isStandalone, off in the mock
e2e): e2e 196. The offline paths are contour-verified.

Next: PR2 - mid-session flight-mode reactivity (online/offline events).
This commit is contained in:
Ilia Denisov
2026-07-06 17:49:53 +02:00
parent 2a4b0fb25e
commit 5bc2ad3b6d
7 changed files with 155 additions and 12 deletions
+59 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import Toast from './components/Toast.svelte';
@@ -136,6 +136,21 @@
<Coachmark />
<MaintenanceOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
{#if app.offlinePrompt}
<div class="offprompt" role="dialog" aria-modal="true">
<div class="card">
<p class="msg">{t('offline.promptTitle')}</p>
<div class="acts">
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
</div>
</div>
</div>
{/if}
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash />
{/if}
@@ -145,6 +160,49 @@
{/if}
<style>
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
.offprompt {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: var(--pad);
}
.offprompt .card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 20px;
max-width: 320px;
width: 100%;
text-align: center;
}
.offprompt .msg {
margin: 0 0 16px;
font-size: 1rem;
color: var(--text);
}
.offprompt .acts {
display: flex;
flex-direction: column;
gap: 8px;
}
.offprompt .opt {
padding: 11px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.95rem;
}
.offprompt .opt.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.splash {
height: 100%;
display: grid;
+56 -2
View File
@@ -55,8 +55,8 @@ import {
savePrefs,
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { offlineMode } from './offline.svelte';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
@@ -78,6 +78,10 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
@@ -148,6 +152,7 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
offlinePrompt: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
@@ -551,6 +556,27 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications();
}
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/**
* applyLinkResult applies a completed account link or merge: it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active
@@ -824,6 +850,34 @@ export async function bootstrap(): Promise<void> {
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
}
}
await adoptSession(saved);
if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
+20
View File
@@ -27,6 +27,26 @@ export function registerProbe(fn: () => Promise<void>): void {
probe = fn;
}
/**
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
* updating the online signal. It reports false when no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probe) return false;
try {
await Promise.race([
probe(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
reportOnline();
return true;
} catch {
reportOffline();
return false;
}
}
/** reportOnline marks the gateway reachable and stops the watcher. */
export function reportOnline(): void {
online = true;
+3
View File
@@ -218,6 +218,9 @@ export const en = {
'settings.online': 'Online',
'settings.offline': 'Offline',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
'about.title': 'About',
'about.tab': 'Info',
+3
View File
@@ -218,6 +218,9 @@ export const ru: Record<MessageKey, string> = {
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
'about.title': 'О программе',
'about.tab': 'Инфо',
+8 -3
View File
@@ -20,10 +20,15 @@ export const offlineMode = {
},
};
/** setOfflineMode enters or leaves offline mode and persists the choice (device-scoped). */
export function setOfflineMode(on: boolean): void {
/**
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
*/
export function setOfflineMode(on: boolean, persist = true): void {
active = on;
saveOfflinePref(on);
if (persist) saveOfflinePref(on);
}
// The dict-preload warning: true when a first-lobby background preload could not fetch every