Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/console-lazy-docs-portal-5467.md
Original file line numberDiff line numberDiff line change
@@ -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%.
41 changes: 32 additions & 9 deletions apps/console/src/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@
* with extra `<Route>` 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';
Expand All@@ -25,6 +25,7 @@ import {
RequireAiSurface,
SystemRedirect,
ConsoleToaster,
LoadingScreen,
DefaultHomeLayout,
DefaultHomePage,
DefaultOrganizationsLayout,
Expand All@@ -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';
Expand All@@ -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`;

/**
Expand DownExpand Up@@ -240,12 +263,12 @@ export function App() {
* coordinate; the book segment is derived nav). */}
<Route path="/docs" element={
<ProtectedRoute>
<DocsLayout />
<Suspense fallback={<LoadingScreen />}><DocsLayout /></Suspense>
</ProtectedRoute>
}>
<Route index element={<DocsIndex />} />
<Route path=":slug" element={<DocsSlug />} />
<Route path=":slug/:name" element={<DocPage />} />
<Route index element={<Suspense fallback={<LoadingScreen />}><DocsIndex /></Suspense>} />
<Route path=":slug" element={<Suspense fallback={<LoadingScreen />}><DocsSlug /></Suspense>} />
<Route path=":slug/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
</Route>
<Route path="/home" element={
<ProtectedRoute>
Expand Down
171 changes: 171 additions & 0 deletions apps/console/src/__tests__/App.docsPortalLazy.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, boolean> = {};
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
const stub = (testid: string) => () => <div data-testid={testid} />;
/** 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: () => (
<div data-testid="docs-layout">
<Outlet />
</div>
),
};
});
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(<App />);

// 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();
});
});
4 changes: 4 additions & 0 deletions apps/console/src/__tests__/internalFormShell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }) => (
Expand Down
Loading