From a0ec93506c303c7afe3efa1362c1af845d4f8656 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:29:56 +0100 Subject: [PATCH] chore: compress and lazy load client code (#2425) --- apps/client/package.json | 2 +- apps/client/scripts/compress-dist.mjs | 48 +++++++ apps/client/src/App.tsx | 122 +++++++++++------- .../src/components/layouts/global/aside.tsx | 47 ++++--- .../layouts/global/global-app-shell.tsx | 11 +- .../components/sidebar/space-sidebar.tsx | 16 ++- .../src/integrations/static/static.module.ts | 10 ++ 7 files changed, 186 insertions(+), 70 deletions(-) create mode 100644 apps/client/scripts/compress-dist.mjs diff --git a/apps/client/package.json b/apps/client/package.json index d836d5db6..2c629a7e9 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -4,7 +4,7 @@ "version": "0.95.0", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/compress-dist.mjs", "lint": "eslint .", "preview": "vite preview", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", diff --git a/apps/client/scripts/compress-dist.mjs b/apps/client/scripts/compress-dist.mjs new file mode 100644 index 000000000..09b941c95 --- /dev/null +++ b/apps/client/scripts/compress-dist.mjs @@ -0,0 +1,48 @@ +import { promises as fs } from "node:fs"; +import { join, extname } from "node:path"; +import { promisify } from "node:util"; +import zlib from "node:zlib"; + +const gzip = promisify(zlib.gzip); +const brotli = promisify(zlib.brotliCompress); + +const DIST = new URL("../dist", import.meta.url).pathname; +// index.html is rewritten by the server at boot, so html must not be precompressed +const COMPRESSIBLE = new Set([".js", ".css", ".svg", ".json", ".txt", ".map"]); +const MIN_SIZE = 1024; + +async function* walk(dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(path); + else yield path; + } +} + +const files = []; +for await (const file of walk(DIST)) { + if (!COMPRESSIBLE.has(extname(file))) continue; + const { size } = await fs.stat(file); + if (size >= MIN_SIZE) files.push(file); +} + +await Promise.all( + files.map(async (file) => { + const content = await fs.readFile(file); + const [gz, br] = await Promise.all([ + gzip(content, { level: 9 }), + brotli(content, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: content.length, + }, + }), + ]); + await Promise.all([ + fs.writeFile(`${file}.gz`, gz), + fs.writeFile(`${file}.br`, br), + ]); + }), +); + +console.log(`precompressed ${files.length} assets (.gz/.br)`); diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ab291ffea..c90593e58 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,60 +1,92 @@ +import { lazy, Suspense, useEffect } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; -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 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 Billing from "@/ee/billing/pages/billing.tsx"; -import CloudLogin from "@/ee/pages/cloud-login.tsx"; -import CreateWorkspace from "@/ee/pages/create-workspace.tsx"; +import { Error404 } from "@/components/ui/error-404.tsx"; import { isCloud } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; -import Security from "@/ee/security/pages/security.tsx"; -import License from "@/ee/licence/pages/license.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; -import SharedPage from "@/pages/share/shared-page.tsx"; -import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx"; -import Shares from "@/pages/settings/shares/shares.tsx"; -import ShareLayout from "@/features/share/components/share-layout.tsx"; -import ShareRedirect from "@/pages/share/share-redirect.tsx"; import { useTrackOrigin } from "@/hooks/use-track-origin"; -import SpacesPage from "@/pages/spaces/spaces.tsx"; -import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; -import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; -import SpaceTrash from "@/pages/space/space-trash.tsx"; -import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; -import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; -import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; -import BasePage from "@/ee/base/pages/base-page.tsx"; -import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; -import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; -import TemplateList from "@/ee/template/pages/template-list"; -import TemplateEditor from "@/ee/template/pages/template-editor"; -import FavoritesPage from "@/pages/favorites/favorites-page"; -import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; -import VerifyEmail from "@/ee/pages/verify-email.tsx"; -import LabelPage from "@/pages/label/label-page"; + +const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); +const LoginPage = lazy(() => import("@/pages/auth/login")); +const Home = lazy(() => import("@/pages/dashboard/home")); +const Page = lazy(() => import("@/pages/page/page")); +const AccountSettings = lazy( + () => import("@/pages/settings/account/account-settings"), +); +const WorkspaceMembers = lazy( + () => import("@/pages/settings/workspace/workspace-members"), +); +const WorkspaceSettings = lazy( + () => import("@/pages/settings/workspace/workspace-settings"), +); +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 AccountPreferences = lazy( + () => import("@/pages/settings/account/account-preferences.tsx"), +); +const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx")); +const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx")); +const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx")); +const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx")); +const PasswordReset = lazy(() => import("./pages/auth/password-reset")); +const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx")); +const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx")); +const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx")); +const Security = lazy(() => import("@/ee/security/pages/security.tsx")); +const License = lazy(() => import("@/ee/licence/pages/license.tsx")); +const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx")); +const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx")); +const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx")); +const ShareLayout = lazy( + () => import("@/features/share/components/share-layout.tsx"), +); +const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); +const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); +const MfaChallengePage = lazy(() => + import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ + default: m.MfaChallengePage, + })), +); +const MfaSetupRequiredPage = lazy(() => + import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({ + default: m.MfaSetupRequiredPage, + })), +); +const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx")); +const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys")); +const WorkspaceApiKeys = lazy( + () => import("@/ee/api-key/pages/workspace-api-keys"), +); +const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx")); +const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx")); +const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx")); +const VerifiedPages = lazy( + () => import("@/ee/page-verification/pages/verified-pages.tsx"), +); +const TemplateList = lazy(() => import("@/ee/template/pages/template-list")); +const TemplateEditor = lazy( + () => import("@/ee/template/pages/template-editor"), +); +const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page")); +const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx")); +const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx")); +const LabelPage = lazy(() => import("@/pages/label/label-page")); export default function App() { const { t } = useTranslation(); useRedirectToCloudSelect(); useTrackOrigin(); + useEffect(() => { + // warm the editor chunk so opening a page doesn't wait on the network + const timer = setTimeout(() => import("@/pages/page/page"), 3000); + return () => clearTimeout(timer); + }, []); + return ( - <> + } /> } /> @@ -135,6 +167,6 @@ export default function App() { } /> - + ); } diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 556adbf17..856e02141 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,17 +1,30 @@ import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { IconX } from "@tabler/icons-react"; -import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import React, { ReactNode, useEffect } from "react"; +import React, { lazy, ReactNode, Suspense, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; -import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; -import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; +const CommentListWithTabs = lazy( + () => import("@/features/comment/components/comment-list-with-tabs.tsx"), +); +const TableOfContents = lazy(() => + import( + "@/features/editor/components/table-of-contents/table-of-contents.tsx" + ).then((m) => ({ default: m.TableOfContents })), +); +const AsideChatPanel = lazy( + () => import("@/ee/ai-chat/components/aside-chat-panel"), +); +const PageDetailsAside = lazy(() => + import("@/features/page-details/components/page-details-aside.tsx").then( + (m) => ({ default: m.PageDetailsAside }), + ), +); + export default function Aside() { const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const { t } = useTranslation(); @@ -68,17 +81,19 @@ export default function Aside() { )} - {tab === "comments" || tab === "chat" ? ( - component - ) : ( - -
{component}
-
- )} + + {tab === "comments" || tab === "chat" ? ( + component + ) : ( + +
{component}
+
+ )} +
)} 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 f35029d40..272ceba62 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -11,7 +11,10 @@ import { sidebarWidthAtom, } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; -import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx"; + +const AiChatSidebar = React.lazy( + () => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"), +); import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import Aside from "@/components/layouts/global/aside.tsx"; import classes from "./app-shell.module.css"; @@ -126,7 +129,11 @@ export default function GlobalAppShell({ )} {isSpaceRoute && } {isSettingsRoute && } - {isAiRoute && } + {isAiRoute && ( + + + + )} {showGlobalSidebar && } diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index f5c6ea0e7..ac67deeb9 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -54,7 +54,9 @@ import { import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { searchSpotlight } from "@/features/search/constants"; -import TemplatePickerModal from "@/ee/template/components/template-picker-modal"; +const TemplatePickerModal = React.lazy( + () => import("@/ee/template/components/template-picker-modal"), +); import { useHasFeature } from "@/ee/hooks/use-feature"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { Feature } from "@/ee/features"; @@ -406,11 +408,13 @@ function SpaceMenu({ {hasTemplates && templatePickerOpened && ( null}> - + + + )} diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 8805804f3..4e5f9b486 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -71,6 +71,16 @@ export class StaticModule implements OnModuleInit { await app.register(fastifyStatic, { root: clientDistPath, wildcard: false, + preCompressed: true, + setHeaders: (reply: any, pathName: string) => { + // Vite content-hashes everything under /assets, so they can be cached forever + if (/[\\/]assets[\\/]/.test(pathName)) { + reply.header( + 'Cache-Control', + 'public, max-age=31536000, immutable', + ); + } + }, }); app.get(RENDER_PATH, (req: any, res: any) => {