From 1c8a60f858d8472aa56e29994ec1c7926da2c6ce Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Mon, 10 Aug 2026 17:34:02 +0530 Subject: [PATCH] [WEB-8632] fix(web): auto-reload on stale chunk load failure during navigation (#9579) * fix(web): implement stale asset recovery in entry client and error boundary Added event listeners in entry.client.tsx to handle stale asset errors in production, allowing for recovery from stale assets. Updated the ErrorBoundary component in root.tsx to utilize the same recovery mechanism for stale chunk failures, enhancing error handling and user experience. * fix(web): improve stale asset recovery logic in entry client Refactored the recoverFromStaleAsset function to return a boolean indicating whether a reload was triggered, enhancing the handling of stale asset errors. Updated the event listener in entry.client.tsx to prevent default behavior only when a recovery is in progress, improving error management in production environments. --- apps/web/app/entry.client.tsx | 18 +++++++ apps/web/app/root.tsx | 7 +++ apps/web/core/lib/stale-asset-error.ts | 66 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 apps/web/core/lib/stale-asset-error.ts 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; +};