Skip to content

Commit

A missing page inside a workspace or project, such as /flagon-io/-/docs or /flagon-io/g1t/nope, shows Not found on its first load instead of "Loading the latest version of g1t…" forever.

The root error boundary took any location key other than "default" to mean a client navigation, but ScrollRestoration's inline script gives a tab's first history entry a key of its own before the app starts. So on a document load of an address the catch-all route caught, the client decided the tab was out of date and rendered the loading message where the server had rendered Not found, which React reported as hydration error #418. It then reloaded once, and the reloaded page found the address already tried and kept the loading message with nothing left to do. The boundary now compares the location key with the one the document loaded at, read before the app hydrates, so a document load renders the same page on the server and the client. An address already reloaded once shows its page, and a reload that has not started after five seconds gives up and shows it too. Tests cover both.

syntaqxcommitted Parent180b34aBrowse files
3 files+73−90/3 viewed
+21−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { reloadFixes } from "./stale-build.ts";
4+import { RELOADED_KEY, clientNavigated, reloadFixes, reloadedBefore } from "./stale-build.ts";
55
66 test("a client navigation that hits an older build reloads the page", () => {
77 const seen = { clientNavigation: true, caughtByCatchAll: false };
2222 assert.equal(reloadFixes({ error: new Error("D1_ERROR: no such table"), clientNavigation: true, caughtByCatchAll: false }), false);
2323 assert.equal(reloadFixes({ error: {}, status: 500, clientNavigation: true, caughtByCatchAll: false }), false);
2424 });
25+
26+test("the location a tab's document loaded at is no client navigation, whatever its key", () => {
27+ // ScrollRestoration's inline script gives the first entry a random key.
28+ assert.equal(clientNavigated("kvhli4udgd", "kvhli4udgd"), false);
29+ assert.equal(clientNavigated("default", "default"), false);
30+ assert.equal(clientNavigated("a1b2c3", "kvhli4udgd"), true);
31+ // The server never navigated.
32+ assert.equal(clientNavigated("default", undefined), false);
33+ assert.equal(clientNavigated("a1b2c3", undefined), false);
34+});
35+
36+test("an address loaded again once is not loaded again", () => {
37+ const storage = (value: string | null) => ({ getItem: (key: string) => (key === RELOADED_KEY ? value : null) });
38+ assert.equal(reloadedBefore("/flagon-io/-/docs", storage("/flagon-io/-/docs")), true);
39+ assert.equal(reloadedBefore("/flagon-io/-/docs", storage("/flagon-io/g1t/nope")), false);
40+ assert.equal(reloadedBefore("/flagon-io/-/docs", storage(null)), false);
41+ // No storage, or storage that throws: nothing remembered.
42+ assert.equal(reloadedBefore("/flagon-io/-/docs", undefined), false);
43+ assert.equal(reloadedBefore("/flagon-io/-/docs", { getItem: () => { throw new Error("denied"); } }), false);
44+});
+39−0
3737 return STALE_MESSAGES.some((pattern) => pattern.test(message));
3838 }
3939
40+/**
41+ * The key React Router gives the location a tab's document loaded at. It is
42+ * the history entry's own key, and not "default": ScrollRestoration's inline
43+ * script gives the entry a key before the app starts, so a key alone cannot
44+ * tell a document load from a client navigation. Read when this module first
45+ * runs, which is before the app hydrates; undefined on the server.
46+ */
47+const DOCUMENT_KEY: string | undefined =
48+ typeof window === "undefined" ? undefined : ((window.history.state as { key?: string } | null)?.key ?? "default");
49+
50+/**
51+ * Whether the page at this location key got there by a client navigation.
52+ * The server, and the render that hydrates the server's page, always say
53+ * no, so both render the same error page.
54+ */
55+export function clientNavigated(key: string, documentKey: string | undefined = DOCUMENT_KEY): boolean {
56+ return documentKey !== undefined && key !== documentKey;
57+}
58+
4059 /** The session key that remembers which address was reloaded, so it is reloaded once. */
4160 export const RELOADED_KEY = "g1t-reloaded";
61+
62+/** Whether this address was already loaded again once, so another try would not help. */
63+export function reloadedBefore(href: string, storage: Pick<Storage, "getItem"> | undefined = sessionStorageOrNone()): boolean {
64+ try {
65+ return storage?.getItem(RELOADED_KEY) === href;
66+ } catch {
67+ return false;
68+ }
69+}
70+
71+/** How long the page waits for the reload to start before it shows the error instead. */
72+export const RELOAD_GIVE_UP_MS = 5000;
73+
74+function sessionStorageOrNone(): Storage | undefined {
75+ try {
76+ return typeof window === "undefined" ? undefined : window.sessionStorage;
77+ } catch {
78+ return undefined;
79+ }
80+}
+13−8
1111 Search,
1212 Settings,
1313 } from "lucide-react";
14−import { useEffect, useRef, useState } from "react";
14+import { useEffect, useMemo, useRef, useState } from "react";
1515 import {
1616 Form,
1717 isRouteErrorResponse,
6969 import { registrationMode } from "./lib/registration.server";
7070 import { addresses } from "./lib/addresses.server";
7171 import { useSignUpCopy } from "./lib/registration";
72−import { RELOADED_KEY, reloadFixes } from "./lib/stale-build";
72+import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build";
7373 import { useNonce } from "./lib/nonce";
7474 import { isNeedsSignIn } from "./lib/website-token";
7575 import { LiveNotifications } from "./components/notifications/live-notifications";
658658 export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
659659 const location = useLocation();
660660 const matches = useMatches();
661+ const href = location.pathname + location.search + location.hash;
661662 // A tab left open across a deploy: load the address again as a whole
662− // page, once, rather than show an error the reload fixes.
663− const reload = reloadFixes({
663+ // page, once, rather than show an error the reload fixes. A document load
664+ // never reloads, so the server and the hydrating client render the same.
665+ const reloadable = reloadFixes({
664666 error,
665667 status: isRouteErrorResponse(error) ? error.status : undefined,
666− // The first page a tab loads has the key "default"; a client navigation's is its own.
667− clientNavigation: location.key !== "default",
668+ clientNavigation: clientNavigated(location.key),
668669 caughtByCatchAll: matches.at(-1)?.id === "routes/not-found",
669670 });
670− const href = location.pathname + location.search + location.hash;
671+ // Already loaded again once, or the reload never started: show the page.
672+ const tried = useMemo(() => reloadable && reloadedBefore(href), [reloadable, href]);
673+ const [gaveUp, setGaveUp] = useState<string | null>(null);
674+ const reload = reloadable && !tried && gaveUp !== href;
671675 useEffect(() => {
672676 if (!reload) return;
673677 try {
674− if (sessionStorage.getItem(RELOADED_KEY) === href) return;
675678 sessionStorage.setItem(RELOADED_KEY, href);
676679 } catch {
677680 // No session storage: reload anyway; a document load never asks again.
678681 }
679682 window.location.assign(href);
683+ const timer = window.setTimeout(() => setGaveUp(href), RELOAD_GIVE_UP_MS);
684+ return () => window.clearTimeout(timer);
680685 }, [reload, href]);
681686 if (reload) {
682687 return (