Files
TREK/client/src/repo/withOfflineFallback.ts
T
jubnl bcd2c8c959 fix(repo): fall back to Dexie when a network read fails (H2) (#1179)
Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow
2026-06-15 09:25:11 +02:00

49 lines
2.1 KiB
TypeScript

/**
* True when an error means the request never reached the server — a network-level
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
* Axios sets `response` only when the server actually replied; its absence (on an
* Axios error) means we never got one. A real HTTP error (4xx/5xx) HAS a response
* and must NOT be treated as a network failure — the server spoke, so the caller
* needs to see it. Non-Axios errors are surfaced too.
*/
function isNetworkError(err: unknown): boolean {
const e = err as { isAxiosError?: boolean; response?: unknown } | null
return !!e && e.isAxiosError === true && e.response == null
}
/**
* Read-through cache pattern shared by every repo's read methods.
*
* Reads degrade to the local Dexie cache in two situations:
* 1. The browser reports it is offline (`navigator.onLine` false) — skip the
* doomed request entirely.
* 2. The browser *thinks* it is online but the request fails at the network
* level — a lying `navigator.onLine` on a captive portal, a dropped
* connection (H2). Rather than surfacing that (which blanks the trip even
* though a good cached copy exists), we fall back to the cache.
*
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
* the probe is a coarse global flag, and a single failed health check would
* otherwise force every read to the (possibly empty) cache even when the request
* itself would succeed. The network-error catch below covers the captive-portal
* case the probe was meant to.
*
* A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it
* is rethrown so callers can set error state, navigate away, etc.
*
* Writes must NOT use this — they go through the mutation queue so failures are
* surfaced and retried, not silently swallowed.
*/
export async function onlineThenCache<T>(
onlineFn: () => Promise<T>,
cacheFn: () => Promise<T>,
): Promise<T> {
if (!navigator.onLine) return cacheFn()
try {
return await onlineFn()
} catch (err) {
if (isNetworkError(err)) return cacheFn()
throw err
}
}