Skip to content

Commit 5d69133

Browse files
perf(console): load the Studio builder and the chart engine with their routes, not on every page (objectui#11798) (#11852)
Refs #11798 (item: Studio and the chart engine) Clause-②: no The console's first authenticated load no longer fetches the Studio builder or the chart engine. Measured below on `main` `455c6466` and on this branch by the same method: 336 → 333 JS chunks, 12,316,677 → 11,572,609 decoded bytes (−6.0%), 3,610,637 → 3,418,259 gzipped (−192,378). The flow designer's canvas could not be moved from inside `apps/console` (why, and the decision it needs, under "Not in this PR"), so this PR is `Part of`, not a closing reference: that half stays open on the card. ## What changed - **Studio loads with its route.** `StudioRoute.tsx` and `registerStudioComponents.tsx` reach `StudioDesignSurface` and `BuilderLanding` through `import('./studioBuilder')` behind `lazy()`. `apps/console/src/components/studioBuilder.ts` is a console-local module that re-exports the two from `@object-ui/app-shell`, and nothing imports it statically. No package entry or `exports` subpath is added: once no static importer names the two components, the bundler leaves them out of the eager set even though the barrel re-exports them. The entry gate (`StudioRoute` then `RequireStudioAccess`) is unchanged and still decides before the chunk is requested. Fallbacks: the full-screen pillar builder shows `LoadingFallback`, the same splash as the gate's pending state; the `/studio` landing and the `studio:builder` entry show a one-line `common.loading` status inside their frame (`role="status"`). - **Charts load with the first chart.** The chart engine was eager because of a chunk rule, not an import. `vendor-charts` captured `use-sync-external-store/shim` (a recharts dependency through `react-redux`) by rolldown's default recursive capture, `react-i18next`'s `useTranslation` imports that shim on every page, and so the eager `vendor-i18n` chunk imported `vendor-charts`. `vendor-react` now claims `use-sync-external-store`, and `vendor-charts` keeps its default capture. - **The eager-closure ledger moves with the bytes.** In `scripts/check-eager-closure-budget.mjs`, `MAX_EAGER_CLOSURE_GZIP_BYTES` goes from 3,637,000 to 3,444,622, down by exactly the 192,378 bytes that left, which keeps the 26,363 bytes (0.29x) of headroom `main` had. `BASELINE` is re-pinned to `c1d378ec` (3,418,259 across 333 of 2456 chunks) in the same commit; the record is the new objectui#11798 entry on that constant. Without the re-pin, `pnpm check:eager-closure` exits 2 on this branch (the aggregate ceiling stood 2.40x the 89 KB regression above the payload). The unit test's rendered-baseline literal moves from 3506.9 to 3338.1. No per-chunk row moved. - The branch's first commit used `includeDependenciesRecursively: false` on `vendor-charts` instead. A browser control caught it: recharts' other dependencies fell into `plugin-charts`, the two chunks imported each other, and a `chart` node rendered "Failed to load plugin: A is not a function" (build of `8cc10b9`). It is replaced, and the reason is recorded beside the `vendor-react` group. ## Acceptance notes ### First authenticated load, before and after Method, the same on both sides: Playwright Chromium (`/opt/pw-browsers/chromium`) at 1440x900, a cold context with a signed-in session token, the built console served statically, the API answered by a scratch mock (one admin, one organization, one default app whose home is an object list), landing on `/` and redirected to `/apps/demo_app/account`. Every `.js` response is counted and its decoded body summed; each reading was repeated, identically. `main` was built in this worktree with this branch's three bundler inputs checked out from `455c6466` (blob hashes compared) and `studioBuilder.ts` removed. | | `main` `455c6466` | this branch | delta | |---|---:|---:|---:| | JS chunks fetched | 336 | 333 | −3 | | decoded bytes | 12,316,677 | 11,572,609 | −744,068 (−6.0%) | | eager closure, gzipped | 3,610,637 | 3,418,259 | −192,378 | | eager chunks of all chunks | 336 of 2452 | 333 of 2456 | | The fetched set equals the build's eager closure on both sides, in count and in raw bytes. The chunk count barely moves because 280 (`main`) and 276 (branch) of those chunks are single-icon `vendor-icon-*` chunks; bytes are the measure. The filer's 336 chunks and ~11.7 MB reproduce on `main`. Per half, each reverted alone on the final tree and built the same way: the chart half is +158,535 gzipped / +583,407 decoded, the Studio half +33,928 / +160,671. Most of `index`'s Studio loss (−72,913 gzipped) is offset by a new `studioScope` chunk (+40,375): 49 modules `index` held on `main` (the app header and the AI chat page among them), split out because the lazy builder imports them too. That is re-attribution, not new payload. ### `pnpm check:eager-closure` - `main`, with its own constants: exit 0, 3526.0 KB of 3551.8 KB, headroom 25.7 KB. - This branch before the re-pin: exit 2, aggregate ceiling DRIFTED 2.40x the regression above the payload. - This branch after the re-pin, on the ordinary console build of `f95872a`: exit 0, 3338.1 KB of 3363.9 KB, headroom 25.7 KB = 0.29x. ### H1 to H3, the dispatch's mechanism hypotheses - **H1, corrected.** Not the `App.tsx` barrel import as such: the NAMED static imports of `StudioDesignSurface` and `BuilderLanding` from the barrel, in `StudioRoute.tsx` and `registerStudioComponents.tsx`, kept them in the entry chunk. The flow canvas is eager through a different barrel edge: app-shell's package entry imports `views/metadata-admin/register-builtins.ts` for its side effects, which reaches `previews/FlowPreview.tsx` and `previews/FlowCanvas.tsx` (chunk `src`, eager on both sides). Of the "Monaco wiring", only `@monaco-editor/react` and `@monaco-editor/loader` are eager (11 modules, 18,560 rendered bytes, through app-shell's `useMonacoFallback.ts`); `@xyflow` is not in the bundle. - **H2, falsified for the heavy plugins.** `register-plugins.ts` imports grid, form, view, list and detail eagerly; charts, dashboard, map, gantt, markdown, calendar, timeline and tree are `registerLazy` already, and `plugin-charts` and `plugin-dashboard` source have 0 eager modules on both sides. - **H3, confirmed for the chart engines.** The `vendor-charts` rule alone made 578 modules (1,305,476 rendered bytes) eager while none of them was statically reachable. One more instance of the class remains, `vendor-markdown`, under "Not in this PR". ### Controls, in the browser, on the ordinary build of `f95872a` with the same mock plus fixtures - A chart page (`/apps/demo_app/page/chart_page`, one `chart` bar node with inline data): an SVG `.recharts-surface` with its axis labels, `vendor-charts` and `plugin-charts` fetched on demand, no page error. Same on `main`. - A flow (`/apps/demo_app/metadata/flow/f1`): the flow canvas renders both node cards. Same on `main`. - Studio: `/studio` renders the landing under its header and fetches `studioBuilder`; `/studio/~org/automations` renders the pillar builder and fetches `studioBuilder`; no page error. ### Pins and reverse checks - `apps/console/src/components/StudioRoute.lazyBuilder-11798.test.tsx`: a refused principal never evaluates the builder module; the pending window holds it back too; a holder gets it once; the landing renders in its frame from the same module. - `apps/console/src/__tests__/registerStudioComponents.builderLazy-11798.test.tsx`, renamed from `registerStudioComponents.builderDirect.test.tsx`, whose objectui#5486 assertion (the builder on the first commit) is now the wrong property: `studio:builder` is registered at boot with nothing of the builder evaluated, and its first commit shows the loading status, then the landing. - The bundle half is pinned by `check:eager-closure`'s re-pinned ceiling. No new gate. - Reverse, unit: with the Studio source reverted to `main`, predicted 3 failed and 2 passed; observed `Tests 3 failed | 2 passed (5)`. The restore is proven by an empty `git diff HEAD` and matching blob hashes. - Reverse, gate: the re-pinned `check:eager-closure` on the reverted builds, predicted exit 1 each; observed chart half reverted 3493.0 KB (129.1 KB over), Studio half reverted 3371.3 KB (7.4 KB over), both reverted 3526.0 KB (162.1 KB over). The final tree exits 0. ### Gates run on `f95872a` - `pnpm exec turbo run build --filter=@object-ui/console... --concurrency=2`: 35 of 35 tasks, exit 0 (the console's own `tsc`, `vite build`, manifest and plugin build). - `pnpm check:eager-closure`: exit 0, above. `pnpm check:eager-locale-catalogues`: exit 0. - `pnpm exec vitest run` over 52 files that name the touched files (the console's App, AppContent, StudioRoute, registration, register-plugins and loadLocales tests, the two new pins, and every `scripts/__tests__` file that reads `check-eager-closure-budget.mjs` or a vite config): 52 files, 1723 tests passed. - `pnpm --filter @object-ui/console type-check`: exit 0; its program includes both new test files. - `check:control-bytes`, `check:new-line-citations`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:unreferenced-sources`, `check:shell-escape-residue`, `check:entry-guard`, `scripts/check-changeset-presence.mjs` and `scripts/check-changeset-no-major.mjs`: exit 0 each. - The repo-wide `pnpm lint` and the full test farm are CI's. ## Not in this PR - **The flow canvas, and with it every metadata-admin designer.** `@object-ui/app-shell`'s package entry imports `views/metadata-admin/register-builtins.ts` for its side effects, and that leaf statically reaches the previews and inspectors: on this branch 162 metadata-admin modules (1,807,259 rendered bytes) are eager, the flow designer's 32 among them (292,151). A console-side `import()` cannot move a side-effect import inside a package entry; only an app-shell change (lazy registration there) or a new subpath export could, and this claim allows neither. Reported on the card as a decision. - **`vendor-markdown`**, the same chunk-rule class as the charts: 53 of its modules sit in the eager closure without being statically reachable. Reported on the card for the seat. - `plugin-chatbot` is eager through `SharedRecordPage` and app-shell's AI hooks: a static import, not a chunk rule. Noted only. Changeset: `.changeset/11798-console-eager-closure.md`, `'@object-ui/console': patch` (the console is in the fixed release group and ships `dist`). Implemented by Claude Code in session `https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e7dd56 commit 5d69133

11 files changed

Lines changed: 602 additions & 203 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
The console's first page load no longer downloads the Studio builder or the chart engine (objectui#11798).
6+
7+
- **Studio loads with its route.** `StudioDesignSurface` and `BuilderLanding`, and the modules only they use (the pillar panels and the form designer), were in the bundle every console page fetched before it rendered, for every user, including the many who can never enter Studio. They now load when a Studio screen is first opened: the `/studio` routes and the `studio:builder` app component both reach them through a console-local module that nothing imports statically. The Studio entry gate is unchanged and still decides first, so a principal without `studio.access` never downloads the builder. While the builder loads, the full-screen pillar builder shows the console's loading splash, and the `/studio` landing and the `studio:builder` entry show a loading line inside their frame, announced to assistive technology as a status.
8+
- **Charts load with the first chart.** Recharts and d3 were already imported only on demand, but the console's `vendor-charts` chunk group also claimed their dependencies, among them `use-sync-external-store`, a small React shim that the translation runtime imports on every page. That one shared module made the whole chart engine part of the first page load. The `vendor-react` group now owns the shim, so the chart chunk holds nothing the first page needs and loads when the first chart renders.
9+
10+
The flow designer's canvas is unchanged here: `@object-ui/app-shell` registers its metadata designers, the flow canvas among them, when the package loads, so the console cannot defer it.
11+
12+
Nothing is added to or removed from any package entry, and no route, registry key or translation key changes.

‎apps/console/src/__tests__/registerStudioComponents.builderDirect.test.tsx‎

Lines changed: 0 additions & 122 deletions
This file was deleted.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11798 — `studio:builder` is registered at boot, and the builder it
5+
* renders loads only when that entry is first rendered.
6+
*
7+
* ## What this replaces, and why its old assertion is now the wrong one
8+
*
9+
* objectui#5486 pinned the opposite: that the builder rendered on the FIRST
10+
* commit, with no Suspense boundary in front of it. That was right for its
11+
* tree. The registration then wrapped `import('@object-ui/app-shell')`, the
12+
* barrel this file and `App.tsx` imported statically, so the `lazy()` deferred
13+
* nothing and only bought a fallback no user could see. objectui#5486 made the
14+
* registration truthful and left genuine laziness to a separate measured card.
15+
*
16+
* objectui#11798 is that card. The registration now reaches `BuilderLanding`
17+
* through `./components/studioBuilder`, a console-local module nothing imports
18+
* statically, so the boundary is real: the builder's chunk leaves the eager
19+
* closure (measured on the built console, see the PR), and the entry shows a
20+
* loading line until it arrives. The property worth pinning therefore flips,
21+
* and it is pinned the same way objectui#5486 pinned its own, by what React
22+
* commits rather than by the source's spelling.
23+
*
24+
* ## The two halves
25+
*
26+
* - REGISTRY: importing this module registers `studio:builder` and evaluates
27+
* nothing of the builder. `ComponentNavView` finds the entry the moment the
28+
* console boots, exactly as before.
29+
* - RENDER: the entry commits its loading line first, then the landing once
30+
* the module resolves. A boundary that deferred nothing (the objectui#5486
31+
* shape) commits the landing on the first commit and reds the first half.
32+
*/
33+
34+
import '@testing-library/jest-dom/vitest';
35+
import { describe, it, expect, vi } from 'vitest';
36+
import { render, screen } from '@testing-library/react';
37+
import type { ComponentType, ReactNode } from 'react';
38+
39+
type Registration = { ref: string; label?: string; source?: string; component: ComponentType };
40+
41+
const { registrations, builderModuleLoads } = vi.hoisted(() => ({
42+
registrations: [] as Registration[],
43+
builderModuleLoads: { count: 0 },
44+
}));
45+
46+
// Mocked at the specifier the module under test imports, so the entry exercised
47+
// below is the production registration itself. The real barrel's surface is
48+
// inherited (`check:vi-mock-inherit`); only the names this file's graph renders
49+
// or records are overridden.
50+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
registerAppComponent: (entry: Registration) => {
53+
registrations.push(entry);
54+
},
55+
// The barrel's own builder, as a marker: the entry must reach the landing
56+
// only through the deferred module below, so registering this one instead
57+
// commits the marker with no loading line and reds the render case.
58+
BuilderLanding: () => <div data-testid="barrel-builder" />,
59+
// `StudioRoute.tsx` is imported for its shared loading line; these are the
60+
// other names its module graph reads from the barrel.
61+
ConnectedShell: ({ children }: { children?: ReactNode }) => <>{children}</>,
62+
RequireOrganization: ({ children }: { children?: ReactNode }) => <>{children}</>,
63+
RedirectWithSplash: () => null,
64+
LoadingFallback: () => null,
65+
LoadingScreen: () => null,
66+
STUDIO_ORG_SCOPE_SEGMENT: '~org',
67+
STUDIO_ORG_SCOPE_PILLAR: 'automations',
68+
getProductName: () => 'ObjectOS',
69+
useHomePath: () => '/home',
70+
}));
71+
72+
// The deferred module. Its factory runs when the module is first imported, so
73+
// the count is the unit-level image of "the builder's chunk was fetched".
74+
vi.mock('../components/studioBuilder', () => {
75+
builderModuleLoads.count += 1;
76+
return {
77+
BuilderLanding: () => <div data-testid="builder-landing" />,
78+
StudioDesignSurface: () => null,
79+
};
80+
});
81+
82+
// Side-effect import, exactly as `main.tsx` performs it.
83+
import '../registerStudioComponents';
84+
85+
function entryFor(ref: string): Registration {
86+
const found = registrations.find((r) => r.ref === ref);
87+
if (!found) throw new Error(`no registration for ${ref}; saw: ${registrations.map((r) => r.ref).join(', ') || '(none)'}`);
88+
return found;
89+
}
90+
91+
describe('studio:builder registration (objectui#11798)', () => {
92+
it('is registered at boot, and registering evaluates nothing of the builder', () => {
93+
const entry = entryFor('studio:builder');
94+
expect(entry.source).toBe('@object-ui/console');
95+
expect(entry.label).toBe('应用构建');
96+
expect(builderModuleLoads.count).toBe(0);
97+
});
98+
99+
it('commits a loading line first, then the landing once its module resolves', async () => {
100+
const Builder = entryFor('studio:builder').component;
101+
render(<Builder />);
102+
103+
// No `await`: the first commit. A boundary that deferred nothing would have
104+
// committed the landing here, which is the objectui#5486 shape.
105+
// The fallback is found by its role, not by its copy: the words are the
106+
// translation catalogue's, and this file pins only that a wait is shown.
107+
expect(screen.queryByTestId('builder-landing')).not.toBeInTheDocument();
108+
expect(screen.getByRole('status')).toBeInTheDocument();
109+
110+
expect(await screen.findByTestId('builder-landing')).toBeInTheDocument();
111+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
112+
expect(builderModuleLoads.count).toBe(1);
113+
});
114+
});

0 commit comments

Comments
 (0)