Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

The console "performance budget" weighs only the index-*.js entry chunk, so it is structurally blind to the other ~3.8 MB gzipped the entry pulls in statically #5324

Closed

Description

@os-support-ai

Found while measuring #5266 (PR #5323). Filing rather than fixing — out of that card's scope.

.github/workflows/performance-budget.yml is named Bundle Analysis and its step is Check console performance budget, but what it actually measures is one file:

MAX_ENTRY_GZIP_KB=350
ENTRY_FILE=$(find "$DIST_DIR" -name 'index-*.js' -not -name '*.gz' -not -name '*.br' | head -1)
GZIP_BYTES=$(gzip -c "$ENTRY_FILE" | wc -c)

The entry chunk is not what a page load costs. index-*.js statically imports a closure of other chunks, and index.html cannot render without them — the browser fetches and parses all of it before the app runs.

Measured on 3fbbea1f3, walking static imports only from index.html:

gzipped
index-*.js alone — what the budget weighs ~110 KB, comfortably under the 350 KB line
the full eager closure — 58 chunks 3,957,301 bytes

So the budget passes on ~2.8% of the payload it claims to govern.

This is not theoretical: #5266 was 89.0 KiB gzipped added to every console page load, and it landed in vendor-objectstack-*.js. The budget could not have caught it, and did not. Any regression that lands outside index-*.js — which is most of them, since advancedChunks deliberately routes vendor and workspace code into named chunks — is invisible to this gate.

Worth deciding rather than assuming: a budget over the whole eager closure would need a threshold set from the current 3.96 MB, which is a different conversation from the 350 KB entry number. Options include budgeting the closure as a whole, budgeting per named chunk, or ratcheting (fail only on growth vs. main). The ratchet form is probably the most useful and the least likely to need re-tuning, but that is a call for whoever picks this up.

PR #5323 adds a targeted build-time assertion for the specific @objectstack/lint case, which is why that one is now guarded. This issue is about the general gate.

Reproduction of the closure figure is in PR #5323 — eager closure = BFS from the <script type="module"> in dist/index.html over static imports only (es-module-lexer, d === -1), gzip read from the build's own emitted .gz siblings.

Activity

  1. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    Contributor

    Triage: labelled finding (observation, awaiting first-touch grading) — the performance budget's blind spot is a gauge-design question (what the budget should weigh, and whether a per-chunk or aggregate ceiling is maintainable) rather than a named-landing-point defect; no user hits it today. Related: #5325 (the ineffective lazy imports are one concrete contributor to the statically-pulled weight this budget cannot see) — grade the pair together.


    Generated by Claude Code

  2. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Triage first-touch grading (finding centralized round, 2026-08-20, triage seat session session_016A4EBi3ky1mjTi6vD2kCvA): promote to pm:queue, type Task, paired with #5325 (one lane slot, #5325 is the concrete half). Re-verified on origin/main: performance-budget.yml still gzips a single index-*.js (~2.8% of the eager payload) against the 350 KB budget — the #5266 89 KiB regression landed in a vendor chunk and was structurally invisible to this gauge. Direction already converged on the card: ratchet the eager import closure against main rather than absolute-threshold the entry chunk.


    Generated by Claude Code

  3. added theissue type on Aug 20, 2026
  4. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim: PM loop round 21
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-5324-perf-budget-eager-closure
    Worktree: objectui-issue-5324
    Domain: repo:objectui
    File surface: .github/workflows/performance-budget.yml + its script; folded with #5325 per triage (one lane slot) (stop on breach; explain in the report)
    Container & model: L, mode:subagent
    Clause-②: no
    Serial constraints cleared: folded pair — #5325 claimed by the same agent


    Generated by Claude Code

  5. os-support-ai commented on Aug 21, 2026

    @os-support-ai
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 5324,
      "status": "needs_decision",
      "branch": "claude/issue-5324-perf-budget-eager-closure",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5466",
      "premise_still_valid": true,
      "summary": "#5324 is DONE and self-contained in PR #5466 (draft) — it does not depend on the decision below and can proceed on its own. The console performance budget now weighs the eager closure: a Vite plugin (emitEagerClosureReport) walks rolldown's chunk.imports from the entry chunks over static edges only, gzips the bytes written to disk, and writes dist/eager-closure.json; scripts/check-eager-closure-budget.mjs applies a 3,960,000-byte ceiling (today's 3,881,609 plus 2.02% headroom, deliberately narrower than the 89 KiB #5266 regression, so a repeat of that incident fails by 12.4 KB). The 350 KB entry-chunk budget is untouched. Premise re-measured on origin/main 77f846a8b and confirmed, with one refinement in the DEFECT'S FAVOUR: the entry chunk is 25,910 bytes gzipped, not ~110 KB, so the old gauge weighed 0.67% of the payload, not 2.8%. Chunk count (58) and closure total (3,881,609 vs the card's 3,957,301 on 3fbbea1f3) both reproduce the card's method. #5325 is NOT fixed and the PR does not close it: its fix is a public-surface change (14 widget classes are imported by name from @object-ui/fields by production code in plugin-detail, plugin-form, app-shell and apps/console — 16 MISSING_EXPORT build errors), and measured, it moves ZERO bytes (46 -> 35 warnings, closure 3,881,609 -> 3,881,938, still 58 chunks) because the console's `ui-components` advancedChunks group claims every packages/fields module and folds it into an eagerly imported chunk. Full evidence posted on #5325.",
      "tests": "All on the final commit 7515fa295 (`git rev-parse --short HEAD`), each heavy run under flock /tmp/os-heavy-verify.lock. `pnpm exec vitest run scripts/__tests__/ apps/console/ --maxWorkers=2` -> 'Test Files 117 passed (117) / Tests 2288 passed (2288)', exit 0 (27 new tests for the checker, 21 for the comment renderer). Gate union re-derived from the actual changed paths and re-run on 7515fa295, all exit 0: check:control-bytes ('OK (scanned 4523 tracked text file(s))'), lint:coverage ('46/46 packages linted, 0 with outstanding errors'), type-check:coverage ('45/46 via type-check ... 41/41 packages compile their tests'), changeset:check, check-changeset-presence, type-check:scripts, `pnpm --filter @object-ui/console type-check` (script name echoed in the output, so it was not a zero-match filter), lint:root and console lint (0 errors). Plus a manual control-byte scan of my 8 files: none. MEASUREMENT: two independent walks agree on the closure — the in-build plugin ('eager closure: 58/507 chunks, 3881609 bytes gzipped') and a throwaway regex BFS over dist/index.html (58 chunks, 3881609). REVERSE VERIFICATION, direction predicted before each run, and vite.config.ts is read directly by Vite so no package dist sits between the mutation and the run; each leg is a full console build. (1) Ablate the closure walk to follow no static edges — predicted: the counter-probe fails the BUILD rather than publishing a small number; observed: build exit 1, 'counter-probe failed: no eagerly loaded chunk contains a react-dom module ... (eager: 1/507)', i.e. the closure collapsed to exactly the entry chunk, which is the old gauge. (2) Restore leg, rebuilt and proven byte-identical to HEAD — predicted 58/507, 3,881,609, 46 warnings; observed identical, checker exit 0. (3) Report doctored with +89 KiB (a #5266 repeat) — predicted exit 1 / closure_status=fail; observed exit 1, '12.4 KB over the 3867.2 KB budget'. (4) Report absent — predicted exit 2 with no number published; observed exit 2, closure_gzip_kb empty. #5325 EXPERIMENT: predicted 35 warnings and an unchanged closure; observed exactly 35 and 3,881,938 (+329 bytes, 58 chunks) — no reduction. Its control leg: widgets/MarkdownContent.tsx has a working lazy import and no static importer anywhere, and still lands in the EAGER ui-components chunk; counter-probed by confirming the same method reports 'lazy' for the maplibre-gl and plugin-map chunks. One zero was counter-probed and rejected: the first removal build printed 0 INEFFECTIVE_DYNAMIC_IMPORT warnings, which meant the build had died with 16 MISSING_EXPORT errors, not that the warnings were fixed.",
      "open_questions": [
        {
          "question": "#5325: the defeated lazy widget imports in packages/fields cannot be fixed without a public-surface change, and — measured — fixing them buys 0 bytes on their own, because the console's `ui-components` advancedChunks group (test matches packages/(components|fields)) claims every fields module and folds it into an eagerly imported chunk, which overrides async-only reachability. Which route should the card take? (I did not pick one; packages/fields is untouched in PR #5466.)",
          "options": [
            "A. Chunking first, then the barrel. Stop `ui-components` from claiming packages/fields (or split fields into its own group), then remove the defeating static re-exports. The ONLY order in which the barrel work moves bytes. Cost: a bundling change needing its own measurement — that group exists to stop chunk explosion, and fields widgets are reached from many places — plus the same breaking API change to @object-ui/fields and a refactor of 4 production modules across 3 packages.",
            "B. Delete the decorative laziness. Honest and cheap: no bytes claimed, no false signal. Cost: it deletes getLazyFieldWidget, which is public API and is used by app-shell's ActionParamDialog, so it is also a breaking change — and it forecloses A.",
            "C. Leave the code, kill the noise. Cheapest; buys nothing. Cost: the 43 warnings keep scrolling past in every build, which is exactly the signal-erosion half the card objects to.",
            "D. Close #5325 as measured-and-not-worth-it, and re-file the chunk-group question as its own card. Cost: the dead intent stays in the source with no marker on it."
          ],
          "recommendation": "A, but explicitly SEQUENCED and not before the chunking half is measured — and the sequencing is the recommendation, not the destination. Real business need: the closure is 3.79 MB gzipped before first render, which is a real cost on every console page load for every user, so the underlying need is genuine — but the need is served by moving BYTES, and route B/C move none while A's first step is the only one that can. Nothing here should be started as barrel cleanup: that was measured at 0 bytes for a breaking change, which is the worst trade of the four. Long-term soundness: A is the only route that makes the declared intent (a lazy widget) and the emitted reality (an eager chunk) agree; B and C both resolve the contradiction by giving up the intent, and this is a renderer whose whole cost model is 'heavy widgets load when used'. A is also the route the #5324 gauge now makes checkable — before this PR nobody could have told which of the four moved bytes, which is how the intent stayed dead for so long. Making AI-written code hard to get wrong: the defect class is 'a declaration nothing enforces', and it has now cost twice (#5266, #5325). A ends with the declaration enforced by the eager-closure number itself; C leaves a dynamic import that silently does nothing, which is precisely the shape an AI author copies as a working pattern. The three axes conflict on cost, not direction: A is by far the most expensive and needs its own card and its own measurement, so if the startup-focus call is that this is not worth the engineering now, D (close as measured, re-file the chunk-group question) is the honest cheap answer — NOT C, which leaves the misleading source in place. Maintainer's call; the measurement is on #5325 either way."
        },
        {
          "question": "Should the eager-closure ceiling be TIGHTENED below today's payload, and on what schedule? Per the dispatch I landed a truthful current-state ceiling (3,960,000 bytes = today plus 2.02%) and did not lower it unilaterally. The gap is large and worth naming: 3.79 MB gzipped before first render is a bad payload, and the largest single eager chunk is vendor-objectstack at 1,493 KB (38% of the closure), followed by framework at 484 KB and ui-components at 380 KB.",
          "options": [
            "A. Leave the absolute ceiling and revisit after the closure is actually cleaved (e.g. after #5325 route A, or #5359). Simplest; the gate already catches a #5266-class regression today.",
            "B. Convert to a true ratchet against main (the direction triage originally converged on). Sharper, but it needs a main build per PR in CI, and two independently-green PRs can still combine to trip it in the merge queue.",
            "C. Add per-chunk budgets on top of the aggregate, so vendor-objectstack cannot grow unnoticed inside an aggregate that has headroom."
          ],
          "recommendation": "A now, C next, and B only if C proves insufficient. The shipped gate's headroom (78,391 bytes) is already narrower than the 89 KiB regression it exists to catch, so the marginal value of B over A today is small relative to its merge-queue cost — the queue rebuilds every PR on current main, so a strict ratchet turns any two concurrent size-adding PRs into a queue ejection neither author can see coming. C is the cheapest real tightening and targets where the mass actually is: one chunk holds 38% of the closure. All three are separate decisions from this PR, which is why none of them is in it."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5467: apps/console's own three INEFFECTIVE_DYNAMIC_IMPORT warnings — src/pages/{DocPage,DocsLayout,DocsSlug}.tsx are lazy-imported by AppContent.tsx and statically imported by App.tsx. Same defect class as #5325 but neither of #5325's blockers applies (app-internal, no published surface; apps/console/src is claimed by no advancedChunks group), so unlike #5325 this one plausibly CAN move bytes. Not measured — filed unassigned with the measurement as the first step, now cheap thanks to this PR's dist/eager-closure.json.",
        "not filed (already covered by open #5359): that card reports +60.1 KB gzipped on the console's eager graph 'that no budget weighs'. It is weighed now — the gauge in this PR makes that number visible and gateable, so #5359 gains a way to verify its own fix."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions