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) => {