From 2e6e805eeb32af2cfac4935ffbf03510b2ab0c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:12:44 +0000 Subject: [PATCH 1/2] perf(console): make the /docs portal genuinely lazy (objectui#5467) App.tsx statically imported DocPage / DocsSlug / DocsLayout while AppContent.tsx lazy-imported the same three for the app-scoped /apps/:packageId/docs tree, so all of them sat in the eager graph and the import() moved nothing -- three INEFFECTIVE_DYNAMIC_IMPORT warnings on every vite build. App.tsx now reaches all four docs pages through lazy() behind Suspense, matching AppContent's existing pattern. DocsIndex joins them because, left static, it alone would keep DocShell / use-book-data / book-nav eager. Measured (both builds exit 0): warnings 46 -> 44, eager closure 3,881,609 -> 3,870,058 gzipped bytes across 58 -> 52 chunks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .changeset/console-lazy-docs-portal-5467.md | 45 +++++++++++++++++++++ apps/console/src/App.tsx | 41 ++++++++++++++----- 2 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 .changeset/console-lazy-docs-portal-5467.md diff --git a/.changeset/console-lazy-docs-portal-5467.md b/.changeset/console-lazy-docs-portal-5467.md new file mode 100644 index 0000000000..9b44325e88 --- /dev/null +++ b/.changeset/console-lazy-docs-portal-5467.md @@ -0,0 +1,45 @@ +--- +'@object-ui/console': patch +--- + +The console's `/docs` portal is code-split for real: its four pages leave the eager closure instead of only pretending to. + +`AppContent.tsx` lazy-imports `DocsLayout` / `DocsSlug` / `DocPage` for the +app-scoped `/apps/:packageId/docs` tree (ADR-0048). `App.tsx` imported the same +three statically for the platform portal at `/docs` (ADR-0046 section 6), so all +of them sat in the eager graph regardless and the `import()` moved nothing — +three `INEFFECTIVE_DYNAMIC_IMPORT` warnings on every `vite build` +(objectui#5467). A static import on either side silently defeats the split for +both, and the only signal is a build warning that fails nothing. + +`App.tsx` now reaches all four docs pages through `lazy()` behind `Suspense`, +matching the pattern `AppContent.tsx` already uses. `DocsIndex` joins them even +though it carried no warning: `AppContent` renders `AppDocsIndex` at that slot, +so nothing imported `DocsIndex` dynamically, but left static it alone would keep +`DocShell`, `use-book-data` and `book-nav` eager and the portal would only +half-leave the closure. + +Measured on this branch with the `dist/eager-closure.json` gauge added by +objectui#5324, both builds exiting 0: + +| | before | after | +|---|---|---| +| `INEFFECTIVE_DYNAMIC_IMPORT` warnings | 46 | 44 | +| eager closure, gzipped | 3,881,609 B | 3,870,058 B | +| eager chunks | 58 | 52 | + +Six chunks leave the eager closure: `plugin-markdown` (4,212 B gz), +`CreateViewDialog` (3,617 B), `use-book-data` (1,966 B), `DocShell` (476 B), +`componentRegistry` (99 B), and `src` (129,555 B), the last of which rolldown +folds into the entry chunk rather than dropping — which is why the entry chunk +grows from 25,910 to 154,378 B gzipped while the closure as a whole shrinks by +11,551 B. The entry stays far under that budget's 350 KB line, and the eager +closure is the number a page load actually pays. + +What does NOT move is `vendor-markdown`, 164,708 B gzipped and the reason this +looked like a bigger win than it is. Three eager chunks import it statically, +and only one of them was this portal: `plugin-chatbot` reaches it directly, and +`packages/fields`' `MarkdownContent` — lazy in source — is folded into the +eagerly imported `ui-components` chunk by the `advancedChunks` group that claims +every `packages/fields` module. That is objectui#5325's mechanism, not this +card's, and it is why the saving here is 0.30% rather than 4%. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 8530096e0a..68d115b33d 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -12,7 +12,7 @@ * with extra `` children. */ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation, Link } from 'react-router-dom'; import { AuthProvider, useAuth } from '@object-ui/auth'; import { DevMasterDetail } from './dev/DevMasterDetail'; @@ -25,6 +25,7 @@ import { RequireAiSurface, SystemRedirect, ConsoleToaster, + LoadingScreen, DefaultHomeLayout, DefaultHomePage, DefaultOrganizationsLayout, @@ -49,10 +50,6 @@ import { FormPage } from './components/FormPage'; import { InternalFormRoute } from './components/InternalFormRoute'; import { MetadataHmrReloader } from './components/MetadataHmrReloader'; import SharedRecordPage from './pages/SharedRecordPage'; -import DocPage from './pages/DocPage'; -import DocsIndex from './pages/DocsIndex'; -import DocsSlug from './pages/DocsSlug'; -import DocsLayout from './pages/DocsLayout'; import { LoginPage } from './pages/auth/LoginPage'; import { RegisterPage } from './pages/auth/RegisterPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; @@ -63,6 +60,32 @@ import { VerifyEmailPromptPage } from './pages/auth/VerifyEmailPromptPage'; import { OAuthConsentPage } from './pages/auth/OAuthConsentPage'; import { DeviceAuthPage } from './pages/auth/DeviceAuthPage'; +/* + * Package documentation portal (ADR-0046 section 6), lazy on purpose. + * + * Nothing on a normal console page load visits /docs, and `DocPage` is the + * only console-owned module that reaches `@object-ui/plugin-markdown`. These + * four therefore belong behind a lazy boundary. + * + * `AppContent.tsx` already lazy-imports `DocsLayout` / `DocsSlug` / `DocPage` + * for the app-scoped `/apps/:packageId/docs` tree (ADR-0048). Importing the + * same three STATICALLY here put them in the eager graph anyway, so that + * `import()` moved nothing -- three `INEFFECTIVE_DYNAMIC_IMPORT` warnings on + * every build (objectui#5467). Both sides must stay lazy: a static import on + * either one silently re-defeats the split for BOTH, and the only signal is a + * build warning that fails nothing. + * + * `DocsIndex` is lazy here for the same reason even though it carried no + * warning (`AppContent` renders `AppDocsIndex` at that slot, so nothing + * imported it dynamically). Left static it would keep `DocShell`, + * `use-book-data` and `book-nav` eager on its own and the portal would only + * half-leave the closure. + */ +const DocsLayout = lazy(() => import('./pages/DocsLayout')); +const DocsIndex = lazy(() => import('./pages/DocsIndex')); +const DocsSlug = lazy(() => import('./pages/DocsSlug')); +const DocPage = lazy(() => import('./pages/DocPage')); + const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; /** @@ -240,12 +263,12 @@ export function App() { * coordinate; the book segment is derived nav). */} - + }> }> - } /> - } /> - } /> + }>} /> + }>} /> + }>} /> From f9bbd4144dbdd611b1b6f285d7cf59b4681186d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:27:43 +0000 Subject: [PATCH 2/2] test(console): pin the /docs portal's laziness as a runtime property (objectui#5467) App.docsPortalLazy.test.tsx measures WHEN each docs page enters the module graph, not how App.tsx spells its imports: a vi.mock factory runs on first import, so a static import sets the flag while App.tsx is evaluated, before any test body runs. Two counter-probes keep "never loaded" from passing vacuously -- the still statically imported SharedRecordPage is a live positive control, and visiting /docs must flip the layout's flag and render it through the Suspense boundary. internalFormShell.test.tsx's @object-ui/app-shell mock gains LoadingScreen: App.tsx's route elements are built when App renders, so the Suspense fallback's export is read even by a test that never visits /docs. Without it both of its tests died on the vitest mock proxy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .../src/__tests__/App.docsPortalLazy.test.tsx | 171 ++++++++++++++++++ .../src/__tests__/internalFormShell.test.tsx | 4 + 2 files changed, 175 insertions(+) create mode 100644 apps/console/src/__tests__/App.docsPortalLazy.test.tsx diff --git a/apps/console/src/__tests__/App.docsPortalLazy.test.tsx b/apps/console/src/__tests__/App.docsPortalLazy.test.tsx new file mode 100644 index 0000000000..98e159ef37 --- /dev/null +++ b/apps/console/src/__tests__/App.docsPortalLazy.test.tsx @@ -0,0 +1,171 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#5467 — the console's `/docs` portal must stay OUT of the eager + * module graph. + * + * ## What this measures, and why it is not a source-text check + * + * `AppContent.tsx` lazy-imports `DocsLayout` / `DocsSlug` / `DocPage` for the + * app-scoped `/apps/:packageId/docs` tree (ADR-0048). `App.tsx` used to import + * the same three STATICALLY for the platform portal at `/docs` (ADR-0046 + * section 6), which put them in the eager graph anyway and made that + * `import()` decoration — three `INEFFECTIVE_DYNAMIC_IMPORT` warnings on every + * `vite build`, and a build warning fails nothing. + * + * So the regression this file exists to catch is a static import re-appearing + * in `App.tsx`, and it is asserted as the PROPERTY rather than the spelling: a + * `vi.mock` factory runs the first time its module is imported, so the flags + * below record *when* each page entered the graph. A static import makes the + * page load while `App.tsx` itself is evaluated — before any test body runs — + * and this file goes red. A regex over `App.tsx` would pin one spelling of the + * mistake; this pins the mistake. + * + * ## Counter-probes + * + * Two, because "the page never loaded" is exactly what a flag that can never + * be set looks like. + * + * 1. `SharedRecordPage` is a LIVE positive control: `App.tsx` still imports + * it statically, so its flag must already be set before the first render. + * If the observer stopped seeing static imports, that assertion fails + * instead of the lazy ones passing vacuously. + * 2. Visiting `/docs` must FLIP the layout's flag and render it. A flag that + * is merely stuck at unset cannot satisfy both halves. + * + * `DocsSlug` and `DocPage` sit on deeper routes that `/docs` does not match, + * so they must still be unloaded after the render — the portal is split per + * page, not into one docs bundle. + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { Outlet } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// `vi.hoisted`, not plain consts: every `vi.mock` factory below is hoisted to +// the top of the file, so a factory closing over an ordinary `const` reads it +// before initialization. +const { loaded, passthrough, stub, tracked } = vi.hoisted(() => { + const loaded: Record = {}; + const passthrough = ({ children }: { children?: ReactNode }) => <>{children}; + const stub = (testid: string) => () =>
; + /** A default-export page module that records the moment it is imported. */ + const tracked = (name: string, testid: string) => () => { + loaded[name] = true; + return { default: stub(testid) }; + }; + return { loaded, passthrough, stub, tracked }; +}); + +// ── The four docs pages, plus the statically imported positive control ──── +// The layout renders its `Outlet` so the index child actually mounts — +// otherwise `DocsIndex` would read as "still lazy" for the wrong reason +// (nothing ever asked for it) and the assertion on it would be vacuous. +vi.mock('../pages/DocsLayout', () => { + loaded.DocsLayout = true; + return { + default: () => ( +
+ +
+ ), + }; +}); +vi.mock('../pages/DocsIndex', tracked('DocsIndex', 'docs-index')); +vi.mock('../pages/DocsSlug', tracked('DocsSlug', 'docs-slug')); +vi.mock('../pages/DocPage', tracked('DocPage', 'doc-page')); +vi.mock('../pages/SharedRecordPage', tracked('SharedRecordPage', 'shared-record-page')); + +// ── Everything else App.tsx imports but this card does not touch ────────── +vi.mock('@object-ui/app-shell', () => ({ + ConsoleShell: passthrough, + ConsoleToaster: () => null, + LoadingScreen: stub('loading-screen'), + RequireAiSurface: passthrough, + SystemRedirect: () => null, + DefaultHomeLayout: passthrough, + DefaultHomePage: stub('home-page'), + DefaultOrganizationsLayout: passthrough, + DefaultOrganizationsPage: stub('organizations-page'), + DefaultOrganizationLayout: stub('organization-layout'), + DefaultMembersPage: stub('members-page'), + DefaultInvitationsPage: stub('invitations-page'), + DefaultSettingsPage: stub('settings-page'), + DefaultAcceptInvitationPage: stub('accept-invitation-page'), + DefaultAiChatPage: stub('ai-chat-page'), + StudioDesignSurface: stub('studio-design-surface'), + BuilderLanding: stub('builder-landing'), + getProductName: () => 'ObjectOS', + getFaviconUrl: () => '', +})); + +vi.mock('@object-ui/auth', () => ({ + AuthProvider: passthrough, + useAuth: () => ({ user: { id: 'u1' } }), +})); + +vi.mock('../AppContent', () => ({ AppContent: stub('app-content') })); +vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough })); +vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') })); +vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') })); +vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') })); +vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') })); +vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null })); +vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') })); +vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') })); +vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') })); +vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') })); +vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') })); +vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') })); +vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') })); +vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') })); +vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') })); +vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') })); +vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') })); +vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') })); +vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') })); +vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { App } from '../App'; + +afterEach(() => { + cleanup(); + window.history.pushState({}, '', '/'); +}); + +describe('the /docs portal is genuinely lazy (objectui#5467)', () => { + it('leaves the docs pages out of the graph until a /docs route matches', async () => { + // Counter-probe 1 — a page App.tsx still imports statically. Importing + // `../App` above was enough to load it, which is what "eager" means and + // what the four assertions below deny for the docs portal. + expect(loaded.SharedRecordPage).toBe(true); + + expect(loaded.DocsLayout).toBeUndefined(); + expect(loaded.DocsIndex).toBeUndefined(); + expect(loaded.DocsSlug).toBeUndefined(); + expect(loaded.DocPage).toBeUndefined(); + + window.history.pushState({}, '', '/docs'); + render(); + + // Counter-probe 2 — the flags can be set, and the Suspense boundary the + // lazy elements sit behind actually resolves them. Without it React would + // throw here rather than render the layout. + expect(await screen.findByTestId('docs-layout')).toBeInTheDocument(); + expect(loaded.DocsLayout).toBe(true); + expect(loaded.DocsIndex).toBe(true); + + // Split per page, not into one docs bundle: `/docs` matches neither of + // these deeper routes, so neither may have been pulled in. + expect(loaded.DocsSlug).toBeUndefined(); + expect(loaded.DocPage).toBeUndefined(); + }); +}); diff --git a/apps/console/src/__tests__/internalFormShell.test.tsx b/apps/console/src/__tests__/internalFormShell.test.tsx index 241252c8eb..b85e8e7a08 100644 --- a/apps/console/src/__tests__/internalFormShell.test.tsx +++ b/apps/console/src/__tests__/internalFormShell.test.tsx @@ -56,6 +56,10 @@ const { passthrough, stub } = vi.hoisted(() => ({ vi.mock('@object-ui/app-shell', () => ({ ConsoleShell: passthrough, ConsoleToaster: () => null, + // Suspense fallback for App.tsx's lazy /docs routes (objectui#5467). + // The route elements are built when App renders, so this export is + // read even by a test that never visits /docs. + LoadingScreen: stub('loading-screen'), RequireAiSurface: passthrough, SystemRedirect: () => null, DefaultHomeLayout: ({ children }: { children?: ReactNode }) => (