fix(#342 review round-2 F5-F6): drop the posthog re-render remount + test chunk detector

- F5 [stability/regression]: the round-1 F2 fix re-rendered the root with
  <PostHogProvider><App/></PostHogProvider> after the analytics chunk loaded. In
  the ChunkLoadErrorBoundary child slot the element TYPE changes App ->
  PostHogProvider, so React does NOT reconcile in place — it REMOUNTS the whole
  App: every mount effect runs twice (websocket connect/disconnect, origin
  tracking, subscriptions) and local state / focus / scroll / in-progress input is
  lost on cloud cold-load (e.g. typing in /login before analytics loads). And it
  was USELESS: the app has ZERO consumers of the PostHog React context (no
  usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given an
  initialized client is a no-op — all capture goes through the posthog singleton.
  Fix: initAnalytics now inits the posthog SINGLETON only (no posthog-js/react
  import, no second render); renderApp() renders <App/> once. First paint stays
  instant, cloud analytics behavior unchanged, no remount.
- F6 [test]: exported isChunkLoadError + chunk-load-error-boundary.test.ts —
  pins the detector (ChunkLoadError name + the 3 dynamic-import failure messages,
  case-insensitive → true; null/undefined/ordinary errors → false) so a
  false-negative that re-blanks the app on a real chunk-404 is caught.

Gate: client tsc 0, chunk-load + sanitize tests 14 passed. Entry chunk unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-05 00:28:46 +03:00
parent 6475cb81e0
commit fdb6f39a8e
3 changed files with 57 additions and 19 deletions
@@ -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);
});
});
@@ -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 ?? "";
+19 -18
View File
@@ -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(
<BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
@@ -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. */}
<ChunkLoadErrorBoundary>{app}</ChunkLoadErrorBoundary>
<ChunkLoadErrorBoundary>
<App />
</ChunkLoadErrorBoundary>
</HelmetProvider>
</QueryClientProvider>
</ModalsProvider>
@@ -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
// <PostHogProvider>. 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(
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>,
);
} 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(<App />);
// cloud no longer blocks on the analytics import). The posthog singleton is
// initialized after, without re-rendering the tree.
renderApp();
void initAnalytics();