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
18 changes: 18 additions & 0 deletions apps/web/app/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,27 @@ import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";

import polyfills from "@/lib/polyfills";
import { isStaleAssetErrorMessage, recoverFromStaleAsset } from "@/lib/stale-asset-error";

void polyfills;

// Production-only: in dev these errors come from the dev server itself (restarts,
// stale optimized deps) and auto-reloading would mask them.
if (import.meta.env.PROD) {
window.addEventListener("vite:preloadError", (event) => {
if (recoverFromStaleAsset()) event.preventDefault();
});

window.addEventListener("error", (event) => {
if (isStaleAssetErrorMessage(event.message || "")) recoverFromStaleAsset();
});

window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "");
if (isStaleAssetErrorMessage(reason)) recoverFromStaleAsset();
});
}

startTransition(() => {
hydrateRoot(
document,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import globalStyles from "@/styles/globals.css?url";
import type { Route } from "./+types/root";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// lib
import { isStaleAssetError, recoverFromStaleAsset } from "@/lib/stale-asset-error";
// local
import { CustomErrorComponent } from "./error";
import { AppProvider } from "./provider";
Expand Down Expand Up @@ -146,5 +148,10 @@ export function HydrateFallback() {
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
// A stale chunk failure surfaces here as React Router's own wrapper error
// (the failed dynamic import itself never reaches a window event) — recover
// the same way entry.client.tsx does instead of just showing the error page.
if (import.meta.env.PROD && isStaleAssetError(error)) recoverFromStaleAsset();

return <CustomErrorComponent error={error} />;
}
66 changes: 66 additions & 0 deletions apps/web/core/lib/stale-asset-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

// Error signatures produced when a deploy removes hashed assets that an open tab
// still references (stale-tab version skew).
const STALE_ASSET_ERROR_SIGNATURES = [
"Failed to fetch dynamically imported module", // Chromium
"error loading dynamically imported module", // Firefox
"Importing a module script failed", // Safari
"Unable to preload CSS", // Vite preload helper
// React Router's own wrapper when a route module's lazy import fails during
// navigation — the original browser error above never reaches here as a
// window event, only this synthesized message.
"No result returned from dataStrategy for route",
];

export const isStaleAssetErrorMessage = (message: string): boolean =>
STALE_ASSET_ERROR_SIGNATURES.some((signature) => message.includes(signature));

export const isStaleAssetError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
return isStaleAssetErrorMessage(message);
};

// Reload-once-then-fall-through-to-boundary guard, shared by the window-level
// listeners (entry.client.tsx) and the route ErrorBoundary (root.tsx) so both
// paths to the same failure share one reload attempt instead of racing.
const STALE_ASSET_RELOAD_KEY = "__plane_chunk_reload";
const STALE_ASSET_RELOAD_WINDOW_MS = 30_000;

const hasRecentStaleAssetReload = (): boolean => {
try {
const lastReloadAt = Number(sessionStorage.getItem(STALE_ASSET_RELOAD_KEY));
return Number.isFinite(lastReloadAt) && Date.now() - lastReloadAt < STALE_ASSET_RELOAD_WINDOW_MS;
} catch {
// No storage means no loop guard — never auto-reload in that case.
return true;
}
};

let isRecoveringFromStaleAsset = false;

// Returns whether it actually triggered a reload, so callers that can otherwise
// fall back to default browser error handling (e.g. Vite's preload-error
// overlay) only suppress that fallback when a reload is really in flight.
export const recoverFromStaleAsset = (): boolean => {
if (isRecoveringFromStaleAsset) return false;
isRecoveringFromStaleAsset = true;

if (hasRecentStaleAssetReload()) {
// Second failure in a row — surface to the route error boundary instead of looping.
isRecoveringFromStaleAsset = false;
return false;
}
try {
sessionStorage.setItem(STALE_ASSET_RELOAD_KEY, String(Date.now()));
} catch {
isRecoveringFromStaleAsset = false;
return false;
}
window.location.reload();
return true;
};
Loading