diff --git a/apps/client/src/components/chunk-load-error-boundary.tsx b/apps/client/src/components/chunk-load-error-boundary.tsx new file mode 100644 index 00000000..07b870c2 --- /dev/null +++ b/apps/client/src/components/chunk-load-error-boundary.tsx @@ -0,0 +1,71 @@ +import { ReactNode } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { Button, Center, Stack, Text } from "@mantine/core"; + +const RELOAD_FLAG = "chunk-reload-attempted"; + +// Heuristic detection of a failed dynamic import. Since the code-splitting work, +// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy +// replaces the hashed chunks, a tab left open on the old index.html requests a +// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these +// with a ChunkLoadError name or one of these messages. +function isChunkLoadError(error: unknown): boolean { + if (!error) return false; + const name = (error as { name?: string }).name ?? ""; + const message = (error as { message?: string }).message ?? ""; + return ( + name === "ChunkLoadError" || + /Failed to fetch dynamically imported module/i.test(message) || + /error loading dynamically imported module/i.test(message) || + /Importing a module script failed/i.test(message) + ); +} + +function handleError(error: unknown) { + if (!isChunkLoadError(error)) return; + // A stale-chunk 404 is cured by a full reload that re-fetches index.html and + // the new chunk manifest. Auto-reload once, guarding against a reload loop + // (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the + // flag is already set we fall through to the manual recovery UI below. + try { + if (sessionStorage.getItem(RELOAD_FLAG)) return; + sessionStorage.setItem(RELOAD_FLAG, "1"); + } catch { + // sessionStorage unavailable (private mode / disabled): skip the automatic + // reload rather than risk an unguarded loop; the fallback UI still recovers. + return; + } + window.location.reload(); +} + +// Root-level boundary that sits ABOVE every route-level Suspense boundary so a +// lazy route/component chunk failure is caught here instead of unmounting the +// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx, +// transclusion, page-embed) remain in place underneath for their local errors. +export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) { + return ( + { + const chunk = isChunkLoadError(error); + return ( +
+ + + {chunk ? "A new version is available" : "Something went wrong"} + + + {chunk + ? "Please reload the page to load the latest version." + : "An unexpected error occurred. Reloading the page may help."} + + + +
+ ); + }} + > + {children} +
+ ); +} diff --git a/apps/client/src/components/layouts/global/layout.tsx b/apps/client/src/components/layouts/global/layout.tsx index 9e931c1f..abf4d9bd 100644 --- a/apps/client/src/components/layouts/global/layout.tsx +++ b/apps/client/src/components/layouts/global/layout.tsx @@ -19,7 +19,9 @@ export default function Layout() { const ric = typeof window !== "undefined" && (window as any).requestIdleCallback; const warm = () => { - void import("@/pages/page/page"); + // Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless + // and must not surface as an unhandledrejection. + void import("@/pages/page/page").catch(() => {}); }; if (ric) { const id = ric(warm); diff --git a/apps/client/src/lib/sanitize-url.test.ts b/apps/client/src/lib/sanitize-url.test.ts new file mode 100644 index 00000000..7c0591a9 --- /dev/null +++ b/apps/client/src/lib/sanitize-url.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeUrl } from "./sanitize-url"; + +// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper +// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS +// sentinel to "". These assertions mirror editor-ext's own security-contract +// test so the extracted copy keeps the same guarantees. +describe("sanitizeUrl", () => { + it("blocks dangerous schemes (returns empty string)", () => { + expect(sanitizeUrl("javascript:alert(1)")).toBe(""); + expect(sanitizeUrl("data:text/html,")).toBe(""); + expect(sanitizeUrl("vbscript:msgbox(1)")).toBe(""); + // Case / whitespace obfuscation must not slip past the sanitizer. + expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe(""); + }); + + it("returns empty string for empty / undefined input", () => { + expect(sanitizeUrl(undefined)).toBe(""); + expect(sanitizeUrl("")).toBe(""); + }); + + it("allows safe https, relative file and mailto URLs", () => { + expect(sanitizeUrl("https://example.com/page")).toMatch( + /^https:\/\/example\.com\/page/, + ); + expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123"); + expect(sanitizeUrl("mailto:user@example.com")).toBe( + "mailto:user@example.com", + ); + }); +}); diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 6efaf778..45c7def3 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -14,6 +14,7 @@ import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { HelmetProvider } from "react-helmet-async"; +import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx"; import "./i18n"; import { getPostHogHost, @@ -43,7 +44,12 @@ function renderApp(app: ReactNode) { - {app} + + {/* Root boundary above every lazy route's Suspense: a stale-chunk + 404 after a deploy is caught and recovered here instead of + blanking the whole app. */} + {app} + @@ -51,12 +57,18 @@ function renderApp(app: ReactNode) { ); } -async function bootstrap() { +async function initAnalytics() { // posthog-js (and its React provider) is only pulled in for cloud deployments // with analytics enabled, so self-hosted builds never download it. The gate is // kept identical to the previous eager code so cloud analytics behavior is // unchanged; the import is simply deferred behind it. - if (isCloud() && isPostHogEnabled) { + // + // Crucially this runs AFTER the immediate first render below, so first paint is + // never gated on the analytics chunk. Any failure (network, stale 404, or an + // ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a + // working app without analytics instead of a permanently blank page. + if (!(isCloud() && isPostHogEnabled)) return; + try { const { default: posthog } = await import("posthog-js"); const { PostHogProvider } = await import("posthog-js/react"); posthog.init(getPostHogKey(), { @@ -65,14 +77,20 @@ async function bootstrap() { disable_session_recording: true, capture_pageleave: false, }); + // Re-render with the provider now that analytics is ready. React reconciles + // the same root, attaching the PostHog context above the (already painted) + // app so the whole cloud tree is wrapped in PostHogProvider as before. renderApp( , ); - } else { - renderApp(); + } catch { + // Analytics failed to load — degrade gracefully; the app already rendered. } } -void bootstrap(); +// Paint immediately for everyone (self-hosted stays exactly as instant as before, +// cloud no longer blocks on the analytics import). Analytics is attached after. +renderApp(); +void initAnalytics();