| 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 the page as it is while it |
| 12 | * tries, and a click elsewhere cancels it. It waits out the browser being |
| 13 | * offline for as long as that lasts, but not a network that is up and |
| 14 | * still never answers (an extension or proxy blocking g1t, broken DNS): |
| 15 | * that is no blip, so after `REFRESH_PATIENCE_MS` of it the error is shown. |
| 16 | * A read for another page (a click) tries for `PATIENCE_MS`. Either way the |
| 17 | * error page then keeps trying on its own, on its own slower schedule |
| 18 | * (lib/reconnect.ts): while it shows, reads are not retried here as well. |
| 19 | * Nothing is retried from a tab out of view; it waits to be looked at. |
| 20 | * A read the router gave up on itself (its signal aborted) stops at once. |
| 21 | * Writes are never sent again: one that failed may have arrived, and |
| 22 | * sending it twice could do it twice. |
| 23 | */ |
| 24 | import { useSyncExternalStore } from "react"; |
| 25 | |
| 26 | /** 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. */ |
| 27 | export const PATIENCE_MS = 45_000; |
| 28 | |
| 29 | /** How long a refresh keeps the page while the browser says it is online and nothing answers. */ |
| 30 | export const REFRESH_PATIENCE_MS = 120_000; |
| 31 | |
| 32 | /** The wait before try `attempt` (1 is the first retry): 0.25 s, 0.5 s, 1 s, 2 s, then every 5 s. */ |
| 33 | export function retryDelay(attempt: number): number { |
| 34 | return Math.min(250 * 2 ** (attempt - 1), 5000); |
| 35 | } |
| 36 | |
| 37 | /** What browsers throw when a request never got an answer, by browser. */ |
| 38 | const NETWORK_MESSAGES = new Set([ |
| 39 | "failed to fetch", // Chromium |
| 40 | "networkerror when attempting to fetch resource.", // Firefox |
| 41 | "load failed", // Safari |
| 42 | "network connection was lost.", // Safari |
| 43 | "the network connection was lost.", |
| 44 | "the internet connection appears to be offline.", |
| 45 | ]); |
| 46 | |
| 47 | /** Whether `error` means the request got no answer at all (not an error the server sent, nor an abort). */ |
| 48 | export function isNetworkFailure(error: unknown): boolean { |
| 49 | if (!(error instanceof TypeError)) return false; |
| 50 | return NETWORK_MESSAGES.has(error.message.trim().toLowerCase()); |
| 51 | } |
| 52 | |
| 53 | /** Whether a request is one of the router's reads, safe to send again. */ |
| 54 | export function retriable(url: URL, method: string, origin: string): boolean { |
| 55 | if (method.toUpperCase() !== "GET" || url.origin !== origin) return false; |
| 56 | return url.pathname.endsWith(".data") || url.pathname === "/__manifest"; |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Whether a `.data` read is for the page already showing (a refresh), not |
| 61 | * one being navigated to: the same path (the root's is `/_root.data`) and |
| 62 | * the same query, besides the router's own `_routes`. |
| 63 | */ |
| 64 | export function refreshesCurrent(url: URL, current: { pathname: string; search: string }): boolean { |
| 65 | if (!url.pathname.endsWith(".data")) return false; |
| 66 | const path = url.pathname.slice(0, -".data".length); |
| 67 | if ((path === "/_root" ? "/" : path.replace(/\/$/, "")) !== (current.pathname.replace(/\/$/, "") || "/")) return false; |
| 68 | const query = (search: string) => { |
| 69 | const params = new URLSearchParams(search); |
| 70 | params.delete("_routes"); |
| 71 | params.sort(); |
| 72 | return params.toString(); |
| 73 | }; |
| 74 | return query(url.search) === query(current.search); |
| 75 | } |
| 76 | |
| 77 | /** What a read has been through, to decide whether to try it again after `wait`. */ |
| 78 | export type Tried = { |
| 79 | /** A refresh of the page already showing, not a navigation. */ |
| 80 | refresh: boolean; |
| 81 | /** Since the read started. */ |
| 82 | elapsedMs: number; |
| 83 | /** Waited between tries while the browser said it was online. */ |
| 84 | onlineMs: number; |
| 85 | /** Whether the error page is showing, trying on its own. */ |
| 86 | erroring: boolean; |
| 87 | }; |
| 88 | |
| 89 | /** Whether to try a read again after `wait`: never twice over, never forever on a network that is up. */ |
| 90 | export function keepTrying(tried: Tried, wait: number): boolean { |
| 91 | if (tried.erroring) return false; |
| 92 | if (tried.refresh) return tried.onlineMs + wait <= REFRESH_PATIENCE_MS; |
| 93 | return tried.elapsedMs + wait <= PATIENCE_MS; |
| 94 | } |
| 95 | |
| 96 | /** Set by the error page while it tries on its own (lib/reconnect.ts). */ |
| 97 | let erroring = false; |
| 98 | export function setErroring(on: boolean) { |
| 99 | erroring = on; |
| 100 | } |
| 101 | |
| 102 | // ── Whether a read is waiting on the network ───────────────────────────── |
| 103 | |
| 104 | let waiting = 0; |
| 105 | const listeners = new Set<() => void>(); |
| 106 | function setWaiting(change: number) { |
| 107 | waiting += change; |
| 108 | for (const listener of listeners) listener(); |
| 109 | } |
| 110 | |
| 111 | /** True while any of the router's reads is waiting for the network to come back. */ |
| 112 | export function useReconnecting(): boolean { |
| 113 | return useSyncExternalStore( |
| 114 | (listener) => { |
| 115 | listeners.add(listener); |
| 116 | return () => listeners.delete(listener); |
| 117 | }, |
| 118 | () => waiting > 0, |
| 119 | () => false, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Waits `ms`, or less once the browser is back online, and then for as long |
| 125 | * as the tab is out of view; rejects when `signal` aborts. |
| 126 | */ |
| 127 | function pause(ms: number, signal: AbortSignal | null | undefined): Promise<void> { |
| 128 | return new Promise((resolve, reject) => { |
| 129 | if (signal?.aborted) return reject(signal.reason); |
| 130 | const cleanup = () => { |
| 131 | clearTimeout(timer); |
| 132 | window.removeEventListener("online", due); |
| 133 | document.removeEventListener("visibilitychange", shown); |
| 134 | signal?.removeEventListener("abort", stop); |
| 135 | }; |
| 136 | let elapsed = false; |
| 137 | function due() { |
| 138 | elapsed = true; |
| 139 | if (document.visibilityState !== "visible") return; |
| 140 | cleanup(); |
| 141 | resolve(); |
| 142 | } |
| 143 | // Only once the wait is over: coming into view does not cut it short. |
| 144 | function shown() { |
| 145 | if (elapsed) due(); |
| 146 | } |
| 147 | function stop() { |
| 148 | cleanup(); |
| 149 | reject(signal?.reason); |
| 150 | } |
| 151 | const timer = setTimeout(due, ms); |
| 152 | window.addEventListener("online", due); |
| 153 | document.addEventListener("visibilitychange", shown); |
| 154 | signal?.addEventListener("abort", stop, { once: true }); |
| 155 | }); |
| 156 | } |
| 157 | |
| 158 | let installed = false; |
| 159 | |
| 160 | /** Wraps the browser's `fetch` so the router's reads survive a moment offline. Browser only; once. */ |
| 161 | export function installResilientFetch() { |
| 162 | if (installed || typeof window === "undefined") return; |
| 163 | installed = true; |
| 164 | const original = window.fetch.bind(window); |
| 165 | window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { |
| 166 | const request = input instanceof Request ? input : null; |
| 167 | let url: URL; |
| 168 | try { |
| 169 | url = new URL(request ? request.url : String(input), window.location.href); |
| 170 | } catch { |
| 171 | return original(input, init); |
| 172 | } |
| 173 | const method = init?.method ?? request?.method ?? "GET"; |
| 174 | if (!retriable(url, method, window.location.origin)) return original(input, init); |
| 175 | const signal = init?.signal ?? request?.signal; |
| 176 | const refresh = refreshesCurrent(url, window.location); |
| 177 | const started = Date.now(); |
| 178 | let onlineMs = 0; |
| 179 | let counted = false; |
| 180 | try { |
| 181 | for (let attempt = 1; ; attempt++) { |
| 182 | try { |
| 183 | // A Request's body is read once; a GET has none, but clone so each try is whole. |
| 184 | return await original(request ? (request.clone() as RequestInfo) : input, init); |
| 185 | } catch (error) { |
| 186 | const wait = retryDelay(attempt); |
| 187 | if (!isNetworkFailure(error) || signal?.aborted) throw error; |
| 188 | if (!keepTrying({ refresh, elapsedMs: Date.now() - started, onlineMs, erroring }, wait)) throw error; |
| 189 | if (navigator.onLine) onlineMs += wait; |
| 190 | // The first miss may be nothing; from the second, say so. |
| 191 | if (attempt === 2 && !counted) { |
| 192 | counted = true; |
| 193 | setWaiting(1); |
| 194 | } |
| 195 | if (attempt === 1) console.warn(`g1t: ${url.pathname} got no answer; trying again`); |
| 196 | await pause(wait, signal); |
| 197 | } |
| 198 | } |
| 199 | } finally { |
| 200 | if (counted) setWaiting(-1); |
| 201 | } |
| 202 | }; |
| 203 | } |