mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
fix(pwa): stop unregistering the service worker on offline boot (#1346)
Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.
Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.
Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.
Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).
This commit is contained in:
@@ -44,7 +44,7 @@ import {
|
||||
type BookingImportMode,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -176,13 +176,17 @@ apiClient.interceptors.response.use(
|
||||
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
// Only an actual edge-proxy auth wall warrants tearing down the SW to
|
||||
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
|
||||
// with a cross-origin redirect or an HTML login page. A genuine offline
|
||||
// boot ALSO lands here — navigator.onLine reflects a network interface,
|
||||
// not reachability, and is routinely true on mobile while offline. So
|
||||
// gate strictly on a positive proxy signal; on plain offline do nothing
|
||||
// and let the request reject so the cached shell + IndexedDB serve the
|
||||
// app. Unregistering the SW here reloaded into a dead network and broke
|
||||
// PWA offline mode (#1346).
|
||||
const state = await probeNow()
|
||||
if (state === 'proxy-wall') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const PROBE_INTERVAL_MS = 30_000
|
||||
const PROBE_TIMEOUT_MS = 1_500
|
||||
|
||||
// Three distinct outcomes so callers can tell a genuine offline (the request
|
||||
// never reached a server — never tear down offline infrastructure) apart from an
|
||||
// edge-proxy auth wall (CF Access / Pangolin intercept /api — a top-level reload
|
||||
// is needed so the proxy can run its auth flow).
|
||||
export type ProbeState = 'online' | 'offline' | 'proxy-wall'
|
||||
|
||||
let reachable = true
|
||||
const listeners = new Set<(v: boolean) => void>()
|
||||
|
||||
@@ -10,8 +16,8 @@ function setReachable(v: boolean): void {
|
||||
listeners.forEach(fn => fn(v))
|
||||
}
|
||||
|
||||
async function probe(): Promise<void> {
|
||||
if (!navigator.onLine) { setReachable(false); return }
|
||||
async function probe(): Promise<ProbeState> {
|
||||
if (!navigator.onLine) { setReachable(false); return 'offline' }
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||
@@ -19,28 +25,36 @@ async function probe(): Promise<void> {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
// Surface a cross-origin auth redirect (CF Access) as an opaque redirect
|
||||
// instead of letting it throw — that's a positive proxy signal, not offline.
|
||||
redirect: 'manual',
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||
if (res.type === 'opaqueredirect') { setReachable(false); return 'proxy-wall' }
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
setReachable(res.ok && ct.includes('application/json'))
|
||||
} catch {
|
||||
if (res.ok && ct.includes('application/json')) { setReachable(true); return 'online' }
|
||||
// A real HTTP response that isn't our health JSON (e.g. Pangolin's HTML 200
|
||||
// auth wall, or an edge 401/403) means the proxy is reachable but gating us.
|
||||
setReachable(false)
|
||||
return 'proxy-wall'
|
||||
} catch {
|
||||
// fetch threw → the request never completed: genuinely offline (or the server
|
||||
// is down). Must NOT be treated as a proxy wall — that would unregister the SW.
|
||||
setReachable(false)
|
||||
return 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
export function startConnectivityProbe(): void {
|
||||
probe()
|
||||
setInterval(probe, PROBE_INTERVAL_MS)
|
||||
window.addEventListener('online', probe)
|
||||
void probe()
|
||||
setInterval(() => { void probe() }, PROBE_INTERVAL_MS)
|
||||
window.addEventListener('online', () => { void probe() })
|
||||
window.addEventListener('offline', () => setReachable(false))
|
||||
}
|
||||
|
||||
export function isReachable(): boolean { return reachable }
|
||||
export function probeNow(): Promise<void> { return probe() }
|
||||
export function probeNow(): Promise<ProbeState> { return probe() }
|
||||
export function onChange(fn: (v: boolean) => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// FE-SYNC-CONNECTIVITY: probeNow must tell a genuine offline (network error —
|
||||
// never tear down the SW) apart from an edge-proxy auth wall (#1346).
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { probeNow } from '../../../src/sync/connectivity'
|
||||
|
||||
function setOnline(v: boolean): void {
|
||||
Object.defineProperty(navigator, 'onLine', { value: v, configurable: true })
|
||||
}
|
||||
|
||||
function fetchReturns(res: Partial<Response>): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res as Response))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
setOnline(true)
|
||||
})
|
||||
|
||||
describe('FE-SYNC-CONNECTIVITY: probeNow offline vs proxy-wall (#1346)', () => {
|
||||
it('FE-SYNC-CONNECTIVITY-001: navigator.onLine false → "offline"', async () => {
|
||||
setOnline(false)
|
||||
expect(await probeNow()).toBe('offline')
|
||||
})
|
||||
|
||||
it('FE-SYNC-CONNECTIVITY-002: fetch throws (network error) → "offline" (must NOT unregister the SW)', async () => {
|
||||
setOnline(true)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
expect(await probeNow()).toBe('offline')
|
||||
})
|
||||
|
||||
it('FE-SYNC-CONNECTIVITY-003: ok JSON health → "online"', async () => {
|
||||
setOnline(true)
|
||||
fetchReturns({ type: 'basic', ok: true, headers: new Headers({ 'content-type': 'application/json' }) })
|
||||
expect(await probeNow()).toBe('online')
|
||||
})
|
||||
|
||||
it('FE-SYNC-CONNECTIVITY-004: cross-origin auth redirect (CF Access) → "proxy-wall"', async () => {
|
||||
setOnline(true)
|
||||
fetchReturns({ type: 'opaqueredirect', ok: false, headers: new Headers() })
|
||||
expect(await probeNow()).toBe('proxy-wall')
|
||||
})
|
||||
|
||||
it('FE-SYNC-CONNECTIVITY-005: HTML auth wall (Pangolin 200) → "proxy-wall"', async () => {
|
||||
setOnline(true)
|
||||
fetchReturns({ type: 'basic', ok: true, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }) })
|
||||
expect(await probeNow()).toBe('proxy-wall')
|
||||
})
|
||||
|
||||
it('FE-SYNC-CONNECTIVITY-006: edge 401/403 (non-JSON) → "proxy-wall"', async () => {
|
||||
setOnline(true)
|
||||
fetchReturns({ type: 'basic', ok: false, headers: new Headers({ 'content-type': 'text/html' }) })
|
||||
expect(await probeNow()).toBe('proxy-wall')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user