Skip to content

perf(console): free the route views the app-shell barrel held in the eager closure, and pin the agreement - #6682

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6535-console-lazy-eager-closure
Aug 28, 2026
Merged

perf(console): free the route views the app-shell barrel held in the eager closure, and pin the agreement#6682
os-sales merged 1 commit into
mainfrom
claude/issue-6535-console-lazy-eager-closure

Conversation

@claude

@claudeclaudeBot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes#6535

Re-measured on today's ref, per view

The card was filed on an earlier ref. Re-measured on ece68882 from apps/console/dist/eager-closure.json (files[] IS the eager set) and cross-checked by an independent BFS over the emitted chunks' static imports -- the two walks agree and my BFS reproduces the report's own chunk count exactly (52).

The count of six holds. Its mechanism holds for only three of the six.

declared lazy()eager BEFOREeager AFTERwhy it was eager
DashboardViewyesNObarrel re-export ONLY -- fixed here
PageViewyesNObarrel re-export ONLY -- fixed here
SearchResultsPageyesNObarrel re-export ONLY -- fixed here
RecordDetailViewyesyesreal static edge: views/ObjectView.tsx imports it by name, and ObjectView is in AppContent's own always-needed block
RecordFormPageyesyeschunk co-tenancy with providers/expressionUser.ts
ReportViewyesyeschunk co-tenancy with views/RuntimeDraftBar.tsx
ComponentNavViewnononever re-exported by the barrel
ObjectDataPagenononever re-exported by the barrel

Positive control for the two "no" rows: both views' chunks demonstrably exist on disk (ComponentNavView-Do6FuCnd.js, ObjectDataPage-BwGRgsh1.js) and the identical query returns EAGER for six others, so the zero is a measurement rather than a mis-aimed probe. All eight get their own chunk, so absence from files[] is a real lazy and not a merge artefact.

The barrel correspondence is exact and it is the natural experiment: the six views the barrel re-exports are precisely the six that were eager, and the two it does not are precisely the two that were lazy.

The measured closure, quoted from check:eager-closure's own output

BEFORE (pristine origin/main, forced console vite build):

Console eager closure is 3237.0 KB gzipped across 52 of 508 chunks (budget: 3266.6 KB, headroom: 29.6 KB).

AFTER (this branch, HEAD cc495a53):

Console eager closure is 3231.7 KB gzipped across 49 of 508 chunks (budget: 3266.6 KB, headroom: 34.9 KB).

Delta: -5,367 bytes gzipped, 52 to 49 eager chunks. Headroom 29.6 KB to 34.9 KB. The three chunks that left, and nothing else moved:

-1571 gz DashboardView -1328 gz PageView -2416 gz SearchResultsPage

Read plainly: this is a real, measured move, and it is small. The three views the barrel alone held eager are the three SMALLEST of the six. The 53.8 KB gzipped still on the first-paint path sits behind RecordDetailView (47.4 KB), ReportView (4.1 KB) and RecordFormPage (2.3 KB), and no import spelling reaches any of them. That correction to the card's cost model is the more valuable half of this PR.

What changed

scripts/vite-declared-lazy-views.ts does both halves from ONE parsed list of AppContent's lazy() declarations, so they cannot drift:

  1. It declares the pure route-view modules moduleSideEffects: falsefor the console build only. Without that, the barrel's named re-exports are unshakeable, because @object-ui/app-shell publishes no sideEffects field and every bundler must therefore assume every module in the re-export chain might do something on import.
  2. It then FAILS the build when a declared-lazy view is in the eager closure and not pinned, or when a pinned one has quietly become lazy.

enforce: 'pre' is load-bearing and is commented as such: vite runs core plugins before normal-order ones and resolveId is first-wins, so at normal order the hook never runs at all and the declaration is silently inert. That is not hypothetical -- it happened on this branch, and it reads exactly like "the fix does not work" rather than "the hook never ran".

Deliberately NOT done: "sideEffects" on @object-ui/app-shell

That is the general fix and it moves far more -- measured on this branch, "sideEffects": false on the package takes the closure to 2994.4 KB (headroom 272.2 KB). It is not shippable, and the reason is measured rather than argued: it silently drops three real SDUI widget registrations from the bundle.

registration keywith sideEffects: falseon this PR
mcp:connect-agent0 chunks1 chunk
cloud:onboarding-next0 chunks1 chunk
cloud:ai-model-status0 chunks1 chunk
marketplace:installed-list1 chunk1 chunk
record:attachments3 chunks3 chunks
record:approvals2 chunks2 chunks
metadata:directory2 chunks2 chunks

An incomplete sideEffects ARRAY would do the same to third-party embedders with nothing to catch it. Declaring the package's published build contract is a maintainer decision, not this card's repair -- raised as an open question in the report. The table's right-hand column is also this PR's own control: the narrow change does not reproduce the hazard.

The pin, and what makes it bite

The ledger DECLARED_LAZY_VIEWS_STILL_EAGER fails the build in BOTH directions, because the dangerous reading here is ZERO, not many:

  • unpinned -- a declared-lazy view found eager that the ledger does not know about. New regression, build stops, view named, plus the chunk holding it and the eager chunks importing that chunk (the edge is frequently not a source-level import at all, so naming only the view would leave the reader to rebuild the graph by hand).
  • missing -- a pinned view that is no longer eager. Either someone fixed the edge and must record the win, or the walk has gone blind.

Two counter-probes guard the walk itself: every declared view must be found in SOME chunk (a matcher that matches nothing cannot fail), and views/ObjectView.tsx -- eager by construction -- must be found EAGER. A third guard refuses to declare a view side-effect-free if its source carries a bare side-effect import, so the complement rule cannot silently turn a future view's registration into a dropped one.

Ablation proving it bites, on this exact tree. Neutralising the moduleSideEffects declaration (one injected early-return; mutation confirmed on disk by blob hash 9a6d2c10 vs HEAD 69de27ca, restored and re-verified byte-identical afterwards) makes the build fail with exit 1:

[declared-lazy-views] 3 view(s) that AppContent declares with `lazy()` are in the EAGER closure ...
+ packages/app-shell/src/views/DashboardView.tsx -- in eager chunk `assets/DashboardView-C_bmemyP.js`, statically imported by assets/index-JlP1yM48.js
+ packages/app-shell/src/views/PageView.tsx -- in eager chunk `assets/PageView-i_B3md5R.js`, statically imported by assets/index-JlP1yM48.js
+ packages/app-shell/src/views/SearchResultsPage.tsx -- in eager chunk `assets/SearchResultsPage-D12XvDyg.js`, statically imported by assets/index-JlP1yM48.js

Those chunk hashes are byte-identical to the pristine origin/main build, so the ablated tree reproduces the baseline exactly -- the guard catches precisely the three views this PR frees, and nothing else.

Gate verdicts, each exit code captured BEFORE any pipe

All run on HEAD cc495a53 after the final commit.

gateexitits own verdict line
check:eager-closure0Console eager closure is 3231.7 KB gzipped across 49 of 508 chunks (budget: 3266.6 KB, headroom: 34.9 KB).
console vite build0[declared-lazy-views] 5/8 views AppContent declares lazy are genuinely lazy; 3 eager, all pinned (objectui#6535).
check:changeset-presence0No source of a released package changed in this range, so no changeset is owed. (3 files changed, 0 published source)
check:control-bytes0check-control-bytes: OK (scanned 5517 tracked text file(s); skipped 85 binary).
check:entry-guard050 scripts/ file(s) -- no entry guard outside the baseline; 45 export bindings, 45 of them inert on import
check:vi-mock-specifiers0OK (3890 tracked source file(s), 2186 test-named; ...)
check:esm-specifiers0no un-ledgered package emits an extensionless relative specifier.
check:self-import0No package names itself inside its own src/.
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:docs-route-closure0ran clean on the same build
@object-ui/consoletype-check0tsc --noEmit && tsc -b tsconfig.node.json --force (its tsconfig.node.json includes ../../scripts/vite-*.ts, so the new plugin IS type-checked)
vitest scripts/__tests__ + console-starter alias closure0Test Files 85 passed (85) / Tests 2386 passed (2386)
vitest new suite0Test Files 1 passed (1) / Tests 18 passed (18)
eslint (narrowed, see below)00 errors, 0 warnings across 3 files

No changeset: the gate's own verdict line above is the authority -- nothing under a released package's published source changed.

Declared narrowing -- eslint

Repo-wide turbo run lint was NOT run; eslint was run on the three changed files only. The three things that make that a measurement rather than a gap:

  1. Population read from the tool's own config, not asserted. Each of the three files was accepted by eslint's own resolution and returned a real result object -- none was reported as ignored.
  2. Count read from --format json: 3 files linted, errorCount 0 and warningCount 0 on each.
  3. Config invariance for untouched files:eslint.config.js configures no projectService, no project, and no type-checked ruleset, so no rule's verdict on a file this PR does not touch can depend on this diff.

CI runs the full farm regardless.

Findings filed, not fixed here

Fences respected

packages/app-shell/src/providers/** (#6559), packages/plugin-grid (#6670), packages/types and packages/components/src/renderers/complex/data-table.tsx (#6673), packages/sdui-parser and packages/plugin-list (#6598), packages/i18n (#6610), examples/schema-catalog (#3965) -- none touched. The diff is 3 files: one new plugin, one new test, and the console vite config.

packages/app-shell/src/index.ts is NOT modified -- the barrel's exports remain the package's public API for third-party embedders, exactly as triage required.


Generated by Claude Code

…eager closure
Six of the eight views `AppContent` declares with `lazy()` were in the console's
eager closure anyway, so the browser fetched and parsed them before first render
whatever the route. Re-measured on today's ref, the count of six holds and its
mechanism holds for only three of them:
- `DashboardView`, `PageView`, `SearchResultsPage` were eager ONLY because
`packages/app-shell/src/index.ts` re-exports them and the console's entry
imports that barrel statically. Tree-shaking cannot drop those re-exports
because `@object-ui/app-shell` publishes no `sideEffects` field.
- `RecordDetailView` is eager for a real reason: `views/ObjectView.tsx`
imports it by name and `ObjectView` is in AppContent's always-needed block.
- `RecordFormPage` and `ReportView` are eager through CHUNK CO-TENANCY --
rolldown emits each in a chunk it shares with a module that is eagerly used
(`providers/expressionUser.ts`, `views/RuntimeDraftBar.tsx`). No import
spelling repairs those; they are pinned with the co-tenant named.
`scripts/vite-declared-lazy-views.ts` does both halves from one parsed list, so
they cannot drift: it declares the pure route views `moduleSideEffects: false`
for the console build only, and then fails the build when a declared-lazy view
is eager and unpinned, or when a pinned one has quietly gone lazy.
Deliberately NOT done: adding `"sideEffects"` to `packages/app-shell/package.json`.
Measured on this branch, `"sideEffects": false` there moves far more but silently
drops three real SDUI widget registrations (`mcp:connect-agent`,
`cloud:onboarding-next`, `cloud:ai-model-status`), and an incomplete array would
do the same to third-party embedders with nothing to catch it. That is a
published-contract decision, not this change.
Measured by `pnpm check:eager-closure` from a console `vite build`:
3237.0 KB -> 3231.7 KB gzipped (-5,367 bytes; 52 -> 49 eager chunks of 508).
@os-sales
os-sales marked this pull request as ready for review August 28, 2026 14:10
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 49 chunks)3231.7 KB3266.6 KB
Main entry chunk (gzip)157.2 KB350 KB
Entry fileindex-t481W9Ee.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)509.24KB115.61KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)239.05KB60.06KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.43KB34.48KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.01KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.51KB54.54KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.44KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

@os-sales