Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parente51f393Browse files
6 files+296−00/6 viewed
+24−0
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−0
44
55 // Product analytics, started before the app (lib/analytics.client.ts).
66 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();
711
812 startTransition(() => {
913 hydrateRoot(
+46−0
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+}
+43−0
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+});
+154−0
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+}
+25−0
9393 useDeveloperMode,
9494 } from "./lib/developer-mode";
9595 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";
9699
97100 export const links: Route.LinksFunction = () => [
98101 { rel: "icon", href: "/favicon.ico", sizes: "32x32" },
591594 <SiteFooter user={user} />
592595 </>
593596 )}
597+ {/* A page that cannot reach g1t says so, and keeps what it shows (lib/resilient-fetch.ts). */}
598+ <ConnectionToast />
594599 {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */}
595600 {user && !awaitsConfirmation(user) && (
596601 <LiveNotifications
659664 import.meta.env.DEV;
660665 const [gaveUp, setGaveUp] = useState<string | null>(null);
661666 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);
662670 useEffect(() => {
663671 if (!reload) return;
664672 try {
682690 );
683691 }
684692
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+
685710 let title = "Something went wrong";
686711 let details = "An unexpected error occurred. Try again in a moment.";
687712

Comments

Sign in to comment.