diff --git a/.changeset/console-lazy-docs-portal-5467.md b/.changeset/console-lazy-docs-portal-5467.md new file mode 100644 index 000000000..9b44325e8 --- /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 8530096e0..68d115b33 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). */} - + }> }> - } /> - } /> - } /> + }>} /> + }>} /> + }>} /> 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 000000000..98e159ef3 --- /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 241252c8e..b85e8e7a0 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 }) => (