Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions client/portal/src/components/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ArrowLeft, Home } from "lucide-react";
import { ArrowLeft, Home, RefreshCw } from "lucide-react";
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router";

import { Button } from "@/components/ui/button";
import { isChunkLoadError } from "@/shared/lib/stale-chunk-reload";

/**
* Static rather than the super admin's runtime `contact_email`: this page is
Expand Down Expand Up @@ -78,6 +79,9 @@ export function ErrorPage() {
const error = useRouteError();
const navigate = useNavigate();
const { status, label, title, message, detail } = describeError(error);
// A missing content-hashed chunk means this tab predates a deploy. Only a
// reload fetches the fresh shell; in-app navigation keeps the stale one.
const staleBuild = detail != null && isChunkLoadError(detail);

return (
<main className="zero-login relative isolate flex min-h-svh flex-col overflow-hidden bg-black text-white">
Expand Down Expand Up @@ -146,7 +150,19 @@ export function ErrorPage() {
</div>
)}

<div className="mt-6 flex flex-col gap-2.5 sm:mt-7 sm:flex-row">
{staleBuild && (
<Button
className="zero-cut-button mt-6 h-11 w-full bg-[#21FFF0] text-xs font-medium tracking-[0.18em] text-black uppercase shadow-[0_0_20px_rgba(33,255,240,0.28)] hover:bg-[#5cfff4] focus-visible:ring-[#21FFF0]/50 sm:mt-7 sm:h-12"
onClick={() => window.location.reload()}
>
<RefreshCw aria-hidden className="mr-1.5 size-4" />A new version
is available — reload
</Button>
)}

<div
className={`${staleBuild ? "mt-2.5" : "mt-6 sm:mt-7"} flex flex-col gap-2.5 sm:flex-row`}
>
<Button
variant="outline"
className="zero-cut-button h-11 w-full border-[#21FFF0]/45 bg-transparent text-xs font-medium tracking-[0.18em] text-white uppercase hover:border-[#21FFF0] hover:bg-[#21FFF0]/10 hover:text-white focus-visible:ring-[#21FFF0]/50 sm:h-12 sm:flex-1"
Expand Down
53 changes: 46 additions & 7 deletions client/portal/src/shared/lib/stale-chunk-reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,62 @@
* home-screen PWA that was open before a deploy still holds the previous
* build's chunk names. The first navigation after the deploy asks the server
* for a file that no longer exists, the dynamic import rejects, and the router
* would show the 500 page for what is really "you need the new build". Vite
* dispatches `vite:preloadError` for exactly that failure; a reload picks up
* the current shell and its chunks.
* would show the 500 page for what is really "you need the new build". A
* reload picks up the current shell (served no-cache) and its live chunks.
*
* Vite dispatches `vite:preloadError` for that failure when the import goes
* through its preload helper; imports that bypass it (router lazy loaders,
* `import()` inside effects) surface as an unhandled rejection or a global
* error instead, so those are watched too.
*
* One reload per short window: if the fresh build also fails to load, the
* problem is not staleness and the error should surface instead of looping.
*/
const RELOAD_STAMP_KEY = "harp:stale-chunk-reload";
const RELOAD_COOLDOWN_MS = 10_000;

const CHUNK_LOAD_ERROR_PATTERNS = [
/failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/importing a module script failed/i,
/chunkloaderror/i,
];

export function isChunkLoadError(message: string): boolean {
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}

function errorMessage(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
if (typeof value === "string") return value;
return "";
}

/** Reloads unless a reload already happened within the cooldown window. */
export function reloadOnce(): boolean {
const last = Number(sessionStorage.getItem(RELOAD_STAMP_KEY) ?? 0);
if (Date.now() - last < RELOAD_COOLDOWN_MS) return false;

sessionStorage.setItem(RELOAD_STAMP_KEY, String(Date.now()));
window.location.reload();
return true;
}

export function installStaleChunkReload(): void {
window.addEventListener("vite:preloadError", (event) => {
const last = Number(sessionStorage.getItem(RELOAD_STAMP_KEY) ?? 0);
if (Date.now() - last < RELOAD_COOLDOWN_MS) return;
event.preventDefault();
reloadOnce();
});

sessionStorage.setItem(RELOAD_STAMP_KEY, String(Date.now()));
window.addEventListener("unhandledrejection", (event) => {
if (!isChunkLoadError(errorMessage(event.reason))) return;
event.preventDefault();
window.location.reload();
reloadOnce();
});

window.addEventListener("error", (event) => {
const message = errorMessage(event.error) || event.message;
if (!isChunkLoadError(message)) return;
reloadOnce();
});
}
Loading