A page left open survives a moment without a network: sitting on /flagon-io/g1t/settings/repository, one background refresh met "TypeError: Failed to fetch" (Wi-Fi changing, a laptop waking, a connection cut during a deploy) and the root error page replaced the whole page, in the browser and the desktop app alike. The router's own reads (its .data requests and /__manifest, GET and same-origin only) are now asked again when they get no answer at all (lib/resilient-fetch.ts, installed in entry.client.tsx): after 0.25 s, 0.5 s, 1 s, 2 s, 4 s, then every 5 s, at once when the browser says it is back online. A refresh of the page already showing keeps trying for as long as it takes, and the page stays as it is meanwhile; a click elsewhere cancels it. From the second miss a toast says "Can't reach g1t. Trying again…" and goes once a read arrives (components/connection-toast.tsx). A read for another page (a click) tries for 45 s, under the stuck-navigation rescue's 75 s, then reaches the error page, which for a network failure now says "Can't reach g1t" and loads the page again by itself (3 s, 6 s, 12 s, then every 30 s, and at once on coming back online or into view; lib/reconnect.ts) instead of showing an error with a link home. Writes are never sent again: one that failed may have arrived. A stale build's "Failed to fetch dynamically imported module" is not a network failure and still reloads the page as before. Checked: apps/web tsc -b; npm test (982; new: each browser's no-answer message and what is not one, which requests are retried, a refresh told from a navigation, the delays).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
6 files+296−00/6 viewed
| 1 | + | import { WifiOff } from "lucide-react"; | |
| 2 | + | ||
| 3 | + | import { useReconnecting } from "../lib/resilient-fetch"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Says so while the page cannot reach g1t (lib/resilient-fetch.ts): the | |
| 7 | + | * page stays as it is and catches up once the network is back, so this | |
| 8 | + | * only says why nothing is moving. Gone by itself when a read arrives. | |
| 9 | + | */ | |
| 10 | + | export function ConnectionToast() { | |
| 11 | + | const reconnecting = useReconnecting(); | |
| 12 | + | if (!reconnecting) return null; | |
| 13 | + | return ( | |
| 14 | + | <div className="pointer-events-none fixed inset-x-0 bottom-5 z-65 flex justify-center px-4 max-sm:top-[calc(env(safe-area-inset-top)+0.75rem)] max-sm:bottom-auto"> | |
| 15 | + | <p | |
| 16 | + | role="status" | |
| 17 | + | className="pointer-events-auto flex items-center gap-2 rounded-xl bg-surface px-3.5 py-2.5 text-sm shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-line-strong" | |
| 18 | + | > | |
| 19 | + | <WifiOff size={15} className="shrink-0 text-warn" aria-hidden /> | |
| 20 | + | <span>Can't reach g1t. Trying again…</span> | |
| 21 | + | </p> | |
| 22 | + | </div> | |
| 23 | + | ); | |
| 24 | + | } |
| 4 | 4 | ||
| 5 | 5 | // Product analytics, started before the app (lib/analytics.client.ts). | |
| 6 | 6 | import "./lib/analytics.client"; | |
| 7 | + | import { installResilientFetch } from "./lib/resilient-fetch"; | |
| 8 | + | ||
| 9 | + | // The router's reads outlast a moment offline (lib/resilient-fetch.ts). | |
| 10 | + | installResilientFetch(); | |
| 7 | 11 | ||
| 8 | 12 | startTransition(() => { | |
| 9 | 13 | hydrateRoot( |
| 1 | + | import { useEffect, useRef } from "react"; | |
| 2 | + | import { useRevalidator } from "react-router"; | |
| 3 | + | ||
| 4 | + | /** The wait before the error page's try `attempt` (from 1): 3 s, 6 s, 12 s, then every 30 s. */ | |
| 5 | + | export function reconnectDelay(attempt: number): number { | |
| 6 | + | return Math.min(3000 * 2 ** (attempt - 1), 30_000); | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * While `on` (the page fell to its error page because the network was | |
| 11 | + | * gone), loads the page again: a little later each time, at once when the | |
| 12 | + | * browser says it is back online or the tab comes back into view. Once a | |
| 13 | + | * load arrives, the router drops the error and the page is back. | |
| 14 | + | */ | |
| 15 | + | export function useReconnect(on: boolean) { | |
| 16 | + | const revalidator = useRevalidator(); | |
| 17 | + | const latest = useRef(revalidator); | |
| 18 | + | latest.current = revalidator; | |
| 19 | + | useEffect(() => { | |
| 20 | + | if (!on) return; | |
| 21 | + | let attempt = 0; | |
| 22 | + | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 23 | + | const schedule = () => { | |
| 24 | + | clearTimeout(timer); | |
| 25 | + | timer = setTimeout(retry, reconnectDelay(++attempt)); | |
| 26 | + | }; | |
| 27 | + | const retry = () => { | |
| 28 | + | if (document.visibilityState === "visible" && latest.current.state === "idle") void latest.current.revalidate(); | |
| 29 | + | schedule(); | |
| 30 | + | }; | |
| 31 | + | const now = () => { | |
| 32 | + | if (document.visibilityState !== "visible") return; | |
| 33 | + | attempt = 0; | |
| 34 | + | clearTimeout(timer); | |
| 35 | + | retry(); | |
| 36 | + | }; | |
| 37 | + | schedule(); | |
| 38 | + | window.addEventListener("online", now); | |
| 39 | + | document.addEventListener("visibilitychange", now); | |
| 40 | + | return () => { | |
| 41 | + | clearTimeout(timer); | |
| 42 | + | window.removeEventListener("online", now); | |
| 43 | + | document.removeEventListener("visibilitychange", now); | |
| 44 | + | }; | |
| 45 | + | }, [on]); | |
| 46 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import test from "node:test"; | |
| 3 | + | ||
| 4 | + | import { isNetworkFailure, refreshesCurrent, retriable, retryDelay } from "./resilient-fetch.ts"; | |
| 5 | + | import { reconnectDelay } from "./reconnect.ts"; | |
| 6 | + | ||
| 7 | + | const ORIGIN = "https://g1t.sh"; | |
| 8 | + | const url = (path: string) => new URL(path, ORIGIN); | |
| 9 | + | ||
| 10 | + | test("a request that got no answer, in each browser's words, is a network failure", () => { | |
| 11 | + | assert.equal(isNetworkFailure(new TypeError("Failed to fetch")), true); | |
| 12 | + | assert.equal(isNetworkFailure(new TypeError("NetworkError when attempting to fetch resource.")), true); | |
| 13 | + | assert.equal(isNetworkFailure(new TypeError("Load failed")), true); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("a stale build, an abort or a server's error is not", () => { | |
| 17 | + | assert.equal(isNetworkFailure(new TypeError("Failed to fetch dynamically imported module: https://g1t.sh/assets/a.js")), false); | |
| 18 | + | assert.equal(isNetworkFailure(new DOMException("The user aborted a request.", "AbortError")), false); | |
| 19 | + | assert.equal(isNetworkFailure(new Error("Failed to fetch")), false); | |
| 20 | + | assert.equal(isNetworkFailure({ status: 500 }), false); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("only the router's own reads are sent again", () => { | |
| 24 | + | assert.equal(retriable(url("/flagon-io/g1t/settings/repository.data?_routes=root"), "GET", ORIGIN), true); | |
| 25 | + | assert.equal(retriable(url("/__manifest?p=/a"), "get", ORIGIN), true); | |
| 26 | + | assert.equal(retriable(url("/flagon-io/g1t/settings/repository.data"), "POST", ORIGIN), false); | |
| 27 | + | assert.equal(retriable(url("/-/notify"), "GET", ORIGIN), false); | |
| 28 | + | assert.equal(retriable(new URL("https://otel.g1t.sh/x.data"), "GET", ORIGIN), false); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("a read for the page already showing is a refresh; one for another page is a navigation", () => { | |
| 32 | + | const here = { pathname: "/flagon-io/g1t/settings/repository", search: "" }; | |
| 33 | + | assert.equal(refreshesCurrent(url("/flagon-io/g1t/settings/repository.data?_routes=root,routes/repo/layout"), here), true); | |
| 34 | + | assert.equal(refreshesCurrent(url("/flagon-io/g1t/issues.data"), here), false); | |
| 35 | + | assert.equal(refreshesCurrent(url("/_root.data"), { pathname: "/", search: "" }), true); | |
| 36 | + | assert.equal(refreshesCurrent(url("/a.data?tab=open&_routes=x"), { pathname: "/a", search: "?tab=open" }), true); | |
| 37 | + | assert.equal(refreshesCurrent(url("/a.data?tab=closed"), { pathname: "/a", search: "?tab=open" }), false); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("retries come quickly at first, then settle", () => { | |
| 41 | + | assert.deepEqual([1, 2, 3, 4, 5, 9].map(retryDelay), [250, 500, 1000, 2000, 4000, 5000]); | |
| 42 | + | assert.deepEqual([1, 2, 3, 4, 8].map(reconnectDelay), [3000, 6000, 12000, 24000, 30000]); | |
| 43 | + | }); |
| 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 | + | } |
| 93 | 93 | useDeveloperMode, | |
| 94 | 94 | } from "./lib/developer-mode"; | |
| 95 | 95 | import { DebugDetails } from "./components/debug-details"; | |
| 96 | + | import { ConnectionToast } from "./components/connection-toast"; | |
| 97 | + | import { isNetworkFailure } from "./lib/resilient-fetch"; | |
| 98 | + | import { useReconnect } from "./lib/reconnect"; | |
| 96 | 99 | ||
| 97 | 100 | export const links: Route.LinksFunction = () => [ | |
| 98 | 101 | { rel: "icon", href: "/favicon.ico", sizes: "32x32" }, | |
| ⋯ | |||
| 591 | 594 | <SiteFooter user={user} /> | |
| 592 | 595 | </> | |
| 593 | 596 | )} | |
| 597 | + | {/* A page that cannot reach g1t says so, and keeps what it shows (lib/resilient-fetch.ts). */} | |
| 598 | + | <ConnectionToast /> | |
| 594 | 599 | {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */} | |
| 595 | 600 | {user && !awaitsConfirmation(user) && ( | |
| 596 | 601 | <LiveNotifications | |
| ⋯ | |||
| 659 | 664 | import.meta.env.DEV; | |
| 660 | 665 | const [gaveUp, setGaveUp] = useState<string | null>(null); | |
| 661 | 666 | const reload = reloadable && !tried && gaveUp !== href; | |
| 667 | + | // No answer from g1t at all: keep trying, and the page comes back by itself. | |
| 668 | + | const offline = !reload && isNetworkFailure(error); | |
| 669 | + | useReconnect(offline); | |
| 662 | 670 | useEffect(() => { | |
| 663 | 671 | if (!reload) return; | |
| 664 | 672 | try { | |
| ⋯ | |||
| 682 | 690 | ); | |
| 683 | 691 | } | |
| 684 | 692 | ||
| 693 | + | if (offline) { | |
| 694 | + | return ( | |
| 695 | + | <PageMain | |
| 696 | + | className="mx-auto max-w-xl px-4 py-32 text-center" | |
| 697 | + | aria-busy="true" | |
| 698 | + | > | |
| 699 | + | <title>Reconnecting · g1t</title> | |
| 700 | + | <h1 className="text-2xl font-semibold tracking-tight"> | |
| 701 | + | Can't reach g1t | |
| 702 | + | </h1> | |
| 703 | + | <p className="mt-3 text-muted"> | |
| 704 | + | Check your connection. This page loads by itself once g1t answers. | |
| 705 | + | </p> | |
| 706 | + | </PageMain> | |
| 707 | + | ); | |
| 708 | + | } | |
| 709 | + | ||
| 685 | 710 | let title = "Something went wrong"; | |
| 686 | 711 | let details = "An unexpected error occurred. Try again in a moment."; | |
| 687 | 712 | ||
Comments
Sign in to comment.