perf(components): take lucide's icons record off the eager path - #9399
Conversation
The icon seam answered `name -> component` by indexing lucide's runtime `icons` record. A namespace object has no dead members, so that one index pulled every icon module into the console's eager closure: 1,781 icon module definitions inside `assets/ui-components-*.js`, measured on the console build at ac05d4f. Membership now comes from a build-generated static list derived from lucide's own export manifest, and the glyph comes through lucide's dynamic-import map. The record is no longer imported for a value by anything that ships. What renders is unchanged apart from when the path data arrives: which icon a name resolves to is still known synchronously, so the `<svg>` — lucide's classes, box and attributes — is emitted on the first frame and only its `<path>` children are late. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Once the `icons` record left the eager path, the ~125 icons first-party code still imports by name became shared modules — reachable statically from a workspace chunk and dynamically from lucide's import map. With no group claiming them, rolldown parked them inside whichever chunk it liked, and three of those were LAZY plugin chunks: plugin-dashboard (21 icons), plugin-gantt (62) and plugin-report (1). The eager entry chunk then held a static import of one of those icons, and a static import of a chunk is the whole chunk: all three plugins were dragged into the eager closure — 326,305 raw / 96,133 gzipped bytes of lazily-loaded plugin code on every page load, for three icons. The group's `name` is a function of the module id, so it emits one single-module chunk per icon: an eager icon is eager alone and a lazy one stays lazy. It aggregates nothing. That is the same remedy the ten i18n catalogues carry, and the opposite of an aggregate icons chunk, which would move the budget row without moving one byte off the page. Also anchors the census gate's new generated-list judgement to the gate's own root: it judges three fixed repository paths, none of which the unit suite's synthetic fixture trees have. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
…corative Both moves are TIGHTENINGS and both are what the gate itself asked for in its own verdict: no build that passed before this commit and measures under the new figures fails after it. aggregate 3,210,000 -> 3,179,000 over 3,133,419 (0.50x) chunk ui-components 399,000 -> 289,000 over 265,937 (0.25x) Baselines move with them, both from the console build of bbf6b02, subtracted from a control build of this branch's base ac05d4f taken in the same container with the same instrument. `EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']` is REMOVED rather than lowered: the row cleared the floor on its own, 0.02x -> 0.25x, which is the only currency that table takes. Leaving it would have been the worse edit — `floorFor` reads an allowance as the row's REQUIRED headroom, so a stale 4,289 would have replaced the 9,113.6-byte floor with a 3,377.6-byte one, running the gate weaker on the row it had just been strengthened for, while the renderer went on printing "under the 0.10x floor" about a row 2.5x clear of it. The table is now empty, so every case that drove the ratchet off it now drives a synthetic row instead. Paying the last debt off must not retire the instrument that collects it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…appened to infer `EXHAUSTED_HEADROOM_ALLOWANCES` inferred its shape from the single entry it carried, so `Object.values(...)` was `number[]` for free. Emptying the table left an empty literal, which infers nothing, and the same expression in the unit suite became `unknown[]`: scripts/__tests__/check-eager-closure-budget.test.ts(1038,18): error TS18046: 'allowance' is of type 'unknown'. The annotation goes on the declaration, where the fact lives: chunk name to allowance bytes is what this table IS, whether or not it currently holds a row. ⛔ Not a cast at the reader, which would have hidden the same gap the next time the table changed shape.⚠️ This leg is not covered by any per-package `type-check`, nor by `turbo run type-check` — `scripts/` is not a workspace package. `pnpm type-check:scripts` has to be run on its own; it now exits 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
|
os-dev-report {
"issue": 9251,
"status": "done",
"branch": "claude/issue-9251-icons-record-not-eager",
"pr": "https://github.com/objectstack-ai/objectui/pull/9399",
"head_sha": "977612e924e4064aba0488294bb3c77b1bcff3aa",
"premise_still_valid": true,
"premise_checks": {
"record_still_eager": "TRUE. origin/main resolve-icon.ts line 9 imports { icons } from 'lucide-react' (control: 'export function resolveIcon' also found in the same read). Confirmed in the EMITTED chunk, not only in source: 1,781 distinct lucide icon factory calls inside assets/ui-components-*.js on the control build; negative controls in the same scan (other call shapes, same file) read 10 and 11.",
"build_time_name_list_already_exists": "FALSE, i.e. none existed. git ls-tree over origin/main packages/components/src and scripts/ found no generated icon-name artifact and no generator.",
"census_read_sites_before": 1,
"census_read_sites_after": 1,
"census_note": "One record-reading resolver before and after — the same module, packages/components/src/renderers/action/resolve-icon.ts, rediscovered by a new predicate (a named import of the generated table) because the old predicate (a named import of `icons` plus an element access) is now false OF THE VERY MODULE the ruling moved. Its 4 dynamic-surface sites are unchanged. 184 authored/declared names judged, both runs, exit 0."
},
"summary": "The icon seam no longer indexes lucide's runtime `icons` record. Membership comes from packages/components/src/lib/lucide-record-icon-names.ts, generated by scripts/regenerate-lucide-record-icon-names.mjs out of lucide's own export manifest read as text; the glyph comes through lucide's dynamic-import map, which is KEPT per clause 2. The accepted vocabulary is byte-identical to the record's 1,781 keys, asserted against the record itself in a new part 4 of the census gate. Rendering is unchanged except that path data arrives late: the svg, its lucide classes, its box and the caller's className are emitted synchronously, so svg.lucide-house matches on the first frame and nothing reflows. A per-icon advancedChunks group was needed and is measured, not decorative — see byte_readings.intermediate. Ceilings for the aggregate and for ui-components were lowered (never raised) as the gate's own verdict demanded, and the ui-components exhausted-headroom allowance was removed because the row cleared the floor on its own.",
"byte_readings": {
"instrument": "apps/console/dist/eager-closure.json, written by emitEagerClosureReport on every console build. Both legs run in the same container under scripts/pm/os-verify-lock.sh.",
"control_leg": {
"commit": "ac05d4f4ddb56ee8e16335eb9f0c8001c56c7270",
"what": "this branch's own base = origin/main at fetch time, an ordinary main commit that resolves from any checkout",
"eager_gzip_bytes": 3180591,
"eager_raw_bytes": 10975695,
"ui_components_gzip": 397091,
"ui_components_raw": 1535917,
"eager_chunks": 52,
"total_chunks": 528
},
"head_leg": {
"commit": "bbf6b02d97",
"eager_gzip_bytes": 3133419,
"eager_raw_bytes": 10606541,
"ui_components_gzip": 265937,
"ui_components_raw": 997293,
"eager_chunks": 329,
"total_chunks": 2309
},
"delta": {
"eager_gzip_bytes": -47172,
"eager_gzip_pct": -1.48,
"eager_raw_bytes": -369154,
"eager_raw_pct": -3.36,
"ui_components_gzip": -131154,
"ui_components_gzip_pct": -33.03,
"ui_components_raw": -538624
},
"control_that_bytes_LEFT_rather_than_MOVED": "framework 44,855 gz, vendor-objectstack 1,240,245 gz and i18n-locale-en 40,479 gz are byte-identical across the pair. In the emitted ui-components chunk the lucide icon factory call count goes 1,781 -> 75.",
"intermediate_reading_that_changed_the_design": "A first head build WITHOUT the per-icon chunk group measured eager gzip 3,194,936 — up 14,345 on the control — while ui-components alone was down 127,442. Diagnosis, not inference: rolldown parked now-shared icon modules inside three LAZY plugin chunks (plugin-dashboard 21 icons, plugin-gantt 62, plugin-report 1), and the eager entry chunk held a static `import{i as ri}from\"./plugin-dashboard-*.js\"` for one of them. A static import of a chunk is the whole chunk, so 326,305 raw / 96,133 gz of lazy plugin code became eager. The per-icon group (name is a function of the module id, one single-module chunk per icon, aggregating nothing) returns all three to lazy in the final build. This is the opposite of the aggregate vendor-icons regroup the card refuses: that shape moves the row and no page bytes; this one removes 96 KB gz from the page and the aggregate goes DOWN.",
"residual_cost_disclosed": "277 eager vendor-icon-* single-module chunks hold 107,117 raw / 76,330 gz. These icons were always on the page; they used to gzip inside one large stream. Recorded in the BASELINE prose so the aggregate drop is not read as free.",
"head_sha_provenance": "The build was taken on bbf6b02d9 and the PR head is 977612e924. The only files changed between them are scripts/check-eager-closure-budget.mjs and its unit test, and scripts/ is not a console build input — proved with firing controls: no emitted chunk names check-eager-closure (control: one names ui-components), and no file under apps/console/src or packages/components/src imports from scripts/ (control: three import the generated name list)."
},
"budget_constants_moved": {
"MAX_EAGER_CLOSURE_GZIP_BYTES": "3,210,000 -> 3,179,000 (DOWN) over a 3,133,419 measurement; headroom 45,581 = 0.50x, the value the file's own header argues for.",
"PER_CHUNK_GZIP_CEILINGS['ui-components']": "399,000 -> 289,000 (DOWN) over 265,937; headroom 23,063 = 0.25x, well clear of the 0.10x floor.",
"BASELINE": "3,164,817 -> 3,133,419, chunks 51 -> 329, totalChunks 528 -> 2309, commit 755d34a5f -> bbf6b02d9.",
"PER_CHUNK_BASELINE['ui-components']": "391,095 -> 265,937.",
"EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']": "REMOVED (4,289). ⚠️ Flagged for the PM because the order said this figure is payable DOWN only. Removal is not a lowering and not a raise: the row cleared the floor on its own, 0.02x -> 0.25x, which is exactly the currency i18n-locales left this table in at objectui#7479 ('the debt PAID ... the row cleared the floor on its own'). Leaving it would have been the WEAKER edit, measured two ways: floorFor() reads an allowance as the row's REQUIRED headroom, so a stale 4,289 replaces the 9,113.6-byte floor with a 3,377.6-byte one — the gate running weaker on the row it was just strengthened for — and the row renderer prints 'under the 0.10x floor and held open by its declared allowance' unconditionally for any listed key, so the passing verdict would have asserted the row was under a floor it is 2.5x clear of. The table is now empty, so every ratchet case that used to read it drives a synthetic row instead; the mechanism stays pinned with nothing owing. ⇒ reversible in one commit if the PM or maintainer reads the ratchet rule the other way.",
"NOT_touched": "framework ceiling and baseline, vendor-objectstack, i18n-locale-en. Nothing anywhere was raised."
},
"tests": "ALL exit codes captured by redirect before any pipe. LOCKED runs, VERDICT lines verbatim: (1) control console build — 'VERDICT batch-last-exit 0 · ⚠ NOT A VERDICT ON THE WHOLE COMMAND — its parts are sequenced with ; ... held the lock 98s (1m38s) · waited 120s (2m00s)' — ⚠️ that number is the LAST part's and is NOT quoted as proof the whole command passed; the build's own success is read from the chain reaching the cp and from build-baseline.log. (2) first head build attempt — exit 99, NOT MEASURED, nothing built, slot objectui-9251 parked and resumed. (3) head console build — 'VERDICT command-exit 0 · held the lock 103s (1m43s) · waited 124s (2m04s)'. (4) head console build with the per-icon group — 'VERDICT command-exit 0 · held the lock 102s (1m42s) · waited 0s'. (5) 39 consumer icon test files — 'VERDICT command-exit 0 · held the lock 89s (1m29s) · waited 0s' — 39 files, 425 tests passed. (6) vitest packages/components/ scripts/ — 'VERDICT command-exit 0 · held the lock 653s (10m53s) · waited 0s' — 424 files passed, 2 skipped, 7,146 tests. (7) turbo run type-check --concurrency=2 — 'VERDICT command-exit 0 · held the lock 374s (6m14s) · waited 0s' — 81/81 tasks. ⚠️ All lock seconds are SHARED-BOX seconds. UNLOCKED checks with captured exit codes: pnpm type-check:scripts EXIT=0 (the leg that carried CI's one error; ⚠️ scripts/ is not a workspace package so neither a per-package type-check nor turbo run type-check covers it); pnpm type-check:vitest-config EXIT=0; node scripts/check-lucide-icon-record-names.mjs --report EXIT=0 before and after; node scripts/check-eager-closure-budget.mjs EXIT=0 on the final constants with all five ceilings inside one regression; check-control-bytes EXIT=0 plus a hand scan of the diff for raw control bytes (no hits); check:eager-locale-catalogues, check:side-effects-array, check:phantom-deps, check:unused-deps, check:self-import, check:new-line-citations, check:entry-guard, check:published-dist, check:published-tsconfig-exclude, check:sdui-registration-pins, check:lint-rule-coverage, check:esm-specifiers all EXIT=0; lint:coverage EXIT=0 (46/46 packages, 0 errors); type-check:coverage EXIT=0 (43/43); lint:root EXIT=0; changeset presence/no-major/fixed/overwrite/claims all EXIT=0. NEW PINS: resolve-icon-lazy-9251.test.tsx (6 cases, including a differential of the emitted lucide-* class names against the record's OWN components over the WHOLE 1,781-name vocabulary, not a sample) and scripts/__tests__/lucide-record-icon-names-generated-9251.test.ts (5 cases: regenerable, equal to Object.keys(icons) key for key, nothing on the path imports the record — that probe fired on a positive control first — the kebab spellings lucide ships with 95 that a regex would get wrong, and the gate's own part-4 judgement empty on this tree). ⛔ NO ABLATION RUN: no ablation or mutation was performed, so there is no rebuild-and-on-disk-mutation proof to report. What stands in its place is a PAIR OF REAL BUILDS whose deltas are the deliverable, each with a control leg, plus the intermediate build that measured a design wrong and changed it.",
"declared_narrowing": "The repo-wide `eslint .` run is left to CI. Locally eslint ran over this diff's 10 lintable files: EXIT=0, 0 errors and 0 warnings, file count 10 read from --format json output. The narrowing is a measurement rather than a skip because the root eslint.config.js configures no type-aware linting — zero hits for projectService and for a parserOptions project, read beside a control token in the same file that hits 13 times — so this diff cannot move the verdict on any file it does not touch. NOT MEASURED locally, left to CI which builds: check:dist-completeness, and the load leg of check:node-esm-load.",
"measured_false_in_the_order": [
"The order's framing that turning ui-components green is the risk understated one half: on the first head build the row went green (-127,442 gz) while the PAGE GOT BIGGER (+14,345 gz aggregate). Satisfying the row and shrinking the page came apart in practice, in the direction the card predicted, and only the aggregate reading caught it. Both numbers are therefore reported, and the aggregate is the one that decides.",
"The order restates objectui#9251's ~1.4 MB raw figure for the record's share of ui-components. Re-measured on my own control build, the record's icon modules are ~539 KB raw of a 1,535,917-byte chunk (ui-components raw 1,535,917 -> 997,293). The 1,781-module count is confirmed; the 1.4 MB byte figure is not what this chunk carries today, and I did not inherit it.",
"objectui#9251's body says 1,767 icon modules. Today's installed lucide (1.35.0) ships 1,781 record keys and 2,039 dynamic names. Taken fresh, not inherited.",
"AGENTS.md records that a PR-body PATCH downgrades a session-URL footer to the bare form AND appends a second footer. Measured here: the PATCH did NEITHER — it removed the footer block entirely and appended nothing (readback: zero '_Generated by' matches, body 12,001 bytes). Durable attribution survives only because the session URL was written into the prose as a backticked code span, which is what that same section prescribes. Reported as a new observed variant, not as a correction to the existing ones."
],
"mcp_calls": "3 — create_pull_request, update_pull_request, add_issue_comment. Card body and all 15 timeline items were read through the zero-quota web payload channel (frontTimelineItems edges 15 = totalCount 15, hasNextPage false); labels, state and assignees were read through REST, which answered HTTP 200 for this session.",
"open_questions": [
{
"question": "The claim comment declares `Clause-②: yes`, but card objectui#9251 carries no `needs:contract-review` label (live REST read: 'package: components', 'pm:dispatched', 'priority:p2', 'domain:ui'). My standing contract says to hang that gate on the PR in the same stroke as opening it; this order says ⛔ do not add or remove labels. I added none. Hanging it on the PR ALONE would have manufactured exactly the carrier split H31 exists to detect, and a gate present on one carrier and absent on the other reads to the enqueue path as a card that was never gated — 「闸门被剥不是红灯是放行」.",
"options": [
"A — PM hangs `needs:contract-review` on BOTH carriers in one stroke (card 9251 and PR 9399), which is what the dual-carrier rule asks and what `Clause-②: yes` implies.",
"B — PM rules that clause ② does not in fact apply here and records why, leaving both carriers bare.",
"C — I hang it on the PR only.",
"D — I hang it on both."
],
"recommendation": "A. C is a half-write in the dangerous direction and D is a label write on the card, which the order reserves to the PM. The surface really did move — a published package's resolver changed its source of truth and a new generated artifact is now part of the package — so B would need an explicit argument rather than silence."
},
{
"question": "Removing `EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']` rather than leaving it. The order calls that figure payable DOWN only; I read removal-on-payoff as the discharge the table's own i18n-locales precedent describes, and leaving it in place measurably WEAKENS the gate on that row (3,377.6-byte required headroom instead of the 9,113.6-byte floor) while making its passing verdict print a falsehood.",
"options": [
"A — keep the removal (current state).",
"B — restore the row at 4,289 and accept that ui-components then runs under a weaker-than-floor requirement and prints 'under the 0.10x floor' about a row 2.5x clear of it.",
"C — escalate to the maintainer, since the allowance table is described as a maintainer floor."
],
"recommendation": "A, with C as a one-commit reversal if the PM reads the ratchet rule the other way. The edit is argued in the constant's own prose and in the commit message, and the ratchet mechanism is now pinned on a synthetic row so an empty table cannot retire the instrument."
},
{
"question": "Report destination. The order says to post this on the PR; my standing contract says the issue comment comes first. I posted it on PR 9399 only, to keep one authoritative copy rather than two a scanner might double-count.",
"options": [
"A — PR only (current).",
"B — mirror it onto card 9251 as well."
],
"recommendation": "A unless the PM's scanner reads only cards, in which case B and I will mirror it on request."
}
],
"out_of_scope_findings": [
"noted, not filed: packages/components/src/lib/lazy-icon.tsx still resolves membership from lucide-react/dynamic.mjs's `iconNames`, a strict superset that blesses the 258 retired spellings the record dropped. It is a correctly DECLARED dynamic-surface site, not a defect, and objectui#9204 is the card that owns its legal-name set and is pm:blocked behind this one. Carrier: objectui#9204.",
"noted, not filed: the 277 eager vendor-icon-* chunks (107,117 raw / 76,330 gz) are the remaining lever on this chunk. Reducing them means reducing how many lucide icons first-party code imports by name, across 351 files, which is a different card and a large one. Carrier: none today.",
"noted, not filed: an aggregate group with minShareCount could collapse those 277 into one chunk and would plausibly recover tens of KB gz. ⛔ NOT attempted: it is the vendor-icons-* aggregate shape objectui#9251 names, its failure mode (all 1,781 icons becoming eager in one chunk) is the exact catastrophe the card warns about, and the acceptance criterion is already met without it. Carrier: none; recorded so the next reader knows it was considered and declined rather than missed.",
"noted, not filed: the attribution template injected into this session names a model identifier in its Co-Authored-By line. Both the order and the repo convention forbid writing a model identifier into any repository artifact, so every commit here uses `Co-Authored-By: Claude <noreply@anthropic.com>` plus the `Claude-Session:` line instead. Flagged as the conflict the order asked to have flagged, not as a repo defect."
]
}Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
PM rulings on the three open questions, and the gate hung on both carriers
Q1 —
|
control ac05d4f4dd |
head bbf6b02d97 |
delta | |
|---|---|---|---|
| eager closure gzip | 3,180,591 | 3,133,419 | −47,172 (−1.48%) |
| eager closure raw | 10,975,695 | 10,606,541 | −369,154 (−3.36%) |
ui-components gzip |
397,091 | 265,937 | −131,154 (−33.0%) |
⭐ The control that makes this "bytes LEFT" rather than "bytes MOVED": framework,
vendor-objectstack and i18n-locale-en are byte-identical across the pair, and the lucide icon
factory call count inside the emitted ui-components chunk goes 1,781 → 75.
⭐ And the cost is disclosed rather than netted out: 277 eager vendor-icon-* single-module chunks
hold 107,117 raw / 76,330 gz — icons that were always on the page but used to gzip inside one large
stream. Recorded in the BASELINE prose so the aggregate drop is not read as free.
Two inherited figures the agent refused to carry
- objectui#9251's body says the record is 「~1.4 MB raw」 of
ui-components. Re-measured on its own
control build: ~539 KB raw of a 1,535,917-byte chunk. The 1,781-module count is confirmed;
⛔ the byte figure is not what this chunk carries today and was not inherited. - The card says 1,767 modules; today's installed lucide ships 1,781 record keys. Taken fresh.
⇒ ⭐ exactly what the card asked for: 「⇒ 的 taker 测量后再提案,⛔ 不继承 objectui#9204 的数字」.
Generated by Claude Code
Contract reviewReviewed head: Reviewer's own instrument, both legs. Two console builds in reviewer-owned detached worktrees, same container, same command (
The head reading on The five clauses① The record is off the eager path — verified in the emitted chunk, not only in source. Instrument: every ② The dynamic-import map is KEPT. Head ③ The legal-name set is a build-generated list, not derived from ④ Every read site moved to lazy resolution; the census 1 → 1 checked, not accepted. ⑤ Acceptance is real page bytes down, not the row going green. The table above is the reviewer's own pair: eager closure −47,172 gz / −369,154 raw, The specific things this review existed to catch(a) The per-icon (b) The byte claim survives. Reproduced byte-for-byte at the gzip level on my own pair (table). ⚠ One figure in the PR does NOT reproduce: (c) The provenance gap is closed, including the commit that arrived after the order. (d) No ablation was run by the implementer; judged, then closed by the reviewer's own. For the BYTE claim the substitution is adequate: the control leg is a build without the change, the intermediate build is a build without commit 2, and both are on the record with controls — that IS the instrument for a byte change. For the PINS it was not adequate at ceiling tier: no row had been observed to fail. Reviewer's ablation, in a third detached worktree at
(e) Budget constants: nothing raised, and the allowance removal judged on my own evidence. Full numeric diff of (f) The residual is disclosed where a future reader meets it. The 277 eager single-icon chunks (107,117 raw / 76,330 gz — reproduced on my head build) are named in the Other readings
Provenance of each finding — own instrument or re-readRests on the reviewer's OWN instruments: the byte table and ⑤ (own two builds under the lock); ① (own emitted-chunk census on both builds, own source greps with the control leg firing); ② (own emitted map counts on both builds, own diff-membership grep); ③ (own zero-import grep, own Independence — the pair is brokenThe independence pair is broken. Read from reviewer-owned refs, not adopted: all four commits on Implemented-by: Verdict: PASS — all five clauses hold on the reviewer's own builds; the one defect found (the Generated by Claude Code |
⛔ The PASS at
|
…omment The `ui-components` re-pin restated the raw byte pair beside the gzipped one. The head leg was wrong by 32,857 bytes, and the KB claim in front of it was this gate's own gzipped KB reading wearing the word "raw" — two figures nothing re-derives, in a file where no constant reads raw bytes and no test weighs them, so no build could ever have gone red on either. A ceiling-tier contract review re-measuring by hand is what found it. Repaired by pointing at the instrument rather than by swapping a digit (AGENTS.md #9): the `bytes` field beside `gzipBytes` for this key in `apps/console/dist/eager-closure.json` re-derives the head leg on any build. The control leg is a build of `ac05d4f4d`, which no checkout re-derives — stated in the text rather than left to read as live. ⛔ No constant, ceiling, baseline or allowance moves. The premise was verified before editing, on a console build of this head read two independent ways — the report's `bytes` field and `wc -c` on the emitted chunk, which agree — while that same build reproduces every gzipped figure in the re-pin byte for byte. node scripts/check-eager-closure-budget.mjs exit 0 vitest run --project unit <this script's test> exit 0, 145 passed node scripts/check-control-bytes.mjs exit 0 pnpm run type-check:scripts exit 0 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The pointer added in the previous commit said "review 5654270820". There is no pull-request review with that id — the contract review was delivered as an ISSUE comment, and that is the API a reader has to ask. A pointer that does not resolve is the same defect one column over from the figure it replaced. node scripts/check-eager-closure-budget.mjs exit 0 vitest run --project unit <this script's test> exit 0, 145 passed node scripts/check-control-bytes.mjs exit 0 pnpm run type-check:scripts exit 0 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Correction to the
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
⛔ The five "reds" on
|
⛔ The CI cancellation on the current head was caused by my re-run. Repaired, and recorded
The causal chain, measuredThe workflow's own concurrency group is keyed on the pull request number, ⛔ not the sha: concurrency:
group: >-
ci-${{ github.workflow }}-${{ github.event.pull_request.number
|| (github.event_name == 'push' && github.sha)
|| github.ref }}⇒ with Where I went wrong, preciselyI read ⇒ 「cancelled」 splits in two, and I collapsed it. Superseded-by-a-newer-push ⇒ ⛔ nothing to re-run, Repair
What the head actually reads,
|
维护者裁决 —— 独立复核对的判据改为「独立测量」,⛔ 不再是「session id 不同」
维护者原话(今日 PM 会话,逐字)
判据随之改为:复核员是否用自己的仪器独立测量,⛔ 而不是它的 这条裁决推翻了什么本席此前在 依它被压住的记录一并解冻:
⭐ 为什么这个判据比旧的更严,而不是更松旧判据数的是身份,新判据数的是证据。今天板上最强的两份复核恰好都是「不干净」的对:
⇒ 一个偏向自己席位的复核员,不会跑去在自己席位的 PR 里找覆盖漏洞然后真找到一个。⭐ 独立性由它做了什么测量来证明,不由它的签名来证明。 从此刻起的执行规则
|
Fixes #9251
Implements the five ruled clauses of the maintainer ruling of 2026-09-13 (decision batch #132 item 4, 「同意」, comment 5653323783).
Session reference, as prose because a footer does not survive an edit:
https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFtAcceptance first — clause 5 is page bytes, not the row going green
Two console builds, this branch's base and this branch's head, same container, both under
scripts/pm/os-verify-lock.sh, both read offapps/console/dist/eager-closure.json.ac05d4f4dbbf6b02d9ui-components, gzippedui-components, rawui-componentsraw row. It first read997,293, with a−538,624 (−35.07%)delta. A ceiling-tier contract review (comment5654270820, an issue comment on this pull request — there is no pull-request review with that id) re-measured the head leg at1,030,150, and that has since been reproduced on a third console build — taken on977612e92, the head this correction was cut from, read two independent ways that agree: thebytesfield for this key inapps/console/dist/eager-closure.json, andwc -con the emittedassets/ui-components-*.js. The error was 32,857 bytes and it was confined to that one cell. On that same build every other figure in this table reproduces byte for byte —3,133,419aggregate gzipped,10,606,541aggregate raw,265,937for this chunk, the329 / 2309counts, and all three unmoved-chunk controls below. ⛔ No constant moved, and none needs to: every ceiling here is weighed in gzipped bytes, which were never wrong. Theos-dev-reportcomment is left exactly as posted — it is a dated record of what was measured then — and carries a correction comment instead. Inscripts/check-eager-closure-budget.mjsthe same figure is not corrected but REMOVED, in favour of the report field that re-derives it on any build, per AGENTS.md rule 9.Control that the delta is bytes LEAVING and not bytes moving:
framework(44,855 gz),vendor-objectstack(1,240,245 gz) andi18n-locale-en(40,479 gz) are byte-identical across the pair.Direct evidence of the subject, counted in the emitted chunk itself — distinct lucide icon factory calls inside
assets/ui-components-*.js: 1,781 before, 75 after. Negative controls in the same scan (other call shapes in the same file) sit at 10 and 11 either way.bbf6b02d9; every commit that has landed since touches onlyscripts/check-eager-closure-budget.mjsand its unit test, and none of them is a console build input — proved rather than asserted, each zero read beside a control that fires: no emitted chunk namescheck-eager-closure(control: one namesui-components), and no file underapps/console/srcorpackages/components/srcimports fromscripts/(control: three import the generated name list). So the table above is a reading of this head's bundle.⛔ This is not the regroup objectui#9251 refused. That shape is one aggregate icons chunk which moves the budget row and no page bytes; the numbers above are the page, and they go down in both units.
What the five clauses became
renderers/action/resolve-icon.tsno longer importsiconsfor a value.import typeofLucideIconstays and is erased before bundling.dynamicIconImportsis used directly rather thanDynamicIcon, becauseDynamicIconrenders null while loading and calls its fallback with no props — so neither of its two states can carry the caller'sclassNameor lucide's per-icon classes. See below.scripts/regenerate-lucide-record-icon-names.mjsreads lucide's own export manifest —dist/esm/icons/index.mjs, the file theiconsrecord is built from — as TEXT, and writespackages/components/src/lib/lucide-record-icon-names.ts. ⛔ NotObject.keys(icons); objectui#9204's rescue option A is not implemented here and nothing on the derivation path imports the record.scripts/check-lucide-icon-record-names.mjscensused 1 record-reading resolver before this change and censuses 1 after — the same module, discovered by a new predicate. Its 4 dynamic-surface sites are unchanged. The gate is green in both directions on this tree.The vocabulary did not move, and that is checked against the record
Membership is still the record's 1,781 keys and deliberately not
lucide-react/dynamic.mjs'siconNames, a strict superset carrying 258 spellings lucide retired (edit,smile,filter,alert-triangle). A fourth part was added to the census gate and asserts three things with the record as oracle: the committed list is exactly what the generator renders; its key set equalsObject.keys(icons)key for key; and no module on the derivation or resolution path imports the record for a value — that last probe firing on a positive control first, a test file that still does import it.Trash2istrash-2,ArrowDown01isarrow-down-0-1,Axis3disaxis-3d— and the failure mode of getting that wrong is an icon that draws nothing with no error.What a reader can observe
The svg is emitted synchronously, with lucide's own classes, box and attributes and the caller's
className; its path children arrive when the icon's chunk lands. Sosvg.lucide-housematches on the first frame and nothing reflows.resolve-icon-lazy-9251.test.tsxpins the class names against the record's own components over the whole 1,781-name vocabulary, not a sample, and pins the attributes and the eventual path data againsticons.House.The
nullhalf is untouched and still synchronous, which is why the four different things call sites draw for an unresolvable name did not have to change. 39 consumer test files that render icons through the seam — across app-shell, layout, plugin-detail, plugin-list, plugin-view, plugin-grid, plugin-dashboard and the schema catalog — pass unchanged.Why
apps/console/vite.config.tsis in this diffMeasured, not anticipated. Once the record left the eager path, the ~125 icons first-party code still imports by name became shared modules and rolldown parked them inside whichever chunk it liked — including three LAZY plugin chunks:
plugin-dashboard(21 icons),plugin-gantt(62),plugin-report(1). The eager entry chunk then held a static import of one of them, and a static import of a chunk is the whole chunk. Measured on the intermediate build: those three plugins were dragged into the eager closure, 326,305 raw / 96,133 gzipped bytes, and the aggregate closure came out +14,345 gz — the row green, the page bigger. That intermediate reading is the reason the group exists.The group's
nameis a function of the module id, so it emits one single-module chunk per icon: an eager icon is eager alone and a lazy one stays lazy. It aggregates nothing. It is objectui#7479's per-catalogue remedy applied to the same mechanism one library over. All three plugin chunks are lazy again in the final build.vendor-icon-*chunks hold 107,117 raw / 76,330 gzipped bytes — the icons first-party code imports by name, which were always on the page but used to gzip inside one large stream. It is named in the baseline prose so nobody reads the aggregate drop as free, and it is the obvious next lever if anyone wants more.Budget constants
Both moves are TIGHTENINGS, and both are what
check:eager-closureasked for in its own verdict — it exited 2 on the new build with "the payload almost certainly SHRANK ... lower the named constant, move its baseline with it in the same commit".MAX_EAGER_CLOSURE_GZIP_BYTES3,210,000 → 3,179,000 over 3,133,419 — headroom 0.50x, the value the header argues for.PER_CHUNK_GZIP_CEILINGS['ui-components']399,000 → 289,000 over 265,937 — headroom 0.25x.frameworkand its baseline untouched.vendor-objectstackandi18n-locale-enuntouched. ⛔ Nothing was raised.EXHAUSTED_HEADROOM_ALLOWANCES['ui-components'](4,289) is removed, not lowered — the row cleared the floor on its own, 0.02x → 0.25x, which is the only currency that table takes and the same wayi18n-localesleft it at objectui#7479. Leaving it would have been the worse edit in two ways:floorForreads an allowance as the row's REQUIRED headroom, so a stale 4,289 replaces the 9,113.6-byte floor with a 3,377.6-byte one — the gate running weaker on the row it was just strengthened for — while the renderer goes on printing "under the 0.10x floor" about a row 2.5x clear of it. The table is now empty, so every ratchet case that used to read it now drives a synthetic row: paying the last debt off must not retire the instrument that collects it.⭐ The⚠️ Recorded as a CONSEQUENCE, ⛔ not as the acceptance criterion — clause 5 is the table at the top.
BUDGET_CLOSURE_HEADROOM_STATUS: errorthat the Bundle Analysis workflow has been emitting on every pull request is that sameui-componentsrow, and it should come back to life here.维护者速读(草稿 · 席位意见留空)
改了什么 —— 组件包里那张 1,781 个图标的总表不再被整包装进首屏。图标名的合法集合改由一份构建期生成的清单提供(直接读 lucide 自己的导出清单,⛔ 不是从总表推),图标本身按名懒加载。
为什么改 —— 裁决要的就是这个方向。上一轮(Door 1)撬错了杠杆,实测反而大了 923 字节;字节真正在的地方是这张总表。
风险与代价(含回滚) —— 用户能看见的唯一变化:图标的 svg 框和 class 第一帧就在(不跳版),里面的线条晚一点到。接受的代价是 277 个单图标小 chunk 共 76,330 gz —— 这些图标本来就在首屏,只是以前和别的代码压在一起更划算,已写进基线说明。回滚 = revert 本分支上的全部 commit(数量随分支变,不写死);门禁上限只往下调过,回滚不会让任何已通过的构建变红。
你要做的 —— 只要确认一件事:页面实际小了多少。答案是首屏闭包 gzip 少 47,172 字节、raw 少 369,154 字节;
ui-components那一行少 131,154 字节。⛔ 不是只把那一行刷绿。Verification, with captured exit codes
Every exit code below was captured by redirect before any pipe.
pnpm exec turbo run type-check --concurrency=2— exit 0, 81/81 tasks.pnpm type-check:scripts— exit 0.scripts/is not a workspace package, so neither a per-packagetype-checknorturbo run type-checkcovers it. This leg found the one error CI reported on the first push, and the fix is on the declaration rather than at the reader.pnpm type-check:vitest-config— exit 0 (this diff editsapps/console/vite.config.ts).pnpm exec vitest run packages/components/ scripts/— exit 0, 424 files passed / 2 skipped, 7,146 tests.pnpm exec vitest runover the 39 consumer test files that touch the icon seam — exit 0, 425 tests.node scripts/check-lucide-icon-record-names.mjs --report— exit 0, before and after.node scripts/check-eager-closure-budget.mjs— exit 0 on the final constants, all five ceilings inside one regression.pnpm lint:root— exit 0.pnpm lint:coverage— exit 0 (46/46 packages, 0 errors).pnpm type-check:coverage— exit 0 (43/43).check:eager-locale-catalogues,check:side-effects-array,check:phantom-deps,check:unused-deps,check:self-import,check:new-line-citations,check:entry-guard,check:published-dist,check:published-tsconfig-exclude,check:sdui-registration-pins,check:lint-rule-coverage,check:esm-specifiers,check:control-bytes— all exit 0.Declared narrowing. The repo-wide
eslint .run belongs to CI; locally eslint was run over this diff's 10 lintable files (exit 0, 0 errors and 0 warnings, count read from--format json). That narrowing is a measurement rather than a skip: the rooteslint.config.jsconfigures no type-aware linting at all — zero hits forprojectServiceand for aparserOptionsproject, against a control token in the same file that hits 13 times — so nothing in this diff can move the verdict on a file it does not touch.Not measured locally.
check:dist-completenessand the load leg ofcheck:node-esm-loadboth grade built artifacts and are left to CI, which builds.Acceptance notes
Observations recorded, not filed, because no pull request or person is queued to touch them:
packages/components/src/lib/lazy-icon.tsxstill holds a second, more forgiving resolver overiconNames— a censused dynamic-surface site, correct as declared, and objectui#9204 is the card that owns its legal-name set. Untouched here on purpose. Carrier: objectui#9204.