diff --git a/apps/web/app/entry.client.tsx b/apps/web/app/entry.client.tsx index 8dc2a587717..1092f6e43db 100644 --- a/apps/web/app/entry.client.tsx +++ b/apps/web/app/entry.client.tsx @@ -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, diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index e9f46d014c1..08a38e632eb 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -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"; @@ -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 ; } diff --git a/apps/web/core/lib/stale-asset-error.ts b/apps/web/core/lib/stale-asset-error.ts new file mode 100644 index 00000000000..5276c023786 --- /dev/null +++ b/apps/web/core/lib/stale-asset-error.ts @@ -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; +};