Merge branch 'worktree-agent-a64e51c8f386f46ac'
3 files+73−90/3 viewed
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { reloadFixes } from "./stale-build.ts"; | |
| 4 | + | import { RELOADED_KEY, clientNavigated, reloadFixes, reloadedBefore } from "./stale-build.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("a client navigation that hits an older build reloads the page", () => { | |
| 7 | 7 | const seen = { clientNavigation: true, caughtByCatchAll: false }; | |
| ⋯ | |||
| 22 | 22 | assert.equal(reloadFixes({ error: new Error("D1_ERROR: no such table"), clientNavigation: true, caughtByCatchAll: false }), false); | |
| 23 | 23 | assert.equal(reloadFixes({ error: {}, status: 500, clientNavigation: true, caughtByCatchAll: false }), false); | |
| 24 | 24 | }); | |
| 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 | + | }); | |
| 37 | 37 | return STALE_MESSAGES.some((pattern) => pattern.test(message)); | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 59 | /** The session key that remembers which address was reloaded, so it is reloaded once. */ | |
| 41 | 60 | 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 | + | } |
| 11 | 11 | Search, | |
| 12 | 12 | Settings, | |
| 13 | 13 | } from "lucide-react"; | |
| 14 | − | import { useEffect, useRef, useState } from "react"; | |
| 14 | + | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 15 | 15 | import { | |
| 16 | 16 | Form, | |
| 17 | 17 | isRouteErrorResponse, | |
| ⋯ | |||
| 69 | 69 | import { registrationMode } from "./lib/registration.server"; | |
| 70 | 70 | import { addresses } from "./lib/addresses.server"; | |
| 71 | 71 | 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"; | |
| 73 | 73 | import { useNonce } from "./lib/nonce"; | |
| 74 | 74 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 75 | 75 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| ⋯ | |||
| 658 | 658 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { | |
| 659 | 659 | const location = useLocation(); | |
| 660 | 660 | const matches = useMatches(); | |
| 661 | + | const href = location.pathname + location.search + location.hash; | |
| 661 | 662 | // 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({ | |
| 664 | 666 | error, | |
| 665 | 667 | 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), | |
| 668 | 669 | caughtByCatchAll: matches.at(-1)?.id === "routes/not-found", | |
| 669 | 670 | }); | |
| 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; | |
| 671 | 675 | useEffect(() => { | |
| 672 | 676 | if (!reload) return; | |
| 673 | 677 | try { | |
| 674 | − | if (sessionStorage.getItem(RELOADED_KEY) === href) return; | |
| 675 | 678 | sessionStorage.setItem(RELOADED_KEY, href); | |
| 676 | 679 | } catch { | |
| 677 | 680 | // No session storage: reload anyway; a document load never asks again. | |
| 678 | 681 | } | |
| 679 | 682 | window.location.assign(href); | |
| 683 | + | const timer = window.setTimeout(() => setGaveUp(href), RELOAD_GIVE_UP_MS); | |
| 684 | + | return () => window.clearTimeout(timer); | |
| 680 | 685 | }, [reload, href]); | |
| 681 | 686 | if (reload) { | |
| 682 | 687 | return ( | |