From cc495a53ad78f67043494644fe18e2aa19ad8395 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:53:52 +0000 Subject: [PATCH] perf(console): free the route views the app-shell barrel held in the 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). --- apps/console/vite.config.ts | 10 + .../vite-declared-lazy-views.test.ts | 202 ++++++++ scripts/vite-declared-lazy-views.ts | 476 ++++++++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 scripts/__tests__/vite-declared-lazy-views.test.ts create mode 100644 scripts/vite-declared-lazy-views.ts diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 9386c0c29a..43aae93be9 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -19,6 +19,7 @@ import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; import { resolveClientDistInjection } from '../../scripts/vite-objectstack-client-dist.ts'; import { resolveSpecDistInjection } from '../../scripts/vite-objectstack-spec-dist.ts'; import { viteIneffectiveDynamicImports } from '../../scripts/vite-ineffective-dynamic-imports.ts'; +import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts'; import { compression } from 'vite-plugin-compression2'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -645,6 +646,15 @@ export default defineConfig({ // firing — the counter-probe, because a build that dies before chunk // assignment reports zero of these and that reads exactly like "fixed". viteIneffectiveDynamicImports(), + // Keeps `AppContent`'s `lazy()` view declarations and the eager closure in + // agreement (objectui#6535). Two halves, one parsed list: it declares the + // pure route-view modules `moduleSideEffects: false` — without which the + // `@object-ui/app-shell` barrel's named re-exports hold five of them in the + // eager closure, since that package publishes no `sideEffects` field — and + // it then FAILS the build if a declared-lazy view is eager anyway and not + // pinned, or if a pinned one has quietly become lazy. Runs before + // `emitEagerClosureReport` reads the same graph. + viteDeclaredLazyViews(), // maplibre-gl loads its worker as a sibling of its own chunk URL — an // edge no bundler can see — so the worker (and the shared module it // imports) must be copied into assets/ or every map page 404s diff --git a/scripts/__tests__/vite-declared-lazy-views.test.ts b/scripts/__tests__/vite-declared-lazy-views.test.ts new file mode 100644 index 0000000000..709e3b13b6 --- /dev/null +++ b/scripts/__tests__/vite-declared-lazy-views.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + APP_CONTENT_PATH, + DECLARED_LAZY_VIEWS_STILL_EAGER, + EAGER_WALK_CONTROL, + bareSideEffectImport, + diffDeclaredLazyViews, + formatDeclaredLazyViewFailure, + parseDeclaredLazyViews, +} from '../vite-declared-lazy-views.ts'; + +/** + * objectui#6535 — six of the eight views `AppContent` declares with `lazy()` + * were in the console's EAGER closure anyway. This file tests the POLICY half: + * what is parsed out of AppContent, what may honestly be declared + * side-effect-free, and which direction of ledger drift fails. + * + * The GRAPH half — which module ids rolldown actually reports, and whether + * `moduleSideEffects` is honoured — is only exercisable by a real console + * build, exactly as `scripts/check-eager-closure-budget.mjs` splits policy from + * `emitEagerClosureReport`'s walk in `apps/console/vite.config.ts`. + */ + +const REPO_ROOT = path.resolve(import.meta.dirname, '../..'); +const read = (repoRelative: string) => fs.readFileSync(path.join(REPO_ROOT, repoRelative), 'utf8'); + +describe('DECLARED_LAZY_VIEWS_STILL_EAGER', () => { + it('is sorted and free of duplicates', () => { + const entries = [...DECLARED_LAZY_VIEWS_STILL_EAGER]; + expect(entries).toEqual([...new Set(entries)]); + expect(entries).toEqual([...entries].sort()); + }); + + it('names files that exist', () => { + // A ledger entry pointing at a deleted module would otherwise fail the + // build as a `missing` sighting, which is a confusing way to learn a view + // was renamed. + for (const entry of DECLARED_LAZY_VIEWS_STILL_EAGER) { + expect(fs.existsSync(path.join(REPO_ROOT, entry)), entry).toBe(true); + } + }); + + it('only pins views that AppContent actually declares lazy', () => { + const declared = parseDeclaredLazyViews(read(APP_CONTENT_PATH)); + for (const entry of DECLARED_LAZY_VIEWS_STILL_EAGER) { + expect(declared, `${entry} is pinned but no longer declared lazy`).toContain(entry); + } + }); +}); + +describe('parseDeclaredLazyViews', () => { + it('finds the eight route views AppContent declares, resolved to real files', () => { + const declared = parseDeclaredLazyViews(read(APP_CONTENT_PATH)); + // The count is the measurement objectui#6535 was filed on. It is asserted + // rather than merely observed because a matcher that silently finds fewer + // makes every check downstream of it pass vacuously. + expect(declared).toHaveLength(8); + for (const view of declared) { + expect(fs.existsSync(path.join(REPO_ROOT, view)), view).toBe(true); + } + expect(declared).toContain('packages/app-shell/src/views/ObjectDataPage.tsx'); + expect(declared).toContain('packages/app-shell/src/views/ComponentNavView.tsx'); + }); + + it('ignores the lazy() declarations that are not single-file route views', () => { + // AppContent also lazily imports a directory barrel, a sibling directory + // and a package. Sweeping those in would widen the ledger to modules whose + // eager-closure story nobody has measured. + const declared = parseDeclaredLazyViews( + [ + "const A = lazy(() => import('../views/Alpha.js').then(m => ({ default: m.Alpha })));", + "const B = lazy(() => import('../views/metadata-admin/index.js').then(m => ({ default: m.B })));", + "const C = lazy(() => import('./marketplace/MarketplacePage.js').then(m => ({ default: m.C })));", + "const D = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.D })));", + ].join('\n'), + (p) => p === 'packages/app-shell/src/views/Alpha.tsx', + ); + expect(declared).toEqual(['packages/app-shell/src/views/Alpha.tsx']); + }); + + it('resolves the NodeNext .js specifier to the real .tsx file rather than guessing', () => { + const seen: string[] = []; + const declared = parseDeclaredLazyViews( + "const A = lazy(() => import('../views/Alpha.js'));", + (p) => { + seen.push(p); + return p.endsWith('.ts'); + }, + ); + expect(seen).toEqual([ + 'packages/app-shell/src/views/Alpha.tsx', + 'packages/app-shell/src/views/Alpha.ts', + ]); + expect(declared).toEqual(['packages/app-shell/src/views/Alpha.ts']); + }); + + it('returns nothing when the declarations are re-spelled — the counter-probe case', () => { + expect(parseDeclaredLazyViews('const A = lazyLoad("../views/Alpha.js");')).toEqual([]); + }); +}); + +describe('bareSideEffectImport', () => { + it('catches a bare side-effect import, which makes a purity claim false', () => { + expect(bareSideEffectImport("import './record-approvals-renderer.js';\n")).toBe( + "import './record-approvals-renderer.js';", + ); + }); + + it('does not mistake a named or default import for one', () => { + expect(bareSideEffectImport("import { RecordDetailView } from './x.js';\n")).toBeNull(); + expect(bareSideEffectImport("import React from 'react';\n")).toBeNull(); + }); + + it('agrees with the real sources: every unpinned declared view is pure', () => { + // The plugin declares `moduleSideEffects: false` for exactly the declared + // views it has NOT pinned, so this is the claim it makes about this repo. + const pinned = new Set(DECLARED_LAZY_VIEWS_STILL_EAGER); + const declared = parseDeclaredLazyViews(read(APP_CONTENT_PATH)); + const unpinned = declared.filter((view) => !pinned.has(view)); + expect(unpinned.length).toBeGreaterThan(0); + for (const view of unpinned) { + expect(bareSideEffectImport(read(view)), view).toBeNull(); + } + }); + + it('agrees with the real source that RecordDetailView is NOT pure', () => { + // The positive control for the assertion above: a source that DOES carry + // one, so a matcher that had stopped matching could not pass both. + expect(bareSideEffectImport(read('packages/app-shell/src/views/RecordDetailView.tsx'))).toBe( + "import './record-approvals-renderer.js';", + ); + }); +}); + +describe('EAGER_WALK_CONTROL', () => { + it('is a real file that AppContent imports STATICALLY', () => { + // The plugin's counter-probe 2 asserts this module is eager. That only + // means anything while AppContent still imports it outside a `lazy()`. + expect(fs.existsSync(path.join(REPO_ROOT, EAGER_WALK_CONTROL))).toBe(true); + const appContent = read(APP_CONTENT_PATH); + expect(appContent).toContain("import { ObjectView } from '../views/ObjectView.js';"); + expect(parseDeclaredLazyViews(appContent)).not.toContain(EAGER_WALK_CONTROL); + }); +}); + +describe('diffDeclaredLazyViews', () => { + const pinned = ['packages/app-shell/src/views/RecordDetailView.tsx']; + + it('is clean when the eager set is exactly the ledger', () => { + const diff = diffDeclaredLazyViews(pinned, pinned); + expect(diff).toEqual({ unpinned: [], missing: [] }); + expect(formatDeclaredLazyViewFailure(diff)).toBeNull(); + }); + + it('reports a NEW eager view as unpinned', () => { + const diff = diffDeclaredLazyViews([...pinned, 'packages/app-shell/src/views/PageView.tsx'], pinned); + expect(diff.unpinned).toEqual(['packages/app-shell/src/views/PageView.tsx']); + expect(diff.missing).toEqual([]); + expect(formatDeclaredLazyViewFailure(diff)).toContain('views/PageView.tsx'); + }); + + it('reports a pinned view that stopped being eager as missing — the counter-probe', () => { + // The dangerous reading is ZERO. A walk that has gone blind reports no + // eager views at all, which without this half is indistinguishable from + // "someone fixed them". + const diff = diffDeclaredLazyViews([], pinned); + expect(diff.unpinned).toEqual([]); + expect(diff.missing).toEqual(pinned); + const message = formatDeclaredLazyViewFailure(diff); + expect(message).toContain('NO LONGER in the eager closure'); + expect(message).toContain('gone blind'); + }); + + it('defaults to the shipped ledger', () => { + expect(diffDeclaredLazyViews(DECLARED_LAZY_VIEWS_STILL_EAGER)).toEqual({ + unpinned: [], + missing: [], + }); + }); +}); + +describe('formatDeclaredLazyViewFailure', () => { + it('carries the per-view explanation so the reader does not rebuild the graph by hand', () => { + const diff = diffDeclaredLazyViews(['packages/app-shell/src/views/PageView.tsx'], []); + const message = formatDeclaredLazyViewFailure( + diff, + new Map([['packages/app-shell/src/views/PageView.tsx', 'in eager chunk `assets/PageView-x.js`']]), + ); + expect(message).toContain('in eager chunk `assets/PageView-x.js`'); + }); + + it('reports both directions of drift at once', () => { + const message = formatDeclaredLazyViewFailure({ + unpinned: ['packages/app-shell/src/views/PageView.tsx'], + missing: ['packages/app-shell/src/views/RecordDetailView.tsx'], + }); + expect(message).toContain('views/PageView.tsx'); + expect(message).toContain('views/RecordDetailView.tsx'); + }); +}); diff --git a/scripts/vite-declared-lazy-views.ts b/scripts/vite-declared-lazy-views.ts new file mode 100644 index 0000000000..69de27ca76 --- /dev/null +++ b/scripts/vite-declared-lazy-views.ts @@ -0,0 +1,476 @@ +import fs from 'node:fs'; +import path from 'node:path'; +// Hook parameters are annotated explicitly throughout: Vite types each hook as +// `ObjectHook` (a function-or-object union), and TypeScript cannot +// contextually infer parameters across such a union — same reason +// `scripts/vite-ineffective-dynamic-imports.ts` spells its types out. +import type { Plugin, Rollup } from 'vite'; + +/** + * Keeps `AppContent`'s `lazy()` view declarations and the console's EAGER + * CLOSURE in agreement — by making the laziness real where it can be, and by + * failing the build when the two drift apart (objectui#6535). + * + * ## The measurement this exists for + * + * `packages/app-shell/src/console/AppContent.tsx` declares eight route views + * with `lazy(() => import('../views/.js'))`. 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, + * SIX of those eight were in the eager closure anyway: + * + * | declared `lazy()` | eager before | eager after | why it was eager | + * |---------------------|--------------|-------------|-------------------------| + * | `DashboardView` | yes | NO | barrel re-export ONLY | + * | `PageView` | yes | NO | barrel re-export ONLY | + * | `SearchResultsPage` | yes | NO | barrel re-export ONLY | + * | `RecordDetailView` | yes | yes | real static edge | + * | `RecordFormPage` | yes | yes | chunk co-tenancy | + * | `ReportView` | yes | yes | chunk co-tenancy | + * | `ComponentNavView` | no | no | never re-exported | + * | `ObjectDataPage` | no | no | never re-exported | + * + * The count of six is right and its MECHANISM is right for only three of them. + * That correction is the whole design of this file, so it is stated rather than + * implied: removing the barrel edge freed exactly the three views that had no + * other reason to be eager, and the eager closure moved 3237.0 KB -> 3231.7 KB + * gzipped (-5,367 bytes, 52 -> 49 eager chunks of 508) as read from + * `pnpm check:eager-closure`. The other three are held by defects the barrel has + * nothing to do with, they are worth 53.8 KB gzipped between them, and no import + * spelling repairs any of them — see {@link DECLARED_LAZY_VIEWS_STILL_EAGER}. + * + * ## Defect 1 — the barrel re-export nobody can tree-shake (three views) + * + * `packages/app-shell/src/index.ts` re-exports the views from the package + * barrel (`export { RecordFormPage, ... } from './views/index.js'`), and the + * console's entry imports that barrel statically (`apps/console/src/main.tsx`, + * and ~30 other console modules). A named re-export is an ordinary static edge, + * so the split chunk stays statically reachable from the entry even though the + * only ROUTING reference to it is a `lazy()` import. + * + * Tree-shaking should have dropped those re-exports — nothing in the eager graph + * uses the bindings. It cannot, because `@object-ui/app-shell` publishes no + * `sideEffects` field, so every bundler must assume every module in the + * re-export chain might do something on import and keeps them all. + * + * The fix is to tell the CONSOLE's build what is true of these specific + * modules: they are pure React components, so `moduleSideEffects: false`. That + * is deliberately narrower than adding `"sideEffects"` to + * `packages/app-shell/package.json`, which would be the general fix and is NOT + * done here — measured on this branch, `"sideEffects": false` on that package + * silently DROPS three real SDUI widget registrations from the bundle + * (`mcp:connect-agent`, `cloud:onboarding-next`, `cloud:ai-model-status`, all + * registered by bare side-effect imports in the barrel), and an incomplete + * `sideEffects` ARRAY would do the same to third-party embedders with nothing + * to catch it. That is a published-contract decision, not this card's repair. + * + * ## Defect 2 — edges the barrel has nothing to do with (three views) + * + * `RecordDetailView` is eager for an honest reason and stays eager: + * `packages/app-shell/src/views/ObjectView.tsx` imports it statically, and + * `ObjectView` is in AppContent's own "eagerly loaded — always needed" block. + * Its `lazy()` is defeated by a genuine dependency, not by a barrel. It is + * therefore PINNED in {@link DECLARED_LAZY_VIEWS_STILL_EAGER} rather than + * quietly declared pure — and it is not declared pure for a second, independent + * reason: it carries a bare `import './record-approvals-renderer.js'`, so the + * claim would be false. + * + * `RecordFormPage` and `ReportView` are eager for a third reason, and it is the + * one no `grep` over the source will show: CHUNK CO-TENANCY. Rolldown emits each + * of them in a chunk it shares with a module that IS eagerly used, so the whole + * chunk is eager and the view's bytes ride along even with no import edge to the + * view itself. Both are pinned with the co-tenant named. + * + * ## Why a ledger, and why drift fails in BOTH directions + * + * A one-off measurement does not stop the next barrel re-export from undoing + * this. Neither does a check that only fails when a view goes eager: the + * dangerous reading here is ZERO, exactly as in + * `scripts/vite-ineffective-dynamic-imports.ts`. So: + * + * - **unpinned** — a declared-`lazy()` view found in the eager closure that + * this ledger does not know about. That is a NEW regression; the build stops + * and names the view. + * - **missing** — a pinned view that is NOT in the eager closure any more. + * Either someone fixed the static edge (record the win: delete the line, say + * so in the PR) or this walk has gone blind. + * + * Two counter-probes guard the walk itself, because both halves above are + * statements about a SET that a broken matcher empties silently: + * + * 1. Every declared view must be found in SOME chunk, eager or lazy. A view + * that matches nothing at all means the module-id matcher stopped matching, + * not that the bundle improved. + * 2. `ObjectView` — imported statically by AppContent's always-needed block — + * must be found EAGER. It is the positive control in the same query shape: + * if the eager walk cannot see a module that is eager by construction, its + * zeroes are not measurements. + */ + +/** Repo root — this file lives in `scripts/`, one level below it. */ +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); + +/** + * The module whose `lazy()` declarations are the subject. Repo-relative POSIX. + */ +export const APP_CONTENT_PATH = 'packages/app-shell/src/console/AppContent.tsx'; + +/** + * The positive control for the eager walk: statically imported by + * {@link APP_CONTENT_PATH}'s "eagerly loaded — always needed" block, so it is + * eager by construction. See counter-probe 2 in the header. + */ +export const EAGER_WALK_CONTROL = 'packages/app-shell/src/views/ObjectView.tsx'; + +/** + * Declared-`lazy()` views that are in the eager closure anyway, with the reason + * each stands. Kept sorted and deduplicated + * (`scripts/__tests__/vite-declared-lazy-views.test.ts` checks that, and that + * every entry still names a file that exists). + * + * All three stand for reasons that are NOT the barrel re-export this card + * removed, and none of the three is fixable by an import spelling. Measured on + * `ece68882`, from the emitted chunks' own module lists: + * + * - `RecordDetailView` — a real static edge. + * `packages/app-shell/src/views/ObjectView.tsx` imports it by name, and + * `ObjectView` sits in AppContent's own "eagerly loaded — always needed" + * block. Splitting it would mean giving `ObjectView` a lazy boundary. + * + * - `RecordFormPage` — CHUNK CO-TENANCY, not an import of the view at all. + * Rolldown emits it in a chunk it shares with + * `packages/app-shell/src/providers/expressionUser.ts` (objectui#6515's leaf + * module), which `AppContent` imports statically and the barrel re-exports + * to the console's `InternalFormRoute`. The co-tenant is eager, so the whole + * chunk is — the view's bytes ride along. + * + * - `ReportView` — the same shape. Its chunk also carries + * `views/ReportConfigPanel.tsx` and `views/RuntimeDraftBar.tsx`, and + * `views/ViewConfigPanel.tsx` (a barrel export the console uses) imports + * `RuntimeDraftBar` statically. + * + * The last two are a different defect from the one objectui#6535 names, they + * are not repaired by anything in the console's import graph, and the obvious + * lever — an `advancedChunks` group that isolates the shared leaves — is a + * chunking-policy change that needs its own measurement. Filed separately; + * pinned here so the bytes are recorded rather than implied. + */ +export const DECLARED_LAZY_VIEWS_STILL_EAGER: readonly string[] = Object.freeze([ + 'packages/app-shell/src/views/RecordDetailView.tsx', + 'packages/app-shell/src/views/RecordFormPage.tsx', + 'packages/app-shell/src/views/ReportView.tsx', +]); + +/** + * Pull the single-file view modules out of AppContent's `lazy()` declarations. + * + * Deliberately narrow: only relative specifiers of the shape `../views/.js` + * — the eight route views objectui#6535 measured. AppContent also declares + * `lazy()` for `../views/metadata-admin/index.js`, `./marketplace/*.js` and + * `@object-ui/plugin-designer`; those are directory barrels and a package, they + * have their own eager-closure story, and sweeping them in here would silently + * widen a ledger nobody has measured. + * + * Returns repo-relative POSIX paths to the REAL source files, resolved on disk + * through `exists` — the specifier says `.js` (NodeNext spelling) and the file + * is `.tsx`, and guessing that mapping instead of checking it is how a ledger + * ends up naming a path that no longer exists. + */ +export function parseDeclaredLazyViews( + source: string, + exists: (repoRelative: string) => boolean = (p) => fs.existsSync(path.join(REPO_ROOT, p)), + appContentPath: string = APP_CONTENT_PATH, +): string[] { + const dir = path.posix.dirname(appContentPath); + const found = new Set(); + const pattern = /lazy\(\s*\(\s*\)\s*=>\s*import\(\s*['"](\.\.\/views\/[A-Za-z0-9_$]+)\.js['"]\s*\)/g; + for (const match of source.matchAll(pattern)) { + const base = path.posix.normalize(path.posix.join(dir, match[1] as string)); + const resolved = ['.tsx', '.ts'].map((ext) => `${base}${ext}`).find(exists); + if (resolved) found.add(resolved); + else found.add(`${base}.tsx`); + } + return [...found].sort(); +} + +/** + * Whether this view can honestly be declared `moduleSideEffects: false`. + * + * Returns the offending line when the module carries a bare side-effect import + * (`import './x.js';`), otherwise `null`. The complement rule — "every declared + * view that is not pinned eager is declared pure" — would otherwise turn a + * future view's registration into a silent drop, so the claim is CHECKED + * against the source rather than assumed from the ledger. + */ +export function bareSideEffectImport(source: string): string | null { + const match = /^\s*import\s+['"][^'"]+['"]\s*;?\s*$/m.exec(source); + return match ? (match[0].trim() as string) : null; +} + +/** The two directions of drift a build can show against the ledger. */ +export interface DeclaredLazyViewDiff { + /** Declared-lazy views found eager that the ledger does not know about. */ + readonly unpinned: readonly string[]; + /** Pinned views that are no longer eager — a fix to record, or a blind walk. */ + readonly missing: readonly string[]; +} + +/** + * Compare one build's eager declared-lazy views against the ledger. Pure, so + * the policy is unit-testable without a five-minute console build. + */ +export function diffDeclaredLazyViews( + eagerViews: Iterable, + pinned: readonly string[] = DECLARED_LAZY_VIEWS_STILL_EAGER, +): DeclaredLazyViewDiff { + const pinnedSet = new Set(pinned); + const eagerSet = new Set(eagerViews); + return { + unpinned: [...eagerSet].filter((id) => !pinnedSet.has(id)).sort(), + missing: [...pinnedSet].filter((id) => !eagerSet.has(id)).sort(), + }; +} + +/** + * Render {@link diffDeclaredLazyViews}'s verdict, or `null` when it is clean. + * + * `why` carries, per view, the chunk that holds it and the EAGER chunks that + * statically import that chunk. Without it the failure names a view and leaves + * the reader to rebuild the graph by hand to find the edge — and the edge is + * frequently not a source-level import of the view at all but a chunk-grouping + * decision, which no amount of `grep` over the source will show. + */ +export function formatDeclaredLazyViewFailure( + diff: DeclaredLazyViewDiff, + why: ReadonlyMap = new Map(), +): string | null { + const lines: string[] = []; + if (diff.unpinned.length > 0) { + lines.push( + `${diff.unpinned.length} view(s) that AppContent declares with \`lazy()\` are in the ` + + `EAGER closure, so the browser fetches and parses them before first render whatever ` + + `the route — the \`lazy()\` + \`\` around them buys nothing (objectui#6535):`, + ...diff.unpinned.map((id) => ` + ${id}${why.has(id) ? ` -- ${why.get(id)}` : ''}`), + `Find the static edge — usually a named re-export from ` + + `\`packages/app-shell/src/index.ts\` that the console's entry pulls in, or a static ` + + `import from a module that is eager by construction. Either remove the edge, or pin ` + + `the view in DECLARED_LAZY_VIEWS_STILL_EAGER with the reason it stands.`, + ); + } + if (diff.missing.length > 0) { + lines.push( + `${diff.missing.length} pinned view(s) are NO LONGER in the eager closure. If you fixed ` + + `the static edge, record the win: delete the line from ` + + `DECLARED_LAZY_VIEWS_STILL_EAGER and say so in the PR. If you did not, this walk has ` + + `gone blind and its zeroes are not measurements:`, + ...diff.missing.map((id) => ` - ${id}`), + ); + } + return lines.length > 0 ? lines.join('\n') : null; +} + +export interface DeclaredLazyViewsOptions { + /** Repo-relative POSIX path to the module carrying the `lazy()` declarations. */ + readonly appContentPath?: string; + /** Ledger of declared-lazy views that are eager anyway. */ + readonly pinnedEager?: readonly string[]; + /** Repo root the repo-relative paths are resolved against. */ + readonly repoRoot?: string; +} + +/** + * Both halves of the agreement, in one plugin so they cannot drift: the same + * parsed declaration list decides what is declared side-effect-free and what + * the eager-closure assertion weighs. + */ +export function viteDeclaredLazyViews(options: DeclaredLazyViewsOptions = {}): Plugin { + const repoRoot = options.repoRoot ?? REPO_ROOT; + const appContentPath = options.appContentPath ?? APP_CONTENT_PATH; + const pinnedEager = options.pinnedEager ?? DECLARED_LAZY_VIEWS_STILL_EAGER; + + /** Repo-relative view paths parsed from AppContent, filled in `buildStart`. */ + let declared: string[] = []; + /** Absolute ids of the views this build declares side-effect-free. */ + let shakeable = new Set(); + /** + * Bare file names (`DashboardView.js`) of {@link shakeable}, so the hot + * `resolveId` path rejects the ~30k specifiers that cannot be a view with one + * string compare, instead of re-entering the resolver for every one of them. + */ + let shakeableSpecifiers = new Set(); + + const abs = (repoRelative: string) => path.join(repoRoot, repoRelative); + + return { + name: 'declared-lazy-views', + // `resolveId` MUST outrank vite's own resolver. Vite runs core plugins + // (`vite:resolve` among them) BEFORE normal-order plugins, and `resolveId` + // is first-wins — so at normal order this hook is never called at all and + // the side-effect declaration below is silently inert. Measured on this + // branch: at normal order the build reached `generateBundle` with all five + // views still eager and failed on the ledger, which reads exactly like "the + // fix does not work" rather than "the hook never ran". + enforce: 'pre', + + buildStart() { + const appContentAbs = abs(appContentPath); + if (!fs.existsSync(appContentAbs)) { + this.error( + `[declared-lazy-views] \`${appContentPath}\` does not exist, so no \`lazy()\` view ` + + `declaration can be read and every check below would pass vacuously. The module ` + + `was moved or renamed — re-point \`appContentPath\` at it.`, + ); + } + const source = fs.readFileSync(appContentAbs, 'utf8'); + declared = parseDeclaredLazyViews(source, (p) => fs.existsSync(abs(p)), appContentPath); + + // Counter-probe — a parse that finds nothing is a broken matcher, and it + // would make BOTH halves below vacuous: nothing declared side-effect-free + // (no win, silently) and nothing weighed (no guard, silently). + if (declared.length === 0) { + this.error( + `[declared-lazy-views] counter-probe failed: no \`lazy(() => import('../views/*.js'))\` ` + + `declaration found in \`${appContentPath}\`. objectui#6535 measured eight. Either the ` + + `route views stopped being declared lazy — in which case retire this plugin ` + + `deliberately rather than leaving a guard with no subject — or the declarations were ` + + `re-spelled and this matcher no longer sees them. Do not read this as "all views are ` + + `lazy now": it is a statement about the regex, never about the graph.`, + ); + } + + const pinnedSet = new Set(pinnedEager); + const next = new Set(); + for (const view of declared) { + if (pinnedSet.has(view)) continue; + const offending = bareSideEffectImport(fs.readFileSync(abs(view), 'utf8')); + if (offending) { + this.error( + `[declared-lazy-views] refusing to declare \`${view}\` side-effect-free: it carries ` + + `a bare side-effect import (\`${offending}\`), so the claim would be false and the ` + + `import could be dropped from every build that believes it — measured on ` + + `objectui#6535, that is exactly how \`"sideEffects": false\` on ` + + `\`@object-ui/app-shell\` silently loses SDUI widget registrations. Either move the ` + + `side effect out of the view, or pin the view in ` + + `DECLARED_LAZY_VIEWS_STILL_EAGER with the reason.`, + ); + } + next.add(abs(view)); + } + shakeable = next; + shakeableSpecifiers = new Set( + [...next].map((id) => `${path.basename(id, path.extname(id))}.js`), + ); + }, + + // A named re-export the eager graph never uses is droppable only if the + // module is known pure. `@object-ui/app-shell` publishes no `sideEffects` + // field, so rolldown must assume otherwise; this states the truth for THIS + // build and these modules only. `resolveId` rather than `load`/`transform` + // so the file's contents and sourcemaps are left entirely alone. + async resolveId(source: string, importer: string | undefined, resolveOptions) { + if (shakeable.size === 0) return null; + const bare = source.split('?')[0] as string; + if (!shakeableSpecifiers.has(bare.slice(bare.lastIndexOf('/') + 1))) return null; + const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); + if (!resolved || resolved.external) return resolved; + if (!shakeable.has(resolved.id.split('?')[0] as string)) return resolved; + return { ...resolved, moduleSideEffects: false }; + }, + + generateBundle(_outputOptions, bundle: Rollup.OutputBundle) { + const chunks = new Map(); + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type === 'chunk') chunks.set(fileName, output); + } + + // The eager closure: every chunk reachable from an entry chunk through + // STATIC imports only. Same walk, and the same entry selection, as + // `emitEagerClosureReport` in `apps/console/vite.config.ts` — the two must + // answer the same question or this guard and the budget disagree. + const entries = [...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName); + const eager = new Set(); + const queue = [...entries]; + while (queue.length > 0) { + const fileName = queue.pop() as string; + if (eager.has(fileName)) continue; + eager.add(fileName); + for (const imported of chunks.get(fileName)?.imports ?? []) { + if (!eager.has(imported)) queue.push(imported); + } + } + + const chunksHolding = (repoRelative: string): string[] => { + const suffix = `/${repoRelative}`; + return [...chunks.values()] + .filter((chunk) => + Object.keys(chunk.modules).some((id) => { + const bare = id.replace(/^\0/, '').split('?')[0] as string; + const posix = bare.split(path.sep).join('/'); + return posix === repoRelative || posix.endsWith(suffix); + }), + ) + .map((chunk) => chunk.fileName); + }; + + // Counter-probe 1 — every declared view must be SOMEWHERE in this bundle. + const invisible = declared.filter((view) => chunksHolding(view).length === 0); + if (invisible.length > 0) { + this.error( + `[declared-lazy-views] counter-probe failed: ${invisible.length} declared-lazy view(s) ` + + `are in no chunk at all — eager or lazy — so the module-id matcher below can no ` + + `longer fail and its verdict is a statement about this matcher, not about the ` + + `graph: ${invisible.join(', ')}. AppContent still declares them, so they must be in ` + + `the bundle; the ids rolldown reports have changed shape. (chunks: ${chunks.size})`, + ); + } + + // Counter-probe 2 — the eager walk must see a module that is eager by + // construction. A walk that finds too little reports "all lazy", which + // this guard would read as good news. + const controlChunks = chunksHolding(EAGER_WALK_CONTROL); + if (controlChunks.filter((fileName) => eager.has(fileName)).length === 0) { + this.error( + `[declared-lazy-views] counter-probe failed: \`${EAGER_WALK_CONTROL}\` is not in the ` + + `eager closure. AppContent imports it statically in its "eagerly loaded — always ` + + `needed" block, so it is eager by construction — its absence means this walk is ` + + `reading the graph wrongly, not that the bundle improved. Fix the walk before ` + + `trusting the verdict below. (chunks holding it: ${controlChunks.join(', ') || 'NONE'}; ` + + `entry chunks: ${entries.join(', ') || 'NONE'}; eager: ${eager.size}/${chunks.size})`, + ); + } + + const eagerViews = declared.filter((view) => + chunksHolding(view).some((fileName) => eager.has(fileName)), + ); + + // Why each eager view is eager: the chunk holding it, and the eager + // chunks that statically import that chunk. An entry chunk names itself. + const why = new Map(); + for (const view of eagerViews) { + for (const holder of chunksHolding(view).filter((f) => eager.has(f))) { + const importers = [...chunks.values()] + .filter((c) => eager.has(c.fileName) && (c.imports ?? []).includes(holder)) + .map((c) => c.fileName); + why.set( + view, + `in eager chunk \`${holder}\`, statically imported by ` + + `${importers.join(', ') || (entries.includes(holder) ? 'nothing (it IS an entry chunk)' : 'NOTHING -- so it was placed there by chunk grouping, not by an import edge')}`, + ); + } + } + + const failure = formatDeclaredLazyViewFailure( + diffDeclaredLazyViews(eagerViews, pinnedEager), + why, + ); + if (failure) this.error(`[declared-lazy-views] ${failure}`); + + const lazyCount = declared.length - eagerViews.length; + this.info( + `${lazyCount}/${declared.length} views AppContent declares lazy are genuinely lazy; ` + + `${eagerViews.length} eager, all pinned (objectui#6535). Ledger + why they stand: ` + + `scripts/vite-declared-lazy-views.ts`, + ); + }, + }; +}