Uh oh!
There was an error while loading. Please reload this page.
perf(console): keep the lazily-imported @objectstack/lint out of the eager chunk - #5323
Conversation
…eager chunk
The `vendor-objectstack` advancedChunks group matched every `@objectstack/*`
package, so `@objectstack/lint` was folded in beside `@objectstack/spec` and
`@objectstack/client`. Those two are reached synchronously from the app entry,
so the group's chunk is a static import of `index-*.js` — and the group
overrides the linter's async-only reachability. Every console page load
downloaded and parsed the whole linter.
The linter has exactly one runtime reference in this repo:
`packages/app-shell/src/preview/capabilityLint.ts`, behind a deliberate
`await import('@objectstack/lint')` that runs only when an author publishes in
the metadata designer.
Exclude it from the group with a negative lookahead on BOTH alternatives —
under pnpm the module id is
`.../.pnpm/@objectstack+lint@<v>/node_modules/@objectstack/lint/dist/index.js`,
which matches both, so guarding one alone changes nothing.
Also add a build-time assertion (`assert-lazy-linter-stays-lazy`) that fails
the build if the linter ever re-enters the eager closure, with a counter-probe
on `@objectstack/spec` so the check cannot pass by finding nothing.
Measured on 3fbbea1 (eager closure = chunks statically reachable from
index.html):
eager gzip 3,957,301 -> 3,866,123 (-91,178 = -89.0 KiB)
eager raw 13,626,583 -> 13,330,253 (-296,330)
eager chunk count 58 -> 58 (identical chunk set)
Part of #5266✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
… it MEANS `scripts/__tests__/vite-objectstack-spec-dist.test.ts` mirrors the console config's `VENDOR_OBJECTSTACK_TEST` as a literal and asserts on it twice, so the lookahead added in the previous commit turned it red. The mirror is updated to the new spelling; neither assertion is relaxed. Both keep their original intent, which is not "the regex is this string": - `.source` equality is inside "leaves all four flagged surfaces at their baseline values" — it pins that an unset OBJECTSTACK_SPEC_DIST leaves the group test INERT. - `startsWith` is inside the override-set case — it pins that the derivation WIDENS the baseline (appends an arm) rather than replacing it. Both still hold verbatim once the mirror is current, so both are kept as-is. Strengthened rather than merely re-pasted — string equality alone would let a future edit keep the shape while changing which modules the group catches: - the live config's regex must reject a real pnpm `@objectstack/lint` id; - counter-probed with spec (both spellings) and client, which must still match, so the rejection above cannot pass by matching nothing at all; - `@objectstack/lint-utils` must still match, pinning that the exclusion is scoped to the `lint` package and not a `lint*` prefix; - the WIDENED regex must keep the exclusion too, in both the live-config case and the `resolveSpecDistInjection` unit — otherwise a spec-dist build could silently re-eagerize the linter while a released build stayed lazy. Part of #5266
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-support-ai
commented
Aug 19, 2026
ACCEPT — PM review, round 17.
The part that mattered: the pin was strengthened, not relaxedThe shard failure was a real in-scope failure —
Checked against the standard I set in the send-back — "if the updated pin passes against the old regex too, you have relaxed it into uselessness": it does not. The The payload itself stands as measured: −91,178 bytes gzipped (−89.0 KiB) off the eager console chunk, plus a build-time Merging via the queue. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#5266
The
vendor-objectstackadvancedChunksgroup matched every@objectstack/*package, so@objectstack/lintwas folded in beside@objectstack/specand@objectstack/client. Those two are reached synchronously from the app entry, so the group's chunk is a static import ofindex-*.js— and the group overrides the linter's async-only reachability. Every console page load downloaded and parsed the whole linter.The linter has exactly one runtime reference in this repo:
packages/app-shell/src/preview/capabilityLint.ts, behind a deliberateawait import('@objectstack/lint')that runs only when an author publishes in the metadata designer.Two commits: the config fix, and the pin in
scripts/__tests__/that mirrors the config constant.Premise re-derived on current
mainbefore implementingThe card measured at pin
82a94170c405/ framework6f40ed736;mainhas taken 120 commits since. Re-checked:pnpm-lock.yamldiff over that range: 1,138 lines changed, 0 of them mentioning@objectstack(counter-probe: 134resolution:lines in the same diff, so the zero is real, not an empty diff). The dependency pin has not moved.@objectstack/lint@17.0.0dist/index.jsis 532,262 bytes — byte-identical to the card's figure.VENDOR_OBJECTSTACK_TESTis unchanged between the pin and currentmain; the onlyvite.config.tscommit in between (578e02516) added an unrelated@object-ui/types/zodalias.One card figure did move: the eager closure is 58 chunks on
main, not 59. The parity check below is therefore 58/58. Everything else reproduced.Both lookahead alternatives are load-bearing
Under pnpm the linter resolves through the store, so its module id — read out of the build's own module map, not assumed — is:
That single path contains both
/@objectstack+and/node_modules/@objectstack/. Tested against the real path rather than picked by inspection:lint-utilssiblingGuarding one alternative alone leaves the other matching and looks like a fix while changing nothing. The lookaheads are tight (
lint[\\/],lint@) so a future@objectstack/lint-*sibling still groups here rather than scattering into its importers' chunks.Measured — baseline beside result
Eager closure = every chunk reachable from
index.htmlthrough static imports only, walked withes-module-lexer; gzip read from the build's own emitted.gz. Both builds ateb503920f's tree,vite build, compression + visualizer active.The eager chunk name sets are identical — nothing entered or left the closure; only the linter's bytes did. The +1 emitted chunk is the linter's new home,
assets/dist-*.js(294,864 raw / 91,271 gzipped), which is not in the eager closure; it is fetched only when an author publishes in the metadata designer, which is what theawait importwas always asking for.vendor-objectstackitself drops 5,352,089 to 5,055,720 raw.The linter's placement is verified directly, not inferred from the delta
Two independent reads, each with a counter-probe:
@objectstack/lint/dist/index.jsmoves fromassets/vendor-objectstack-BM4dkOHC.js(eager) toassets/dist-LXdey3OB.js(not eager). Counter-probe:@objectstack/specis still invendor-objectstack, so the exclusion is not over-broad.widget-legacy-analytics-unrenderable,visibility-root-mislayered,view-ref-form-target-missing) hit 0 eager chunks after the change, all three inassets/dist-LXdey3OB.js. Counter-probe: the substringobjectstackstill hits 27 chunks (19 eager), so the scan is live and the zero is a real zero.Regression checks
1. A build-output assertion, stated plainly
This part is not a unit test. The invariant is a property of the emitted chunk graph, so it is asserted where it is decided: a new
assert-lazy-linter-stays-lazyplugin inapps/console/vite.config.tswalks the bundle atgenerateBundle, computes the eager closure fromchunk.imports(neverdynamicImports), and fails the build if any eager chunk holds an@objectstack/lintmodule. It followsviteMaplibreWorker's existing precedent in this file: an invisible bundling edge must fail the BUILD, loudly.It is two-part on purpose. Asserting "no eager chunk holds the linter" alone would also pass if the walk found nothing at all — a green check with no subject. A counter-probe runs first and demands a known-eager
@objectstack/spec, so the linter verdict is only ever read after the walk has proven it can see the very chunk the linter used to hide in.This costs no new CI job: the console is already built by
ci.yml(Build & E2E),live-e2e.yml, andperformance-budget.yml. Worth noting why the existing bundle budget never caught this — it weighs theindex-*.jsentry chunk alone against 350 KB, and these bytes were sitting in a vendor chunk. Filed separately as #5324.2. The existing pin in
scripts/__tests__/, re-pinned and strengthenedscripts/__tests__/vite-objectstack-spec-dist.test.tsmirrors the config'sVENDOR_OBJECTSTACK_TESTas a literalBASE_VENDOR_TESTand asserts on it twice. The lookahead turned both red. Neither assertion was relaxed — I worked out what each was protecting first, and both hold verbatim once the mirror is current:.sourceequality lives in "leaves all four flagged surfaces at their baseline values". Its intent is "an unsetOBJECTSTACK_SPEC_DISTleaves the group test inert", not "the regex is this string". Kept as exact equality.startsWithlives in the override-set case. Its intent is "the derivation WIDENS the baseline (appends an arm) rather than replacing it" —resolveSpecDistInjectionbuilds`${base.source}|${dir}${SEP}`. My change does not touch the derivation, so this holds unchanged. Kept as-is.Strengthened, because string equality alone would let a future edit keep the shape while changing membership. Added, reading the live config's regex rather than the mirror: the real pnpm lint id must not match; spec (both spellings) and client must match, so that rejection cannot pass by matching nothing;
@objectstack/lint-utilsmust match, pinning the exclusion to thelintpackage rather than alint*prefix. The same exclusion is also asserted on the widened regex in both the live-config case and theresolveSpecDistInjectionunit — otherwise a spec-dist build could silently re-eagerize the linter while a released build stayed lazy.Reverse-verification — predicted before running, then observed
Ablation A — the guard. Reverted the regex only, kept the build assertion. Predicted: non-zero exit at
generateBundle; the second assertion fires naming the vendor-objectstack chunk; the counter-probe does not fire (spec is eager either way); plain red. Observed: all four.BUILD_EXIT=1, stack inPluginContextImpl.generateBundle,counter-probe failedcount 0 /is in the EAGER closurecount 1, error namingassets/vendor-objectstack-BM4dkOHC.js— the baseline chunk hash exactly, so reverting reproduces the original bundle rather than some third state.Ablation B — the pin catches a config revert. Reverted
vite.config.tsonly, kept the updated pin. Predicted: red in exactly the 2 cases that read the live config; the helper-level cases stay green because they feedBASE_VENDOR_TESTintoresolveSpecDistInjectiondirectly and never read the config. Observed: exactly that —Tests 2 failed | 16 passed (18).Ablation C — the strengthening is load-bearing. Ablation B fails on
.sourcefirst, so it does not by itself exercise the new semantic assertions. Reverted the config and the mirror in lockstep, so string equality andstartsWithboth pass and only semantics can catch it. Observed:3 failed / 15 passed — one more than Ablation B, allexpected true to be false, and it reaches the helper-level case that a config-only revert cannot. Direction here is "diagnostics increase", not the plain red of A and B.The fix was committed before each ablation; restoration was proven byte-identical by
sha256sum -c, tree clean.Not taken: the documented trap
Switching the import to
@objectstack/lint/runtimeis not a fix and was not attempted — that subpath reaches 70 of the 72 modules the main entry reaches, is 93.6% of its size, and does not exportvalidateCapabilityReferencesat all. Recorded in objectstack#9772; the reasoning is now also inline in the guard's failure message, where the next person reaching for it will actually read it.Verification run at
6db2fa648All from the repository root unless noted:
pnpm exec vitest run scripts/__tests__/vite-objectstack-spec-dist.test.ts— 18/18 passed.pnpm exec vitest run scripts/__tests__/— 57 files, 1,291 tests, all passed. Widened deliberately, to check whether any other pin in that directory encodes the same pattern. None does: a repo-wide grep for the pattern finds exactly two sites, this test and the config (counter-probed — the same grep finds both known sites).pnpm exec vitest run apps/console/ --maxWorkers=2— 55 files, 658 tests, all passed (vitest root echoed as the repo root, so not the package-cwd phantom run).tsc --noEmit(console app program) — 0, afterpnpm --filter '@object-ui/console^...' build. The first run's 20TS2307/TS2882were unbuilt@object-ui/*dists, none involving this change.tsc -b tsconfig.node.json --force— 0. That is the program that actually containsvite.config.ts; the app program does not (include: ["src", "dev"]).eslinton both changed files — 0 errors. 3 pre-existingno-explicit-anywarnings in untouched regions of the test file; this diff adds none.check:control-bytes(4,714 files),check:phantom-deps,check:self-import,check:esm-specifiers— all 0.vite buildwith the guard active — exit 0, byte-for-byte the same output as the pre-guard fixed build.Changeset: none owed —
node scripts/check-changeset-presence.mjsreports0 of them under the src/ of a package the release covers. No label applied (this repo has noskip-changesetmechanism).Scope check: in-flight #5254's file surface is
packages/components/src/renderers/form/**— no overlap.Generated by Claude Code