Skip to content

perf(components): take lucide's dynamic-import map off the eager path (objectui#9204) - #9394

Closed
os-tesla wants to merge 2 commits into
mainfrom
claude/issue-9204-lucide-name-vocabulary
Closed

perf(components): take lucide's dynamic-import map off the eager path (objectui#9204)#9394
os-tesla wants to merge 2 commits into
mainfrom
claude/issue-9204-lucide-name-vocabulary

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9204

Pays the ui-components eager-closure row down in full. BUDGET_EXIT goes 2 → 0, and ⛔ the ceiling was not raised.

ui-components   397,090 → 353,658 gz    −43,432    headroom 1,910 → 45,342 = 0.02x → 0.50x
aggregate     3,180,382 → 3,136,585     −43,797
eager chunks       52/528 → 51/527
content hash   CzbwZzUA → CVGVNgSG

That is 6.03× what the row needed.

⭐ The mechanism — the map was a duplicate, not a feature

lucide-react/dynamic.mjs publishes iconNames as Object.keys(dynamicIconImports). Meanwhile resolve-icon.ts already indexes the icons record, and a namespace object has no dead members — so every icon module was ALREADY eager in the same chunk, and DynamicIcon was import()-ing modules that were already loaded.

⇒ the 120 KB map was a second copy of a catalogue the chunk had already paid for. Deleting it removes a duplicate; it does not remove a capability.

The forgiving vocabulary is rebuilt from the record's own keys plus a generated 264-entry table of the names no key can produce, resolved to live record keys by object identity. LazyIcon / getLazyIcon / isLucideIconName accept exactly the same 2,039 names, and resolution becomes synchronous.

⛔ Shipping a name list was measured and REFUSED

shape in-chunk
the full 2,039-name catalogue (objectui#9250 measured it) 9,176 gz
the map it would replace 8,253 gz
⇒ net +923

Why, so nobody re-derives it: a bare name list carries each name once with little internal repetition; lucide's map carries each name twice (key and module path) inside a repetitive () => import("./icons/…") scaffold. The map is the better compression carrier for its own names. ⇒ any shape whose plan is "ship the 2,039 names as data" loses to the thing it replaces.

What ships here instead is 2,621 gz (264-entry alias table, splice-measured on the emitted chunk; standalone gz of the same span 2,475).

objectui#9250 is reconciled, not refuted

card's Door-1 ablation −45,749
this implementation −43,432
difference 2,317 ≈ the alias table

⇒ the card's figure was not an unreachable upper bound — it is reachable to within the cost of the vocabulary data. objectui#9250's +923 came from its shape, on two counts it measured itself: it deferred the map into a lazy chunk that imported 2,257 bindings back across the boundary (export statement 4,457 → 22,108 raw bytes), and it shipped the 9,176 B catalogue. Deleting the map pays neither.

The ceiling, the allowance, and the baseline

  • Ceiling NOT moved. It stays at 399,000. The maintainer's 2026-09-13 authorisation to raise it went unused.
  • The 4,289-byte allowance is NOT raised — it is REMOVED, the only way a row may leave EXHAUSTED_HEADROOM_ALLOWANCES, and exactly the i18n-locales precedent the file states in its own words. The row cleared the 0.10x floor on its own, by 36,228 bytes.
  • PER_CHUNK_BASELINE['ui-components'] moves 391,095 → 353,658 in the same commit.
  • Band check: 0.50x, inside [0.10x, 1.00x], beside framework's 0.61x precedent. Aggregate moves 0.32x → 0.81x — still under 1.00x, so ⛔ not blind.

Both constants carry their reasoning beside them, including the explicit "the ceiling did NOT move" and why tightening it onto the new measurement is a deliberate next act rather than this one.

Comparability — the pair is ONE measurement

The restored-baseline ablation reproduced 397,090 / 1,535,918 raw / 3,180,382 / 52/528 / hash CzbwZzUA to the byte and to the content hash, and the branch reproduced after it. ⇒ proved in both directions, not two builds of two trees.

Drift guard

lucide-dynamic-name-aliases.ts is generated by scripts/gen-lucide-dynamic-name-aliases.mjs (pnpm gen:lucide-aliases; --check wired as pnpm check:lucide-aliases), and pinned by 6 tests that rebuild the vocabulary the runtime builds and compare it against Object.keys(dynamicIconImports) from the installed lucide in both directions — so ⛔ neither a narrowing nor a widening can land silently.

Also pinned: a non-vacuity control (>2,000 names, and the two vocabularies genuinely differing), a firing control (an invented name must still degrade to Database), the retired spellings record-alert's severity glyphs need (asserted ABSENT from the record and PRESENT here), and a proof that every published name draws from the record rather than the fallback — with database derived as the one legitimate exception rather than assumed.

⛔ Aliases resolve by object identity against lucide's own exports, never by a remembered rename table. The generator refuses to emit if any dynamic name fails to resolve.

⚠️ A claim in this card's neighbourhood, REFUTED here

The PM's dispatch carried "+272 icons from 1.31.0 → 1.35.0". Like for like it is +14.

dynamicIconImports   1.31.0  2,025   1.35.0  2,039    +14
icons record         1.31.0  1,767   1.35.0  1,781    +14
removed in either vocabulary:  0

1,767 is the record count at 1.31.0 and 2,039 is the dynamic count at 1.35.0 — comparing them compares two vocabularies, not two releases, and the gap between the vocabularies (258) dwarfs the gap between the releases (14). Both 1.31.0-era figures sit in check-lucide-icon-record-names.mjs's own header, which is where the confusion is reachable from; that header now carries the measurement and an explicit warning against the cross-comparison.


⚠️ Opened by the PM seat on the implementing seat's behalf. The session lost GitHub access mid-round (GitHub access is not enabled for this session), so the work was committed locally and pushed only once access returned. origin/main was merged in at push time — the byte figures above were measured at 69aa9c017, and CI re-weighs them against the current base; that re-weighing is the verdict, not these local numbers.

The seat's full os-dev-report and every log backing a VERDICT line were written to disk during the outage and are posted on objectui#9204.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ


Generated by Claude Code

claude and others added 2 commits September 13, 2026 10:03
`lucide-react/dynamic.mjs` publishes `iconNames` as
`Object.keys(dynamicIconImports)` — a list derived at module init from a
120,683-byte import map. Four modules imported that specifier, three of them for
the names alone. Beside it `renderers/action/resolve-icon.ts` indexes lucide's
`icons` record, and a namespace object has no dead members, so every icon module
was already eager in the same chunk: `DynamicIcon` was `import()`-ing modules
that were already loaded, and the map was a second copy of a catalogue the chunk
had already paid for (objectui#9204).

Rebuild the forgiving vocabulary from the record's own keys plus a generated
table of the 264 names no key can produce, each mapped to its live record key by
object identity. `LazyIcon` / `getLazyIcon` / `isLucideIconName` accept exactly
the same 2,039 names, asserted in both directions against the installed lucide by
a drift test. Resolution becomes synchronous; the exported API is unchanged.

Measured on one pair of console builds at 69aa9c0, read from
apps/console/dist/eager-closure.json, with the restored baseline reproducing
397,090 to the byte:

    chunk `ui-components`   397,090 -> 353,658 gz   -43,432
    eager closure         3,180,382 -> 3,136,585    -43,797
    eager / total chunks     52/528 -> 51/527
    headroom                  1,910 ->  45,342      0.02x -> 0.50x

BUDGET_EXIT goes 2 -> 0. The ceiling is NOT raised — the 2026-09-13
authorisation to raise it went unused — and `ui-components` leaves
EXHAUSTED_HEADROOM_ALLOWANCES the only way a row may: it cleared the floor on its
own. PER_CHUNK_BASELINE moves with it in this commit.

app-shell's and the console's `utils/getIcon` were transcriptions of the same
resolver and become re-exports of it: one resolver, one vocabulary, one memo.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ

Copy link
Copy Markdown
Collaborator Author

⛔ Closing — this PR is refused by the director ruling on objectui#9251, and I opened it without the ruling in hand

Maintainer, verbatim (2026-09-13):

icons 总表不再 eager:图标按名经动态导入表解析(Door 1 已证明该表几乎免费);合法图标名集合由构建期生成的静态名单提供(⛔ 不从 Object.keys(icons) 推导#9204 救援 A 拒绝);总表的每个读点(check-lucide-icon-record-names.mjs 普查着)迁到懒解析;验收 = 页面实际字节减少,不是那一行变绿

This branch derives its vocabulary from Object.keys(icons) plus a 264-entry alias table, and keeps the icons record eager. ⇒ refused on clause 3, and pointed at the wrong lever besides: it removes the ~120 KB map while leaving the ~1.4 MB record the ruling retires.

⛔ And the process failure is mine, stated plainly

Three things were already on objectui#9204 before I dispatched, and I did not read the thread to the end:

  1. Ruling A (5650203001, director seat, decision batch Redesign elegant homepage with fumadocs layout and navigation #125 item 3, maintainer 「同意」, 02:23Z) — membership from the eager icons record, the 258 retired aliases stop resolving, a retired spelling is refused. My seat's alias table does the opposite: it preserves all 2,039 names.
  2. Dead-claim recovery (5652439605, 09:26Z) — the domain:spec seat took this card over under the maintainer's takeover instruction, correctly, because my subagent died with its session and no push followed for 37 minutes. PR objectui#9250 is the designated implementation.
  3. That seat had already delivered (5652889482, 11:08Z).

⇒ I re-dispatched a card that was no longer mine, against a ruling I had not read, and pushed the result once access returned. ⛔ The measurements in this branch are sound; the act of opening it was not.

What survives, for whoever implements objectui#9251

The seat's round-2 measurement is the part worth keeping, and it supports the ruling rather than this branch:

  • resolve-icon.ts already indexes the icons record, and a namespace object has no dead members ⇒ every icon module was already eager, and DynamicIcon was import()-ing modules that were already loaded.
  • Those modules already carry every icon name as a string in the emitted chunk (W("air-vent", node)). ⭐ That is precisely why the dynamic-import table is nearly free in context and a separate name list is expensive — the ruling's clause 2 and the refusal in clause 3 are the same measurement read correctly.
  • objectui#9250's +923 came from its shape: deferring the map into a lazy chunk that imported 2,257 bindings back across the boundary (export statement 4,457 → 22,108 raw bytes) plus a 9,176 B catalogue.
  • +272 icons is refuted — like for like it is +14 (dynamicIconImports 2,025 → 2,039; icons record 1,767 → 1,781; nothing retired). 1,767 is the record count at 1.31.0 and 2,039 the dynamic count at 1.35.0: comparing them compares two vocabularies, not two releases. That error was mine and it reached a dispatch.

Branch claude/issue-9204-lucide-name-vocabulary is left in place; the full report and every log backing a VERDICT line are on objectui#9204. ⛔ Not deleted, so the measurement stays reachable.


Generated by Claude Code

@os-tesla os-tesla closed this Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 51 chunks) 3063.3 KB 3134.8 KB
Main entry chunk (gzip) 144.4 KB 350 KB
Entry file index-zlelwIQv.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.22KB 117.82KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 211.58KB 58.68KB
fields (index.js) 247.89KB 62.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.25KB 13.99KB
plugin-charts (index.js) 71.34KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.59KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.46KB 65.85KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.77KB 34.17KB
plugin-gantt (index.js) 166.95KB 41.04KB
plugin-grid (index.js) 211.66KB 57.50KB
plugin-kanban (index.js) 46.00KB 14.30KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.64KB 6.86KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.79KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 96.00KB 31.71KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Projects

None yet

2 participants