diff --git a/apps/client/package.json b/apps/client/package.json index b922efe9..97e67394 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.208", + "@braintree/sanitize-url": "7.1.2", "@atlaskit/pragmatic-drag-and-drop": "1.8.1", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5", "@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index d19bbded..880bab00 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,38 +1,72 @@ +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; +import { Center, Loader } from "@mantine/core"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import Layout from "@/components/layouts/global/layout.tsx"; +import { useTrackOrigin } from "@/hooks/use-track-origin"; + +// ShareLayout is route-split: its ShareShell chrome pulls in the table of +// contents (and thus TipTap), so keeping it out of the eager graph removes the +// editor engine from startup for authenticated users too. +const ShareLayout = lazy( + () => import("@/features/share/components/share-layout.tsx"), +); + +// Auth / entry pages stay eager: they are the first paint for an unauthenticated +// visitor (e.g. /login) and are already small, so code-splitting them would only +// add a cold-chunk round trip to the most common cold-start path. import SetupWorkspace from "@/pages/auth/setup-workspace.tsx"; import LoginPage from "@/pages/auth/login"; -import Home from "@/pages/dashboard/home"; -import Page from "@/pages/page/page"; -import AccountSettings from "@/pages/settings/account/account-settings"; -import WorkspaceMembers from "@/pages/settings/workspace/workspace-members"; -import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings"; -import AiSettings from "@/pages/settings/workspace/ai-settings"; -import Groups from "@/pages/settings/group/groups"; -import GroupInfo from "./pages/settings/group/group-info"; -import Spaces from "@/pages/settings/space/spaces.tsx"; -import { Error404 } from "@/components/ui/error-404.tsx"; -import AccountPreferences from "@/pages/settings/account/account-preferences.tsx"; -import SpaceHome from "@/pages/space/space-home.tsx"; -import PageRedirect from "@/pages/page/page-redirect.tsx"; -import Layout from "@/components/layouts/global/layout.tsx"; import InviteSignup from "@/pages/auth/invite-signup.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx"; import PasswordReset from "./pages/auth/password-reset"; -import SharedPage from "@/pages/share/shared-page.tsx"; -import Shares from "@/pages/settings/shares/shares.tsx"; -import ShareLayout from "@/features/share/components/share-layout.tsx"; +import PageRedirect from "@/pages/page/page-redirect.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx"; -import { useTrackOrigin } from "@/hooks/use-track-origin"; -import SpacesPage from "@/pages/spaces/spaces.tsx"; -import SpaceTrash from "@/pages/space/space-trash.tsx"; -import FavoritesPage from "@/pages/favorites/favorites-page"; -import LabelPage from "@/pages/label/label-page"; + +// Heavy / leaf pages are route-split with React.lazy so their code (most +// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that +// the page editor and the readonly share editor pull in) is fetched only when +// the matching route is actually visited. The boundaries live inside +// each Layout (around its ), so the app shell stays mounted while a +// route chunk loads. +const Home = lazy(() => import("@/pages/dashboard/home")); +const Page = lazy(() => import("@/pages/page/page")); +const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx")); +const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx")); +const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); +const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page")); +const LabelPage = lazy(() => import("@/pages/label/label-page")); +const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx")); + +const AccountSettings = lazy( + () => import("@/pages/settings/account/account-settings"), +); +const AccountPreferences = lazy( + () => import("@/pages/settings/account/account-preferences.tsx"), +); +const WorkspaceSettings = lazy( + () => import("@/pages/settings/workspace/workspace-settings"), +); +const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings")); +const WorkspaceMembers = lazy( + () => import("@/pages/settings/workspace/workspace-members"), +); +const Groups = lazy(() => import("@/pages/settings/group/groups")); +const GroupInfo = lazy(() => import("./pages/settings/group/group-info")); +const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx")); +const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx")); export default function App() { useTrackOrigin(); return ( - <> + + + + } + > } /> } /> @@ -83,6 +117,6 @@ export default function App() { } /> - + ); } 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 new file mode 100644 index 00000000..c3e39a00 --- /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. +export 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/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 1b41011e..302cf2bc 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -1,9 +1,10 @@ import { AppShell, Container } from "@mantine/core"; -import React, { useEffect, useRef, useState } from "react"; +import React, { Suspense, useEffect, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; +import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { APP_NAVBAR_ID, NAVBAR_COLLAPSE_BREAKPOINT, @@ -14,8 +15,6 @@ import { } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; import { AppHeader } from "@/components/layouts/global/app-header.tsx"; -import Aside from "@/components/layouts/global/aside.tsx"; -import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx"; import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx"; import classes from "./app-shell.module.css"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; @@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched +// only after the user first opens the chat, instead of for every authenticated +// user on load. The window itself renders null while closed, so there is no +// behavior difference — it simply is not mounted until first opened. +const AiChatWindow = React.lazy( + () => import("@/features/ai-chat/components/ai-chat-window.tsx"), +); + +// The right aside hosts the comment panel and table of contents, both of which +// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps +// the whole editor engine out of the eager global-shell startup graph. +const Aside = React.lazy( + () => import("@/components/layouts/global/aside.tsx"), +); + export default function GlobalAppShell({ children, }: { @@ -37,6 +51,15 @@ export default function GlobalAppShell({ const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); + // Latch: once the AI chat window has been opened, keep it mounted so an + // in-flight stream is never torn down. Before the first open the AI chat chunk + // is never fetched. + const aiChatOpen = useAtomValue(aiChatWindowOpenAtom); + const [aiChatEverOpened, setAiChatEverOpened] = useState(false); + useEffect(() => { + if (aiChatOpen) setAiChatEverOpened(true); + }, [aiChatOpen]); + const startResizing = React.useCallback((mouseDownEvent) => { mouseDownEvent.preventDefault(); setIsResizing(true); @@ -160,13 +183,21 @@ export default function GlobalAppShell({ : undefined } > -