diff --git a/apps/client/package.json b/apps/client/package.json index 76d617da..709bc01b 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/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 } > -