| 1 | /** |
| 2 | * A page left open refreshes itself (lib/refresh.ts, lib/use-repo-live.ts), |
| 3 | * and any of those reads can meet a moment without a network: Wi-Fi |
| 4 | * changing, a laptop waking, a connection cut during a deploy. The browser |
| 5 | * says only "Failed to fetch", and the router would show the error page in |
| 6 | * place of a page that was fine. So the router's own reads (its `.data` |
| 7 | * requests and route manifest) are asked again until they arrive: a little |
| 8 | * later each time, sooner once the browser says it is back online. While |
| 9 | * one is waiting, `useReconnecting` says so (components/connection-toast.tsx). |
| 10 | * |
| 11 | * A refresh of the page already showing keeps trying for as long as it |
| 12 | * takes: the page stays as it is meanwhile, and a click elsewhere cancels |
| 13 | * it. A read for another page (a click) tries for `PATIENCE_MS`, then |
| 14 | * reaches the error page, which keeps trying on its own (lib/reconnect.ts). |
| 15 | * A read the router gave up on itself (its signal aborted) stops at once. |
| 16 | * Writes are never sent again: one that failed may have arrived, and |
| 17 | * sending it twice could do it twice. |
| 18 | */ |
| 19 | import { useSyncExternalStore } from "react"; |
| 20 | |
| 21 | /** How long a read keeps trying; under lib/stuck.ts's `STUCK_NAVIGATION_MS`, so a click still lands on an error page that recovers rather than a whole-page load that cannot. */ |
| 22 | export const PATIENCE_MS = 45_000; |
| 23 | |
| 24 | /** The wait before try `attempt` (1 is the first retry): 0.25 s, 0.5 s, 1 s, 2 s, then every 5 s. */ |
| 25 | export function retryDelay(attempt: number): number { |
| 26 | return Math.min(250 * 2 ** (attempt - 1), 5000); |
| 27 | } |
| 28 | |
| 29 | /** What browsers throw when a request never got an answer, by browser. */ |
| 30 | const NETWORK_MESSAGES = new Set([ |
| 31 | "failed to fetch", // Chromium |
| 32 | "networkerror when attempting to fetch resource.", // Firefox |
| 33 | "load failed", // Safari |
| 34 | "network connection was lost.", // Safari |
| 35 | "the network connection was lost.", |
| 36 | "the internet connection appears to be offline.", |
| 37 | ]); |
| 38 | |
| 39 | /** Whether `error` means the request got no answer at all (not an error the server sent, nor an abort). */ |
| 40 | export function isNetworkFailure(error: unknown): boolean { |
| 41 | if (!(error instanceof TypeError)) return false; |
| 42 | return NETWORK_MESSAGES.has(error.message.trim().toLowerCase()); |
| 43 | } |
| 44 | |
| 45 | /** Whether a request is one of the router's reads, safe to send again. */ |
| 46 | export function retriable(url: URL, method: string, origin: string): boolean { |
| 47 | if (method.toUpperCase() !== "GET" || url.origin !== origin) return false; |
| 48 | return url.pathname.endsWith(".data") || url.pathname === "/__manifest"; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Whether a `.data` read is for the page already showing (a refresh), not |
| 53 | * one being navigated to: the same path (the root's is `/_root.data`) and |
| 54 | * the same query, besides the router's own `_routes`. |
| 55 | */ |
| 56 | export function refreshesCurrent(url: URL, current: { pathname: string; search: string }): boolean { |
| 57 | if (!url.pathname.endsWith(".data")) return false; |
| 58 | const path = url.pathname.slice(0, -".data".length); |
| 59 | if ((path === "/_root" ? "/" : path.replace(/\/$/, "")) !== (current.pathname.replace(/\/$/, "") || "/")) return false; |
| 60 | const query = (search: string) => { |
| 61 | const params = new URLSearchParams(search); |
| 62 | params.delete("_routes"); |
| 63 | params.sort(); |
| 64 | return params.toString(); |
| 65 | }; |
| 66 | return query(url.search) === query(current.search); |
| 67 | } |
| 68 | |
| 69 | // ── Whether a read is waiting on the network ───────────────────────────── |
| 70 | |
| 71 | let waiting = 0; |
| 72 | const listeners = new Set<() => void>(); |
| 73 | function setWaiting(change: number) { |
| 74 | waiting += change; |
| 75 | for (const listener of listeners) listener(); |
| 76 | } |
| 77 | |
| 78 | /** True while any of the router's reads is waiting for the network to come back. */ |
| 79 | export function useReconnecting(): boolean { |
| 80 | return useSyncExternalStore( |
| 81 | (listener) => { |
| 82 | listeners.add(listener); |
| 83 | return () => listeners.delete(listener); |
| 84 | }, |
| 85 | () => waiting > 0, |
| 86 | () => false, |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | /** Waits `ms`, or less once the browser is back online; rejects when `signal` aborts. */ |
| 91 | function pause(ms: number, signal: AbortSignal | null | undefined): Promise<void> { |
| 92 | return new Promise((resolve, reject) => { |
| 93 | if (signal?.aborted) return reject(signal.reason); |
| 94 | const done = () => { |
| 95 | clearTimeout(timer); |
| 96 | window.removeEventListener("online", done); |
| 97 | signal?.removeEventListener("abort", stop); |
| 98 | resolve(); |
| 99 | }; |
| 100 | const stop = () => { |
| 101 | clearTimeout(timer); |
| 102 | window.removeEventListener("online", done); |
| 103 | reject(signal?.reason); |
| 104 | }; |
| 105 | const timer = setTimeout(done, ms); |
| 106 | window.addEventListener("online", done, { once: true }); |
| 107 | signal?.addEventListener("abort", stop, { once: true }); |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | let installed = false; |
| 112 | |
| 113 | /** Wraps the browser's `fetch` so the router's reads survive a moment offline. Browser only; once. */ |
| 114 | export function installResilientFetch() { |
| 115 | if (installed || typeof window === "undefined") return; |
| 116 | installed = true; |
| 117 | const original = window.fetch.bind(window); |
| 118 | window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { |
| 119 | const request = input instanceof Request ? input : null; |
| 120 | let url: URL; |
| 121 | try { |
| 122 | url = new URL(request ? request.url : String(input), window.location.href); |
| 123 | } catch { |
| 124 | return original(input, init); |
| 125 | } |
| 126 | const method = init?.method ?? request?.method ?? "GET"; |
| 127 | if (!retriable(url, method, window.location.origin)) return original(input, init); |
| 128 | const signal = init?.signal ?? request?.signal; |
| 129 | const patient = refreshesCurrent(url, window.location); |
| 130 | const started = Date.now(); |
| 131 | let counted = false; |
| 132 | try { |
| 133 | for (let attempt = 1; ; attempt++) { |
| 134 | try { |
| 135 | // A Request's body is read once; a GET has none, but clone so each try is whole. |
| 136 | return await original(request ? (request.clone() as RequestInfo) : input, init); |
| 137 | } catch (error) { |
| 138 | const wait = retryDelay(attempt); |
| 139 | if (!isNetworkFailure(error) || signal?.aborted) throw error; |
| 140 | if (!patient && Date.now() - started + wait > PATIENCE_MS) throw error; |
| 141 | // The first miss may be nothing; from the second, say so. |
| 142 | if (attempt === 2 && !counted) { |
| 143 | counted = true; |
| 144 | setWaiting(1); |
| 145 | } |
| 146 | if (attempt === 1) console.warn(`g1t: ${url.pathname} got no answer; trying again`); |
| 147 | await pause(wait, signal); |
| 148 | } |
| 149 | } |
| 150 | } finally { |
| 151 | if (counted) setWaiting(-1); |
| 152 | } |
| 153 | }; |
| 154 | } |