diff --git a/apps/client/src/components/chunk-load-error-boundary.test.ts b/apps/client/src/components/chunk-load-error-boundary.test.ts new file mode 100644 index 00000000..78ecce6b --- /dev/null +++ b/apps/client/src/components/chunk-load-error-boundary.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { isChunkLoadError } from "./chunk-load-error-boundary"; + +// The detector decides whether a caught render error is a stale-deploy chunk-404 +// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic +// recovery UI, no reload). A false negative on a real chunk failure re-blanks the +// app; a false positive would auto-reload on an ordinary error. Pin both sides. +describe("isChunkLoadError", () => { + it("detects the ChunkLoadError name", () => { + expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true); + }); + + it.each([ + "Failed to fetch dynamically imported module: https://x/assets/index-abc.js", + "error loading dynamically imported module", + "Importing a module script failed.", + ])("detects the dynamic-import failure message %#", (message) => { + expect(isChunkLoadError({ name: "TypeError", message })).toBe(true); + }); + + it("is case-insensitive on the message", () => { + expect( + isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }), + ).toBe(true); + }); + + it.each([ + null, + undefined, + {}, + { name: "TypeError", message: "Cannot read properties of undefined" }, + { message: "Network request failed" }, + new Error("some ordinary render error"), + ])("returns false for a non-chunk error %#", (err) => { + expect(isChunkLoadError(err)).toBe(false); + }); +}); diff --git a/apps/client/src/components/chunk-load-error-boundary.tsx b/apps/client/src/components/chunk-load-error-boundary.tsx index 07b870c2..c3e39a00 100644 --- a/apps/client/src/components/chunk-load-error-boundary.tsx +++ b/apps/client/src/components/chunk-load-error-boundary.tsx @@ -9,7 +9,7 @@ const RELOAD_FLAG = "chunk-reload-attempted"; // 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 { +export function isChunkLoadError(error: unknown): boolean { if (!error) return false; const name = (error as { name?: string }).name ?? ""; const message = (error as { message?: string }).message ?? ""; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 45c7def3..39779765 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -4,7 +4,6 @@ import "@mantine/notifications/styles.css"; import '@mantine/dates/styles.css'; import "@/styles/a11y-overrides.css"; -import { ReactNode } from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { mantineCssResolver, theme } from "@/theme"; @@ -37,7 +36,7 @@ export const queryClient = new QueryClient({ const container = document.getElementById("root") as HTMLElement; const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); -function renderApp(app: ReactNode) { +function renderApp() { root.render( @@ -48,7 +47,9 @@ function renderApp(app: ReactNode) { {/* 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} + + + @@ -58,39 +59,39 @@ function renderApp(app: ReactNode) { } 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. + // posthog-js 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. // // 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. + // + // NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in + // . The app has zero consumers of the PostHog React context + // (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given + // an already-initialized `client` is a no-op — all capture goes through the + // singleton. Re-rendering to attach the provider would only REMOUNT the whole + // App (running every mount effect twice and dropping local state / focus / + // in-progress input on cloud cold-load) for no functional gain. if (!(isCloud() && isPostHogEnabled)) return; try { const { default: posthog } = await import("posthog-js"); - const { PostHogProvider } = await import("posthog-js/react"); posthog.init(getPostHogKey(), { api_host: getPostHogHost(), defaults: "2025-05-24", 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( - - - , - ); } catch { // Analytics failed to load — degrade gracefully; the app already rendered. } } // 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(); +// cloud no longer blocks on the analytics import). The posthog singleton is +// initialized after, without re-rendering the tree. +renderApp(); void initAnalytics();