diff --git a/apps/admin/package.json b/apps/admin/package.json index f76cd3c15a2..d889a1c6ac5 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Admin UI for Plane", "license": "AGPL-3.0", diff --git a/apps/api/package.json b/apps/api/package.json index b2fee3bb9bb..912e90d20fc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "API server powering Plane's backend", "license": "AGPL-3.0" diff --git a/apps/live/package.json b/apps/live/package.json index 4c46b2ca5f5..9d418e14af9 100644 --- a/apps/live/package.json +++ b/apps/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "A realtime collaborative server powers Plane's rich text editor", "license": "AGPL-3.0", diff --git a/apps/space/package.json b/apps/space/package.json index 602921852d1..7ccbca5206f 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", 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; +}; diff --git a/apps/web/package.json b/apps/web/package.json index 9fbf4caf9a1..273a7a8769d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/package.json b/package.json index 44798ac9912..119b0fba09c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plane", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Open-source project management that unlocks customer value", "license": "AGPL-3.0", diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 4adb38360c6..83a31e93845 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,6 +1,6 @@ { "name": "@plane/codemods", - "version": "1.4.1", + "version": "1.4.2", "private": true, "scripts": { "test": "vitest run", diff --git a/packages/constants/package.json b/packages/constants/package.json index 61de5bfbdcc..c10def67fda 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/editor/package.json b/packages/editor/package.json index 837353e869b..a3ec66eef63 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Core Editor that powers Plane", "keywords": [ diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 8c1088bff4f..8a509c6642d 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@plane/hooks", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "React hooks that are shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 32c05b72c1f..6a60e258933 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@plane/i18n", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "I18n shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/logger/package.json b/packages/logger/package.json index fe6fe66cde3..7ebd844070d 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@plane/logger", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Logger shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/propel/package.json b/packages/propel/package.json index fae839f79df..f92a5579bc7 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,6 +1,6 @@ { "name": "@plane/propel", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/services/package.json b/packages/services/package.json index 7f988cc0481..c76d4a6c081 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@plane/services", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 0c28184383a..21cfedcedd0 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -1,6 +1,6 @@ { "name": "@plane/shared-state", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Shared state shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 10b8fc156bd..6c37a83db38 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/tailwind-config", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "common tailwind configuration across monorepo", "license": "AGPL-3.0", diff --git a/packages/types/package.json b/packages/types/package.json index 440bbb7f8e1..7218205243f 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 01543ae8ede..4368b20a3fd 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "AGPL-3.0", "files": [ diff --git a/packages/ui/package.json b/packages/ui/package.json index a7e7c047ae4..94c5d0ef986 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plane/ui", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "UI components shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/utils/package.json b/packages/utils/package.json index 50871c0865a..2dc85cda7fb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@plane/utils", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "Helper functions shared across multiple apps internally", "license": "AGPL-3.0",