diff --git a/.changeset/6681-declared-lazy-marketplace-routes.md b/.changeset/6681-declared-lazy-marketplace-routes.md new file mode 100644 index 000000000..23985e911 --- /dev/null +++ b/.changeset/6681-declared-lazy-marketplace-routes.md @@ -0,0 +1,35 @@ +--- +"@object-ui/console": patch +--- + +Console: free the marketplace routes `AppContent` declares lazy, and pin the metadata-admin surface that cannot be freed + +`AppContent` declares four surfaces with `lazy()` that the emitted bundle loaded on +every console page load anyway. Measured on `b98352a15` from +`apps/console/dist/eager-closure.json` and the emitted chunks' own module lists — +not from a source-level search, which cannot see the mechanism at all: + +| chunk | gz, eager before | after | +|---|---|---| +| `metadata-admin` | 172,651 B | unchanged — pinned, with the module list that proves it | +| `MarketplacePackagePage` | 7,647 B | lazy | +| `MarketplaceInstalledPage` | 1,836 B | lazy | +| `MarketplacePage` | already lazy | lazy | + +Both marketplace chunks were held by CHUNK CO-TENANCY, not by any import of the page: +rolldown had parked `components/SuggestedBindingsPanel.tsx` (statically imported by the +eager `views/studio-design/StudioDesignSurface.tsx`) in the first, and +`console/marketplace/InstalledListWidget.tsx` (bare-imported by the package barrel for +its SDUI registration) in the second. An `app-shell-eager-leaves` group in +`apps/console/vite.config.ts` isolates those co-tenants so the three declared-lazy pages +chunk by their own dynamic-only reachability. The console eager closure moves +3180.2 KB → 3171.5 KB gzipped (−8,888 bytes, 48 → 45 eager chunks) with the three +per-chunk ceilings unmoved. + +`metadata-admin` is NOT freed and is now pinned in +`DECLARED_LAZY_VIEWS_STILL_EAGER` with the reason: it is statically imported by the +package barrel and by `services/builtinComponents.tsx`, which registers +`MetadataDirectoryPage` and `MetadataResourceRouter` by value, and it performs five +load-bearing top-level registrations. Freeing it would change what +`registerAppComponent` accepts and what the barrel re-exports — a published-contract +decision, not a bundling one. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 43aae93be..17bbb5c11 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -767,6 +767,55 @@ export default defineConfig({ // /api/v1/runtime/config (objectstack#12681); a deployment that // configured none never requests this chunk at all. { name: 'vendor-sentry', test: /[\\/]node_modules[\\/]@sentry[\\/]/, priority: 85 }, + // The eagerly-reached app-shell leaves that were holding declared-lazy + // route chunks in the eager closure by CHUNK CO-TENANCY (objectui#6680). + // + // Measured on `b98352a15` from the emitted chunks' own module lists — + // never from a source-level search, which cannot see this at all + // (objectui#6681): + // + // assets/MarketplacePackagePage-*.js 7,647 B gz, EAGER + // holds `components/SuggestedBindingsPanel.tsx` (+ its + // `services/suggestedBindingsApi.ts`), which + // `views/studio-design/StudioDesignSurface.tsx` — a barrel export the + // console reaches eagerly — imports STATICALLY. + // assets/MarketplaceInstalledPage-*.js 1,836 B gz, EAGER + // holds `console/marketplace/InstalledListWidget.tsx`, which + // `packages/app-shell/src/index.ts` BARE-imports for its SDUI + // registration and which the package's `sideEffects` array names. + // + // In both, the page itself had NO static importer: only the co-tenant + // did, and the page's bytes rode along on every console page load while + // `AppContent` declared it `lazy()`. `MarketplacePage.tsx` had no eager + // co-tenant and was already lazy — the control that makes the mechanism + // legible rather than a story about these two files. + // + // Isolating the co-tenants lets all three pages chunk by their own + // (dynamic-only) reachability: eager closure 3180.2 KB -> 3171.5 KB + // gzipped, -8,888 bytes, 48 -> 45 eager chunks, with the three per-chunk + // ceilings unmoved. + // + // ⚠️ Group the CO-TENANTS, not the pages. The opposite grouping was + // measured first: a `marketplace-routes` group over the three + // declared-lazy pages swept `InstalledListWidget.tsx` in with them, so + // the barrel's bare import made the GROUP eager and it became an + // attractor for 47 modules — `runtime-config.ts`, + // `providers/MetadataProvider.tsx` and `@object-ui/plugin-form` among + // them — emitting a 44 KB eager chunk for a net of -1,697 bytes and + // three destroyed lazy boundaries. A group decides co-tenancy; it is + // not a laziness declaration, and `assertLazyLinterStaysLazy` above is + // the other half of that same lesson. + // + // The membership is EXPLICIT rather than a directory glob because each + // entry is a measured fact about one module's importers, and a glob + // would quietly enrol modules nobody weighed. + // `scripts/vite-declared-lazy-views.ts` fails this build in BOTH + // directions if the result drifts. + { + name: 'app-shell-eager-leaves', + test: /[\\/]packages[\\/]app-shell[\\/]src[\\/](?:components[\\/]SuggestedBindingsPanel\.tsx|services[\\/]suggestedBindingsApi\.ts|console[\\/]marketplace[\\/]InstalledListWidget\.tsx)$/, + priority: 75, + }, ], }, } diff --git a/scripts/__tests__/vite-declared-lazy-views.test.ts b/scripts/__tests__/vite-declared-lazy-views.test.ts index 709e3b13b..d4088c98e 100644 --- a/scripts/__tests__/vite-declared-lazy-views.test.ts +++ b/scripts/__tests__/vite-declared-lazy-views.test.ts @@ -6,8 +6,10 @@ import { DECLARED_LAZY_VIEWS_STILL_EAGER, EAGER_WALK_CONTROL, bareSideEffectImport, + declaredSideEffectful, diffDeclaredLazyViews, formatDeclaredLazyViewFailure, + nearestPackage, parseDeclaredLazyViews, } from '../vite-declared-lazy-views.ts'; @@ -51,33 +53,62 @@ describe('DECLARED_LAZY_VIEWS_STILL_EAGER', () => { }); describe('parseDeclaredLazyViews', () => { - it('finds the eight route views AppContent declares, resolved to real files', () => { + it('finds every relative module 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); + // The count is a measurement, asserted rather than merely observed because a + // matcher that silently finds fewer makes every check downstream of it pass + // vacuously. Eight single-file route views (objectui#6535), the + // metadata-admin directory barrel that six declarations share, and the three + // marketplace pages (objectui#6681) = twelve distinct files. + expect(declared).toHaveLength(12); 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'); + // The three surfaces objectui#6535 deliberately looked away from and + // objectui#6681 measured. Named individually: a bare length assertion goes + // on passing if the widened matcher swaps one surface for another. + expect(declared).toContain('packages/app-shell/src/views/metadata-admin/index.ts'); + expect(declared).toContain('packages/app-shell/src/console/marketplace/MarketplacePage.tsx'); + expect(declared).toContain( + 'packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx', + ); + expect(declared).toContain( + 'packages/app-shell/src/console/marketplace/MarketplaceInstalledPage.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. + it('sweeps in the directory barrel and the sibling directory, and dedupes the barrel', () => { 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 B2 = lazy(() => import('../views/metadata-admin/index.js').then(m => ({ default: m.B2 })));", "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', + (p) => + p === 'packages/app-shell/src/views/Alpha.tsx' || + p === 'packages/app-shell/src/views/metadata-admin/index.ts' || + p === 'packages/app-shell/src/console/marketplace/MarketplacePage.tsx', ); - expect(declared).toEqual(['packages/app-shell/src/views/Alpha.tsx']); + expect(declared).toEqual([ + 'packages/app-shell/src/console/marketplace/MarketplacePage.tsx', + 'packages/app-shell/src/views/Alpha.tsx', + 'packages/app-shell/src/views/metadata-admin/index.ts', + ]); + }); + + it('still ignores a PACKAGE specifier, which has no repo-relative source file', () => { + // `@object-ui/plugin-designer` needs a resolver to become a path, and this + // parser runs in `buildStart` before any module is loaded. Its chunk was + // measured NOT eager on `b98352a15`, so this is a recorded blind spot rather + // than a live one. + const declared = parseDeclaredLazyViews( + "const D = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.D })));", + () => true, + ); + expect(declared).toEqual([]); }); it('resolves the NodeNext .js specifier to the real .tsx file rather than guessing', () => { @@ -134,6 +165,68 @@ describe('bareSideEffectImport', () => { }); }); +describe('declaredSideEffectful', () => { + it('matches an exact `./path` entry, with or without the leading dot-slash', () => { + const array = ['./src/index.ts', './src/views/metadata-admin/index.ts']; + expect(declaredSideEffectful(array, 'src/views/metadata-admin/index.ts')).toBe( + './src/views/metadata-admin/index.ts', + ); + expect(declaredSideEffectful(array, 'src/views/RecordDetailView.tsx')).toBeNull(); + }); + + it('reads `false` as "nothing is side-effectful" and `true`/absent the other way', () => { + expect(declaredSideEffectful(false, 'src/anything.ts')).toBeNull(); + // Absent is not a refusal: it is the state this whole plugin was written + // for, and the per-module declaration is what it replaces. + expect(declaredSideEffectful(undefined, 'src/anything.ts')).toBeNull(); + expect(declaredSideEffectful(true, 'src/anything.ts')).toContain('sideEffects'); + }); + + it('refuses on a glob rather than under-matching it', () => { + // A guard that silently fails to match is the failure this function exists + // to prevent, so the ambiguous case fails LOUD. + expect(declaredSideEffectful(['./src/**/*.css'], 'src/views/Alpha.tsx')).toContain('glob'); + }); + + it('agrees with the real package: metadata-admin/index.ts is declared side-effectful', () => { + // The subject. This is the file whose FIVE top-level registration calls + // `bareSideEffectImport` cannot see, so without this guard the plugin would + // declare it pure the moment someone deleted its ledger line + // (objectui#6681). + const owner = nearestPackage('packages/app-shell/src/views/metadata-admin/index.ts', REPO_ROOT); + expect(owner?.packageJsonPath).toBe('packages/app-shell/package.json'); + expect(owner?.packageRelative).toBe('src/views/metadata-admin/index.ts'); + const manifest = JSON.parse(read(owner!.packageJsonPath)) as { sideEffects?: unknown }; + expect(declaredSideEffectful(manifest.sideEffects, owner!.packageRelative)).toBe( + './src/views/metadata-admin/index.ts', + ); + // The positive control in the same query shape: a declared-lazy module the + // array does NOT name, so a matcher that answered "side-effectful" to + // everything could not pass both. + expect( + declaredSideEffectful(manifest.sideEffects, 'src/console/marketplace/MarketplacePage.tsx'), + ).toBeNull(); + }); + + it('the source-reading guard is blind to it, which is why this one exists', () => { + // Stated as a test rather than a comment: if `bareSideEffectImport` ever + // learns to see top-level calls, this expectation flips and the reader is + // told, instead of two guards silently overlapping. + expect( + bareSideEffectImport(read('packages/app-shell/src/views/metadata-admin/index.ts')), + ).toBeNull(); + }); +}); + +describe('nearestPackage', () => { + it('walks up to the owning package, not the repo root', () => { + expect(nearestPackage('packages/app-shell/src/views/ObjectView.tsx', REPO_ROOT)).toEqual({ + packageJsonPath: 'packages/app-shell/package.json', + packageRelative: 'src/views/ObjectView.tsx', + }); + }); +}); + 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 @@ -146,7 +239,10 @@ describe('EAGER_WALK_CONTROL', () => { }); describe('diffDeclaredLazyViews', () => { - const pinned = ['packages/app-shell/src/views/RecordDetailView.tsx']; + const pinned = [ + 'packages/app-shell/src/views/RecordDetailView.tsx', + 'packages/app-shell/src/views/metadata-admin/index.ts', + ]; it('is clean when the eager set is exactly the ledger', () => { const diff = diffDeclaredLazyViews(pinned, pinned); diff --git a/scripts/vite-declared-lazy-views.ts b/scripts/vite-declared-lazy-views.ts index e5786212b..e1971ad30 100644 --- a/scripts/vite-declared-lazy-views.ts +++ b/scripts/vite-declared-lazy-views.ts @@ -91,6 +91,46 @@ import type { Plugin, Rollup } from 'vite'; * 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. * + * ## Defect 3 — the surfaces this file used to look away from (objectui#6681) + * + * AppContent declares `lazy()` for more than the eight single-file views, and + * objectui#6535 deliberately parsed only those: a directory barrel + * (`../views/metadata-admin/index.js`, six declarations), a sibling directory + * (`./marketplace/*.js`, three) and a package (`@object-ui/plugin-designer`, + * three). Measured on `b98352a15` from `apps/console/dist/eager-closure.json` + * and the emitted chunks' own module lists, three of those four surfaces were + * eager and worth 182,134 bytes gzipped — 5.6% of a 3180.2 KB closure, and more + * than four times its 42.5 KB of headroom: + * + * | chunk | gz eager | mechanism | + * |----------------------------|----------|------------------------------------| + * | `metadata-admin` | 172,651 | real static edges — PINNED below | + * | `MarketplacePackagePage` | 7,647 | chunk co-tenancy — FIXED | + * | `MarketplaceInstalledPage` | 1,836 | chunk co-tenancy — FIXED | + * | `MarketplacePage` | 0 | already lazy (the control) | + * + * The two marketplace chunks were held by objectui#6680's mechanism, not by an + * import of the page: rolldown had put `MarketplacePackagePage.tsx` in a chunk + * with `components/SuggestedBindingsPanel.tsx` (which eager + * `views/studio-design/StudioDesignSurface.tsx` imports statically) and + * `MarketplaceInstalledPage.tsx` in a chunk with + * `console/marketplace/InstalledListWidget.tsx` (which the package barrel + * bare-imports for its SDUI registration). `MarketplacePage.tsx` had no eager + * co-tenant and was already lazy — the control that makes the mechanism legible. + * The repair is the `app-shell-eager-leaves` group in + * `apps/console/vite.config.ts`, which isolates the co-tenants; the eager + * closure moved 3180.2 KB -> 3171.5 KB (-8,888 bytes, 48 -> 45 eager chunks). + * + * ⚠️ The opposite grouping was tried FIRST and measured WORSE, which is why the + * group names the leaves and not the pages: a `marketplace-routes` group over + * the three declared-lazy pages became an attractor for 47 modules — including + * `runtime-config.ts`, `providers/MetadataProvider.tsx` and + * `@object-ui/plugin-form` — and emitted a 44 KB EAGER chunk, because the same + * bare-imported `InstalledListWidget.tsx` got swept into it. Net -1,697 bytes + * against -8,888, and three other lazy boundaries destroyed. A chunk group is + * not a laziness declaration; it decides co-tenancy, and co-tenancy runs both + * ways. + * * ## Why a ledger, and why drift fails in BOTH directions * * A one-off measurement does not stop the next barrel re-export from undoing @@ -133,19 +173,56 @@ export const APP_CONTENT_PATH = 'packages/app-shell/src/console/AppContent.tsx'; 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 + * Declared-`lazy()` modules 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). * - * One entry stands, and it stands for a reason that is NOT the barrel - * re-export objectui#6535 removed: + * Two entries stand, and neither stands for the barrel re-export objectui#6535 + * removed: * * - `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. * + * - `views/metadata-admin/index.ts` — the LARGEST single entry this ledger has + * ever carried (172,651 bytes gzipped, 144 modules, 5.3% of the whole eager + * closure) and the one most likely to be "fixed" by someone who has not read + * why it stands. It is the target of SIX `lazy()` declarations in AppContent + * and it is statically imported by two modules that are eager by + * construction, both measured from the emitted chunk's module list on + * `b98352a15` (objectui#6681): + * + * 1. `packages/app-shell/src/index.ts` — the package barrel, which + * re-exports eleven runtime values from it (`registerMetadataPreview`, + * `useMetadataClient`, …). The console's entry imports that barrel. + * 2. `packages/app-shell/src/services/builtinComponents.tsx` — which the + * barrel BARE-imports (`import './services/builtinComponents.js';`) for + * its ComponentRegistry registrations, and which imports + * `MetadataDirectoryPage` and `MetadataResourceRouter` from this module + * BY VALUE. A registry entry that names a component must hold the + * component. + * + * Neither edge is removable inside a bundling change, and the module cannot + * be declared pure: it performs FIVE top-level registrations at module load + * (`registerBuiltinAnchors`, `registerDefaultMetadataSchemas`, + * `registerDatasourceResource`, `registerBuiltinPreviews`, + * `registerBuiltinInspectors`), which is why + * `@object-ui/app-shell`'s own `sideEffects` array names it (objectui#6683). + * ⚠️ Those five are CALLS, not bare imports, so {@link bareSideEffectImport} + * returns `null` for this file — the guard that actually refuses to declare + * it pure is {@link declaredSideEffectful}, reading the package's own array. + * + * The six pages the declarations name (`DirectoryPage`, `StudioHomePage`, + * `ResourceListPage`, `ResourceEditPage`, `ResourceHistoryPage`, + * `DiagnosticsPage`) have NO dynamic importer of their own in the emitted + * graph — they are reached only through this barrel's static re-exports — + * so no chunking policy separates them from it. Making this lazy means + * changing what `registerAppComponent` accepts (a component VALUE today) and + * what the package barrel re-exports; that is a published-contract decision, + * not a bundling one, and it is recorded as such rather than attempted here. + * * ## Two entries were REMOVED here, and that removal is a recorded win * * `RecordFormPage` and `ReportView` were pinned for a third reason, the one no @@ -169,22 +246,35 @@ export const EAGER_WALK_CONTROL = 'packages/app-shell/src/views/ObjectView.tsx'; */ export const DECLARED_LAZY_VIEWS_STILL_EAGER: readonly string[] = Object.freeze([ 'packages/app-shell/src/views/RecordDetailView.tsx', + 'packages/app-shell/src/views/metadata-admin/index.ts', ]); /** - * Pull the single-file view modules out of AppContent's `lazy()` declarations. + * Pull the modules out of AppContent's `lazy()` declarations. + * + * Every RELATIVE specifier, not just `../views/.js`. objectui#6535 kept + * this to the eight single-file route views on purpose — the directory barrel + * (`../views/metadata-admin/index.js`) and the sibling directory + * (`./marketplace/*.js`) had their own eager-closure story and nobody had + * measured it. objectui#6681 measured it, so they are in the subject now: on + * `b98352a15` this parses TWELVE distinct files, and three of the four surfaces + * it adds were eager (see the header table). * - * 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. + * The PACKAGE specifier stays out. AppContent also declares + * `lazy(() => import('@object-ui/plugin-designer'))` for three pages, and + * `@object-ui/plugin-designer` is a bare specifier with no repo-relative source + * file until a resolver has run — this parser reads a string and checks the + * filesystem, which is the property that lets it run in `buildStart` before any + * module is loaded. Measured on the same build, the chunk holding + * `packages/plugin-designer/src/**` is NOT in the eager closure, so the ledger + * is not currently blind to a live defect; it IS blind to a future one, which + * is recorded rather than papered over. * * 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. + * is `.tsx` (or, for a directory barrel, `index.ts`), 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, @@ -193,7 +283,10 @@ export function parseDeclaredLazyViews( ): 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; + // Any relative specifier: `./x.js`, `../views/x.js`, `../views/dir/index.js`. + // A bare/package specifier (no leading `.`) is deliberately not matched — see + // the note above. + const pattern = /lazy\(\s*\(\s*\)\s*=>\s*import\(\s*['"](\.[^'"]*?)\.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); @@ -217,6 +310,88 @@ export function bareSideEffectImport(source: string): string | null { return match ? (match[0].trim() as string) : null; } +/** + * Whether the OWNING PACKAGE has already declared this module side-effectful. + * + * {@link bareSideEffectImport} reads the module's own source for a bare + * `import './x.js';`. That catches one shape of side effect and misses the + * shape that costs the most here: a top-level CALL. Measured on `b98352a15`, + * `packages/app-shell/src/views/metadata-admin/index.ts` performs five of them + * (`registerBuiltinAnchors()`, `registerDefaultMetadataSchemas()`, + * `registerDatasourceResource()`, `registerBuiltinPreviews()`, + * `registerBuiltinInspectors()`) and `bareSideEffectImport` returns `null` for + * it — so on that file alone, the source-reading guard would have let this + * plugin declare `moduleSideEffects: false` and silently drop every built-in + * preview and inspector registration in the console. That is the exact failure + * objectui#6683 measured when `"sideEffects": false` was tried on the package. + * + * So the second guard does not re-derive the answer from the source at all. It + * reads the package's OWN published claim — the `sideEffects` array that + * objectui#6683 landed and `scripts/check-side-effects-array.mjs` keeps honest + * — and refuses any module that array names. One producer of the fact, two + * consumers; a module cannot be side-effectful for npm and pure for this build. + * + * `sideEffects` is matched the way bundlers match it: `false` claims nothing is + * side-effectful, `true` (or absent) claims everything might be, and an array + * names paths relative to the package root. Only exact `./path` entries are + * honoured here — glob entries are reported as a match for the whole package, + * because a guard that silently under-matches a glob is the failure this + * function exists to prevent. + * + * @param sideEffects the package.json `sideEffects` field, as parsed. + * @param packageRelative the module's path relative to the package root, POSIX, + * WITHOUT a leading `./` (e.g. `src/views/metadata-admin/index.ts`). + * @returns the entry that names it (or a description of the claim), else `null`. + */ +export function declaredSideEffectful( + sideEffects: unknown, + packageRelative: string, +): string | null { + if (sideEffects === false) return null; + if (sideEffects === undefined) { + // No claim at all. This plugin exists precisely because that is the state + // `@object-ui/app-shell` was in before objectui#6683, and the narrow + // per-module declaration below is what it replaced. Not a refusal. + return null; + } + if (sideEffects === true) return 'the package declares `"sideEffects": true`'; + if (!Array.isArray(sideEffects)) { + return `the package declares a \`sideEffects\` of an unrecognised shape (${JSON.stringify(sideEffects)})`; + } + for (const raw of sideEffects) { + if (typeof raw !== 'string') continue; + const entry = raw.startsWith('./') ? raw.slice(2) : raw; + if (entry === packageRelative) return raw; + if (entry.includes('*')) { + return `${raw} (a glob — this guard refuses rather than under-match it)`; + } + } + return null; +} + +/** + * The nearest `package.json` at or above `repoRelative`, and the module's path + * relative to it. Returns `null` above the repo root — a source file outside any + * package has no published `sideEffects` claim to read. + */ +export function nearestPackage( + repoRelative: string, + repoRoot: string, +): { packageJsonPath: string; packageRelative: string } | null { + let dir = path.posix.dirname(repoRelative); + while (dir !== '.' && dir !== '' && dir !== '/') { + const candidate = path.posix.join(dir, 'package.json'); + if (fs.existsSync(path.join(repoRoot, candidate))) { + return { + packageJsonPath: candidate, + packageRelative: path.posix.relative(dir, repoRelative), + }; + } + dir = path.posix.dirname(dir); + } + return 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. */ @@ -261,10 +436,15 @@ export function formatDeclaredLazyViewFailure( `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.`, + `Find the edge in the EMITTED chunk's own module list, never by grepping the source: ` + + `the \`--\` note above names the chunk and its eager importers, and the edge is often ` + + `not an import of the view at all but CHUNK CO-TENANCY (objectui#6680) — an eagerly ` + + `reached leaf that rolldown parked in the same chunk. Three repairs, in order of ` + + `preference: remove the static edge (usually a named re-export from ` + + `\`packages/app-shell/src/index.ts\` that the console's entry pulls in); isolate the ` + + `eager co-tenant with an \`advancedChunks\` group in \`apps/console/vite.config.ts\` ` + + `(objectui#6681 — group the CO-TENANT, not the lazy page: the other way round measured ` + + `worse); or pin the view in DECLARED_LAZY_VIEWS_STILL_EAGER with the reason it stands.`, ); } if (diff.missing.length > 0) { @@ -339,8 +519,10 @@ export function viteDeclaredLazyViews(options: DeclaredLazyViewsOptions = {}): P // (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 ` + + `[declared-lazy-views] counter-probe failed: no \`lazy(() => import('.js'))\` ` + + `declaration found in \`${appContentPath}\`. objectui#6535 measured eight single-file ` + + `route views and objectui#6681 widened the subject to every relative specifier, ` + + `twelve distinct files on \`b98352a15\`. 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 ` + @@ -352,6 +534,37 @@ export function viteDeclaredLazyViews(options: DeclaredLazyViewsOptions = {}): P const next = new Set(); for (const view of declared) { if (pinnedSet.has(view)) continue; + + // Guard 1 — the package's own published claim. Checked FIRST because it + // is the one that catches a top-level registration CALL, which the + // source-reading guard below cannot see (objectui#6681). + const owner = nearestPackage(view, repoRoot); + if (owner) { + const manifest = JSON.parse(fs.readFileSync(abs(owner.packageJsonPath), 'utf8')) as { + sideEffects?: unknown; + }; + const claim = declaredSideEffectful(manifest.sideEffects, owner.packageRelative); + if (claim) { + this.error( + `[declared-lazy-views] refusing to declare \`${view}\` side-effect-free: its own ` + + `package says otherwise — \`${owner.packageJsonPath}\` names it in ` + + `\`sideEffects\` (\`${claim}\`). A module cannot be side-effectful for every ` + + `npm consumer and pure for this one build; the array is the published claim and ` + + `this plugin is not entitled to a second opinion about it. Measured on ` + + `objectui#6683, declaring \`@object-ui/app-shell\` side-effect-free silently ` + + `dropped three SDUI widget registrations, and on objectui#6681 the module this ` + + `guard first fired for performs FIVE top-level registrations that ` + + `\`bareSideEffectImport\` cannot see — it has no bare import at all. Either ` + + `pin \`${view}\` in DECLARED_LAZY_VIEWS_STILL_EAGER with the reason it stands, ` + + `or move the side effect out of it and drop the \`sideEffects\` entry ` + + `(\`pnpm check:side-effects-array\` weighs that edit).`, + ); + } + } + + // Guard 2 — the module's own source. Narrower, and still needed: a + // module absent from the array can carry a bare side-effect import that + // nobody has noticed yet, which is how `RecordDetailView` was caught. const offending = bareSideEffectImport(fs.readFileSync(abs(view), 'utf8')); if (offending) { this.error( @@ -477,8 +690,9 @@ export function viteDeclaredLazyViews(options: DeclaredLazyViewsOptions = {}): P 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: ` + + `${lazyCount}/${declared.length} modules AppContent declares lazy are genuinely lazy; ` + + `${eagerViews.length} eager, all pinned (objectui#6535, widened to the directory ` + + `barrel and the marketplace routes by objectui#6681). Ledger + why they stand: ` + `scripts/vite-declared-lazy-views.ts`, ); },