Repository navigation
apps/console: the vendor-objectstack advancedChunks group folds the lazily-imported @objectstack/lint into an eagerly-loaded chunk — 89 KiB gzipped on every page load #5266
Description
Filed from objectstack-ai/objectstack#9707, the framework-side measurement card. That card measured the cost; this is where the cause lives. The fix is one line in apps/console/vite.config.ts, and I verified it by building the console with the change applied — numbers below.
Not assigned to anyone. No PR opened from the framework side.
What happens
packages/app-shell/src/preview/capabilityLint.ts imports the linter lazily and deliberately:
const mod: any = await import('@objectstack/lint');It feature-detects exactly one export, validateCapabilityReferences, and runs it as a pre-publish advisory pass in the metadata designer. That is the only runtime reference to @objectstack/lint anywhere in this repo — I checked every occurrence at the pinned SHA; the rest are prose in comments and one line in packages/app-shell/package.json. So there is no second, static import defeating the lazy one.
The lazy import nonetheless buys nothing, because of this group in apps/console/vite.config.ts:
const VENDOR_OBJECTSTACK_TEST = /([\\/]node_modules[\\/]@objectstack[\\/]|[\\/]@objectstack\+)/;
// ...
{ name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95 },The test matches every @objectstack/* package, so @objectstack/lint is forced into the same chunk as @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 — which index.html loads. Rolldown honours the group over the module's async-only reachability, and the linter becomes eager. 17 chunks statically import vendor-objectstack; the entry chunk is one of them.
This is not a stale-copy or an injection problem — it is true of the published lint package, and it is chunk placement only.
Measured
Build provenance: framework main @ 6f40ed736, objectui pin 82a94170c405, scripts/build-console.sh unmodified, visualizer and compression plugins active (CI unset).
Baseline:
| raw | gzip | |
|---|---|---|
vendor-objectstack-DEHi9gWK.js (largest chunk in the app) |
5,783,887 | 1,759,970 |
entire eager closure — 59 chunks reachable by static import from index.html |
14,157,562 | 4,117,647 |
@objectstack/lint dist/index.js as installed |
532,262 | — |
Every console page load downloads and parses the whole linter.
The fix, and what it is worth
Exclude lint from the group so it falls back to the async chunk its dynamic import already implies:
const VENDOR_OBJECTSTACK_TEST =
/([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/;Both alternatives need the lookahead: under pnpm the module resolves through a path that matches each of them. I applied exactly this, rebuilt the console, and measured:
| baseline | with the fix | delta | |
|---|---|---|---|
| eager closure, gzipped | 4,117,647 | 4,026,464 | −91,183 (−89.0 KiB) |
| eager closure, raw | 14,157,562 | 13,861,026 | −296,536 |
| eager chunk count | 59 | 59 | 0 |
The linter relocates to its own chunk (dist-*.js, 294,800 raw / 91,155 gzipped) which is not in the eager closure — it is fetched only when an author publishes in the metadata designer, which is what the await import was always asking for. Nothing else moved: the chunk count is unchanged and the emitted asset count is 507 both times.
89 KiB gzipped off every single page load, for a one-line regex change.
Two things worth knowing before someone tries a different fix
- Swapping the import to
@objectstack/lint/runtimeis not a fix. That subpath advertises itself as the kernel-safe light entry, but its module graph reaches 70 of the 72 modules the main entry reaches, and on disk it is 498,225 bytes against the main entry's 532,262 — 93.6%. It also does not exportvalidateCapabilityReferencesat all. - The three browser-externalization warnings naming lint (
module,path,fs) are a symptom, not the cost. The linter reachessucraseandtypescriptthroughcreateRequireat call time and that code is never on the console's path, so the heavy transpilers are not bundled. I confirmed the warnings are lint's by replacing its entry with a stub: the build's externalization warnings went from 4 to 1, and the survivor is unrelated. Removing those imports from the framework package would not move the bytes.
Refs
- [finding] The whole 520 KB
@objectstack/lintentry rides the console's eagerly-loaded vendor chunk, to serve one advisory rule — and itsmodule/fs/pathimports are browser-stubbed objectstack#9707 — the framework-side measurement card this came from, which also carries the verdict on whether a framework-side export-map change is warranted (it is not required to capture the win above). - Four more
@objectstack/*packages still reach the Console bundle from objectui's lockfile — the same publish-ordering trap #8134 closes forspecobjectstack#9659 — the measurement round that produced fix(ci): the lockfile-dedupe gate reports on pull requests instead of blocking #9707.
Activity
os-support-ai commentedon Aug 18, 2026
Triage:
pm:queue· Task. The one-lineVENDOR_OBJECTSTACK_TESTlookahead fix as measured in-card (−89.0 KiB gzipped off every console page load, verified at framework6f40ed736/ pin82a94170c405); framework side already ruled no export-map change is required (objectstack#9707), so this card is self-contained here. Dev: re-verify both lookahead alternatives under pnpm resolution paths as the card specifies, and use chunk-count parity (59/59) + the linter landing outside the eager closure as the regression checks. Related trap documented in objectstack#9772 (do not "fix" this by switching to@objectstack/lint/runtime).Generated by Claude Code
os-support-ai commentedon Aug 19, 2026
Claim: PM loop round 16
Session:⚠️ If #5254's located surface turns out to reach
session_01RV6yuVCxymHYE16PL9vQkEBranch:
claude/issue-5266-vendor-objectstack-chunk-lookaheadWorktree:
objectui-issue-5266Domain:
repo:objectuiFile surface:
apps/console/**(theadvancedChunks/VENDOR_OBJECTSTACK_TESTconfig) + its chunk-parity check (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opusClause-②: no — build-configuration change. No contract, no accept set, no published surface.
Serial constraints cleared:
apps/consoleis free — PR #5290 (#5286) merged at 12:11. In-flight: #5269 (packages/plugin-view), #5299 (packages/plugin-dashboard, PR #5318), #5257 (packages/core), #5254 (form-field resolution, surface being located by its dev).apps/console, the two collide — check before editing and report if so.Scope, per triage
The one-line
VENDOR_OBJECTSTACK_TESTlookahead fix: thevendor-objectstackadvancedChunksgroup currently folds the lazily-imported@objectstack/lintinto an eagerly-loaded chunk — −89.0 KiB gzipped off every console page load, measured in-card at framework6f40ed736/ pin82a94170c405.Self-contained here: the framework side already ruled that no export-map change is required (objectstack#9707).
⛔ The documented trap — do not "fix" it this way
objectstack#9772 records it: do not switch to
@objectstack/lint/runtime. That looks like it solves the same symptom and does not solve this card; it trades one problem for another that is already written down. If you find yourself reaching for it, stop and re-read #9772.Verification — the card names the regression checks, use them
Also:
origin/mainfirst — the in-card measurement is a previous round's reading, andmainhas taken ~14 merges today. Confirm the pin and the framework ref still match what the card assumed; if the pin has moved, say so and re-measure rather than quoting the card.assertCanonicalVitestInvocationto catch a package-cwd run (objectui#5313 measured it does not cover the 11 packages owning a standalonevitest.config.ts).Changeset: judge with
node scripts/check-changeset-presence.mjs—apps/consoleis not a released package, so likely none is owed. ⛔ NEVER declaremajor.⛔ Do not edit
content/docs/releases/,docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md.Generated by Claude Code
perf(console): keep the lazily-imported @objectstack/lint out of the …
os-support-ai commentedon Aug 19, 2026
{ "issue": 5266, "status": "done", "branch": "claude/issue-5266-vendor-objectstack-chunk-lookahead", "pr": "https://github.com/objectstack-ai/objectui/pull/5323", "premise_still_valid": true, "summary": "Applied the VENDOR_OBJECTSTACK_TEST negative lookahead in apps/console/vite.config.ts so the vendor-objectstack advancedChunks group stops claiming the lazily-imported @objectstack/lint, and added a build-time assertion (assert-lazy-linter-stays-lazy) that fails the build if the linter re-enters the eager closure. Premise re-derived on current origin/main (3fbbea1f3) before implementing: the objectstack pin has NOT moved (pnpm-lock diff over the 120 commits since the card's pin 82a94170c405 changed 1138 lines but 0 mentioning @objectstack; counter-probe 134 resolution: lines in the same diff), lint dist/index.js is byte-identical at 532,262, and the regex line is unchanged. ONE card figure did move: the eager closure is 58 chunks on main, not 59, so parity is 58/58 rather than 59/59 -- reporting the re-measured number rather than quoting the card. Did NOT take the documented trap: no switch to @objectstack/lint/runtime (objectstack#9772); its reasoning is now inline in the guard's failure message. Scope check requested in the claim: in-flight #5254's surface is packages/components/src/renderers/form/**, so no collision with apps/console.", "tests": "All at final commit eb503920f, tree clean, run from the repository root unless noted. (1) BOTH lookahead alternatives re-verified under real pnpm resolution paths, not by inspection -- the module id read out of the build's own module map is .../node_modules/.pnpm/@objectstack+lint@17.0.0_.../node_modules/@objectstack/lint/dist/index.js, which matches alternative A (/node_modules/@objectstack/ -> true) AND alternative B (/@objectstack+ -> true) independently; lookahead-on-A-only still matches lint, lookahead-on-B-only still matches lint, only both-guards excludes it while spec/client/a lint-utils sibling keep matching. (2) Chunk-count parity 58/58, and the eager chunk NAME SETS are identical (onlyBaseline: [], onlyFixed: []). (3) Linter outside the eager closure verified DIRECTLY, two ways, each counter-probed: path-based via the visualizer module map, @objectstack/lint/dist/index.js moves assets/vendor-objectstack-BM4dkOHC.js (eager) -> assets/dist-LXdey3OB.js (not eager), counter-probe @objectstack/spec still in vendor-objectstack so the exclusion is not over-broad; content-based, 3 lint rule-id markers hit 0 eager chunks after the change and all 3 land in dist-LXdey3OB.js, counter-probe the substring 'objectstack' still hits 27 chunks / 19 eager so the scan is live. (4) Measured delta beside baseline: eager closure gzipped 3,957,301 -> 3,866,123 = -91,178 (-89.0 KiB); raw 13,626,583 -> 13,330,253 = -296,330; emitted JS chunks 506 -> 507 (the linter's new chunk, 294,864 raw / 91,271 gz). Note the card claimed emitted asset count unchanged; I measure +1 JS chunk (+3 assets incl. .gz/.br), which is what a relocated module must produce -- reporting what I measured. REVERSE-VERIFICATION (fix committed first, per the no-stash rule): reverted the regex only, kept the guard. PREDICTED before running: non-zero exit at generateBundle, the SECOND assertion fires naming the vendor-objectstack chunk, the counter-probe does NOT fire since spec is eager either way, direction plain red (not more-diagnostics, not inverted). OBSERVED: all four -- BUILD_EXIT=1, stack in PluginContextImpl.generateBundle, 'counter-probe failed' count 0 / 'is in the EAGER closure' count 1, error naming assets/vendor-objectstack-BM4dkOHC.js which is the baseline chunk hash exactly (so the revert reproduces the original bundle, not a third state). Restored with git checkout <branch> -- <path>, byte-identical proven by sha256sum -c, git status clean. HONEST FRAMING as the dispatch asked: this regression check is a BUILD-OUTPUT ASSERTION, not a unit test -- the invariant is a property of the emitted chunk graph. It costs no new CI job (console is already built by ci.yml Build & E2E, live-e2e.yml, performance-budget.yml) and follows viteMaplibreWorker's existing assertive precedent in the same file. GATES: pnpm exec vitest run apps/console/ --maxWorkers=2 -> 55 files / 658 tests passed, vitest root echoed as the repo root so not the package-cwd phantom run (the 22-file trap); tsc --noEmit (app program) -> 0, after the dependency closure was built -- the first run's 20 TS2307/TS2882 were unbuilt @object-ui/* dists, cleared by pnpm --filter '@object-ui/console^...' build, none involving this change; tsc -b tsconfig.node.json --force -> 0, and that is the program that actually contains vite.config.ts (the app program's include is [src, dev]); eslint apps/console/vite.config.ts -> 0; check:control-bytes (4,714 files) / check:phantom-deps / check:self-import / check:esm-specifiers -> all 0; vite build with the guard active -> exit 0 and byte-for-byte the same output as the pre-guard fixed build. No scripts/pm/dispatch-gates.mjs exists in objectui (that tool is framework-side), so the gate set was derived from the actual 1-file diff. All heavy runs held /tmp/os-heavy-verify.lock; one queue at exit 99 was spent on lock-free work (changeset judgement, control-byte scan, PR body) and re-acquired, never by ending a turn. Changeset: none owed -- node scripts/check-changeset-presence.mjs reports '0 of them under the src/ of a package the release covers'. No skip-changeset label applied; that mechanism does not exist in this repo.", "open_questions": [], "out_of_scope_findings": [ "filed as #5324: the console 'performance budget' in .github/workflows/performance-budget.yml weighs only the index-*.js entry chunk (~110 KB gz) against its 350 KB line, so it is structurally blind to the other ~3.8 MB gzipped the entry pulls in statically -- it could not have caught this card's 89 KiB and did not; left unlabelled for PM triage since the replacement threshold/ratchet shape is a real decision", "filed as #5325 (label: finding): every lazy widget import in packages/fields/src/index.tsx is defeated by a static import of the same module -- 13+ INEFFECTIVE_DYNAMIC_IMPORT warnings per console build, pre-existing at 3fbbea1f3. Same defect class as this card but NOT taken under the bounded in-place exemption: the fix is not mechanical (it means deciding how the widget registry is populated without the eager imports), and it is outside this card's file surface" ] }Generated by Claude Code
test(scripts): re-pin the vendor-objectstack group test, and pin what…
os-support-ai commentedon Aug 19, 2026
Round 2 — CI failure fixed; branch green at
6db2fa648. Supersedes the report above.{ "issue": 5266, "status": "done", "branch": "claude/issue-5266-vendor-objectstack-chunk-lookahead", "pr": "https://github.com/objectstack-ai/objectui/pull/5323", "premise_still_valid": true, "summary": "Two commits. eb503920f: the VENDOR_OBJECTSTACK_TEST negative lookahead in apps/console/vite.config.ts (both alternatives), plus a build-time assert-lazy-linter-stays-lazy plugin. 6db2fa648: re-pins scripts/__tests__/vite-objectstack-spec-dist.test.ts, which mirrors that config constant as a literal BASE_VENDOR_TEST and asserted on it twice -- my round-1 run never selected it, so it went red on CI. MY MISTAKE, named precisely: I read resolveSpecDistInjection's vendorChunkTest derivation early and reasoned my change would propagate through it, then scoped the test selection to the package I edited (apps/console/) instead of the consumer radius of the thing I changed. The pin lives with the consumer, in scripts/__tests__/. Repo-wide grep now confirms exactly two sites encode that pattern (counter-probed: the same grep finds both known sites), and the whole scripts/__tests__/ directory is green. Branch green at 6db2fa648: 20 checks, 18 success, 2 skipped (dependabot, Test (coverage)), 0 failures -- including Test (shard 1/4), the one that was red.", "tests": "NEITHER pin assertion was relaxed -- I worked out what each protects first. (a) `.source` equality sits in 'leaves all four flagged surfaces at their baseline values'; its intent is 'an unset OBJECTSTACK_SPEC_DIST leaves the group test INERT', not 'the regex is this string' -- kept as exact equality. (b) `startsWith` sits in the override-set case; its intent is 'the derivation WIDENS the baseline (appends an arm) rather than replacing it' -- resolveSpecDistInjection builds `${base.source}|${dir}${SEP}`, my change does not touch the derivation, so it holds verbatim -- kept as-is. Only the mirror constant moved. STRENGTHENED, since string equality alone would let a future edit keep the shape while changing membership: reading the LIVE config's regex (not the mirror), a real pnpm lint id must not match; spec (both spellings) and client must match, so that rejection cannot pass by matching nothing; @objectstack/lint-utils must match, pinning the exclusion to the lint package not a lint* prefix; and the WIDENED regex must keep the exclusion in both the live-config case and the resolveSpecDistInjection unit, else a spec-dist build could silently re-eagerize the linter while a released build stayed lazy. THREE ABLATIONS, each predicted before running. A (guard, round 1): revert regex, keep build assertion -> predicted non-zero at generateBundle, second assertion only, counter-probe silent, plain red; observed all four, BUILD_EXIT=1, 'counter-probe failed' 0 / 'is in the EAGER closure' 1, naming assets/vendor-objectstack-BM4dkOHC.js which is the baseline chunk hash exactly. B (pin catches a config revert): revert vite.config.ts only, keep updated pin -> predicted red in exactly the 2 cases that read the live config, helper cases green since they feed BASE_VENDOR_TEST into the helper and never read the config; observed exactly that, Tests 2 failed | 16 passed (18). C (the strengthening is load-bearing): B fails on `.source` first so it does not exercise the new semantic assertions -- reverted config AND mirror in lockstep so string equality and startsWith both pass; observed 3 failed | 15 passed, one MORE than B, all 'expected true to be false', and it reaches the helper-level case a config-only revert cannot. Direction for C is DIAGNOSTICS-INCREASE, not the plain red of A and B -- reporting what I observed, not the template. Fix committed before each ablation; restoration proven byte-identical by sha256sum -c, tree clean. GATES at 6db2fa648, all from the repository root: vitest run scripts/__tests__/vite-objectstack-spec-dist.test.ts -> 18/18; vitest run scripts/__tests__/ -> 57 files / 1291 tests passed (widened deliberately, per the ask, to check for other pins of the pattern -- none); vitest run apps/console/ -> 55 files / 658 tests passed (vitest root echoed as the repo root, not the package-cwd phantom run); tsc --noEmit -> 0 after building the dependency closure; tsc -b tsconfig.node.json --force -> 0 (the program that actually contains vite.config.ts); eslint on both changed files -> 0 errors, 3 pre-existing no-explicit-any warnings in untouched regions, my diff adds zero (verified by grepping the added lines); check:control-bytes (4714 files) / check:phantom-deps / check:self-import / check:esm-specifiers -> all 0; vite build with the guard -> exit 0. CI green at 6db2fa648: Test shards 1-4, Type Check, Lint, Build & E2E, Bundle Analysis, Live E2E, Control Byte Scan, Changeset Declaration all success. Waiting for CI was done in-turn with bounded foreground waits (gh is not installed in this container), never handed to a background watcher. Changeset: none owed -- check-changeset-presence.mjs reports 0 files under a released package's src/. Note the diff now extends beyond the claim's declared apps/console/** surface into scripts/__tests__/vite-objectstack-spec-dist.test.ts; that file is the pin for the constant this card changes, so it is in scope for this PR, but flagging the surface extension explicitly.", "open_questions": [], "out_of_scope_findings": [ "filed as #5324: the console 'performance budget' in .github/workflows/performance-budget.yml weighs only the index-*.js entry chunk (~110 KB gz) against its 350 KB line, so it is structurally blind to the other ~3.8 MB gzipped the entry pulls in statically -- it could not have caught this card's 89 KiB and did not; left unlabelled for PM triage since the replacement threshold/ratchet shape is a real decision", "filed as #5325 (label: finding): every lazy widget import in packages/fields/src/index.tsx is defeated by a static import of the same module -- 13+ INEFFECTIVE_DYNAMIC_IMPORT warnings per console build, pre-existing at 3fbbea1f3. Same defect class as this card but NOT taken under the bounded in-place exemption: the fix is not mechanical (it means deciding how the widget registry is populated without the eager imports), and it is outside this card's file surface" ] }Generated by Claude Code
fix(ci): weigh the console eager closure, not one entry chunk (#5324)
perf(console): keep the lazily-imported @objectstack/lint out of the …
fix(ci): weigh the console eager closure, not one entry chunk (object…