Skip to content

perf(components): defer lucide dynamic-import map — MEASURED net +923 B, do not merge as-is - #9250

Draft
os-tesla wants to merge 2 commits into
mainfrom
claude/issue-9204-eager-lucide-icon-map
Draft

perf(components): defer lucide dynamic-import map — MEASURED net +923 B, do not merge as-is#9250
os-tesla wants to merge 2 commits into
mainfrom
claude/issue-9204-eager-lucide-icon-map

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Part of #9204

⛔ DO NOT MERGE AS-IS — this branch is a measurement, and the measurement says no

The card's ranked recommendation #1 ("Door 1, lazy-load lucide-react/dynamic.mjs.
−45,749 on the row") does not reproduce on today's origin/main. Implemented
faithfully, it is net +923 B gzipped on ui-components, and
check-eager-closure-budget.mjs exits 2 on it:

❌ chunk `ui-components`  386.7 KB measured / 389.6 KB ceiling (headroom 3.0 KB = 0.03x …)
chunk `ui-components` was ALREADY declared exhausted before this run, and has now lost
a further 0.01x of a regression against the pinned figure.

⛔ No gate constant was moved — not a ceiling, not the 4289-byte allowance, not an
exemption. The row is red because the change costs bytes, and that is the finding.

The measurement — four console builds, one container, tip 91facaef6

Instrument each time: pnpm turbo run build --filter='./packages/*'pnpm --filter @object-ui/console buildapps/console/dist/eager-closure.json read directly.

build ui-components gz Δ row aggregate gz Δ aggregate eager/total
baseline 91facaef6 395,026 3,187,675 51/528
this branch 395,949 +923 3,188,456 +781 50/528
ablation A — map deferred and names dropped 386,773 −8,253 3,179,311 −8,364 50/528
ablation B — the four import sites restored to origin/main 395,026 0 3,187,675 0 51/528

Ablation B reproduces the baseline to the byte, which is what makes the other two
readings comparable rather than three separate builds of three different trees.

The card's figure for the ablation-A shape was 349,283 / −45,749. Measured here:
386,773 / −8,253. The gap is 37 KB, and it has a mechanism.

Why deferring the map does not bank the map's bytes

  1. The map's keys ARE the icon names. lucide derives iconNames as
    Object.keys(dynamicIconImports), so the names cannot stay while the map goes.
    isLucideIconName is a synchronous published predicate — notificationIcon
    picks between the authored icon and the severity glyph during render — so this
    branch ships the names as data instead. Measured by splicing the emitted chunk:
    the catalogue costs 9,176 B gzipped, and the whole map it replaces cost
    8,253 B. A bare list of the names is more expensive than lucide's map of
    the same names. That is the entire +923.
  2. The map still has to reach the icons, and they stay eager. Door 2's
    icons-record import keeps all 1,767 icon modules in ui-components, so the
    now-lazy map chunk imports them back across a chunk boundary. ui-components'
    export statement grows from 4,457 B / 492 bindings (baseline) to 22,108 B /
    2,257 bindings
    . The card's ablation stubbed DynamicIcon itself, so that edge
    never existed and its cost never appeared in the −45,749.

⇒ With Door 2 fenced, Door 1 has no bytes to give. The recoverable maximum is 8,253 B
and it is only reachable by ALSO taking the names off the eager path, which is a
question about isLucideIconName's contract — routed as a decision on #9204, ⛔ not
decided here.

What is on the branch, and why it is worth reading rather than deleting

  • packages/components/src/lib/lucide-icon-names.ts — the DYNAMIC vocabulary as
    data, generated by scripts/gen-lucide-icon-names.mjs (pnpm gen:lucide-icon-names)
    and re-derived from the installed lucide by a test, so the mirror cannot age
    silently — the defect check-lucide-icon-record-names.mjs's own header warns about.
  • lazy-icon.tsx answers membership from that mirror and loads the map through
    import() on the first icon that renders. The published ESM keeps the external
    dynamic import (grep import\(\"lucide-react/dynamic.mjs\"\) dist/index.js → 2), so
    consumers get the split too.
  • The two transcriptions of getLazyIcon (app-shell, apps/console) become
    delegations: one resolver, one vocabulary, one memo. This also deletes a 576 B
    eager chunk (51 → 50 eager chunks above).
  • check-lucide-icon-record-names.mjs learns the two spellings its census could not
    see — import('lucide-react/dynamic…') and the catalogue binding — and gains an
    empty DECLARED_EAGER_DYNAMIC_IMPORTERS. Without the first half, moving the map
    behind import() would have read as "this module stopped reading the vocabulary"
    and quietly retired the census entry.

Verification

Ablations were run from the committed state, each with a trap ... EXIT INT TERM restore,
each leg proved on disk (HEAD blob hash printed before, mutated hash after, git diff --numstat non-empty, grep anchor counts before/after) and each restore proved by an
empty git diff HEAD — printed, not assumed. Exit codes captured by redirect-then-capture.

check result
vitest run packages/components/ scripts/ 415 files / 6,854 passed, 1 pre-existing failure — see below
vitest run packages/app-shell/ 686 files / 6,638 passed, 1 skipped
vitest run --project @object-ui/console 99 files / 1,144 passed
type-check (components, app-shell, console) all Done
check:icon-record-names OK — 1 record resolver, 2 dynamic sites, 0 static importers
check:entry-guard --self-test then plain 63 self-test cases / 93 scripts, both exit 0
check:eager-locale-catalogues exit 0 — 406.2 KB deferred, 9 catalogues
check:doc-snippets / check:doc-examples 644 blocks judged, 0 failed / ledger exact
check:changeset-presence exit 0 — 1 changeset for 8 published source files
check:readme-exports, check:unreferenced-sources, check:control-bytes, check:self-import, check:phantom-deps all exit 0
check:eager-closure exit 2 — the verdict this PR exists to report

⚠️ scripts/__tests__/check-side-effects-array.test.ts fails in this worktree and is
not this diff's doing: it reds on any tree where packages/layout/dist/index.umd.cjs
exists, because that form is classified unmapped (and so counted as an
alternateFormats) only while it is absent from disk. Isolated by moving that one
file aside — test passes; moved back — test fails, with no source change either way.
Already filed as #9124.

Acceptance notes

  • ⛔ Not enqueued, no auto-merge armed, needs:contract-review applied. The PM lands
    this, or closes it — the decision on perf(components): the eager ui-components chunk carries lucide's 1,767-entry dynamic-import map and the whole icons record — 45,749 B / 144,361 B gzipped, both measured #9204 comes first.
  • Out of scope and left alone, as the triage fenced: Door 2 (resolveIcon, the
    icons record), the packages/fields barrel, and any re-chunk of
    react-day-picker / react-hook-form / vaul / date-fns / embla-carousel /
    react-resizable-panels.
  • Noted, not filed — a vendor-icons-* group for lucide's per-icon modules would move
    ~1.4 MB raw out of ui-components and drop the row under its ceiling without
    removing one byte from the page load
    . That is exactly the form the console config
    forbids in its own words ("This moves NO MODULE BYTES … Nothing here may be read as
    headroom that was earned"), recorded here so the next reader does not rediscover it
    as a fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ


Generated by Claude Code

os-dev and others added 2 commits September 12, 2026 04:13
lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so a static
import of either name drags the 1,767-entry dynamic-import map into the
importer's chunk. Four modules imported it, and the map rode the console's
eager `ui-components` chunk on every page load.

- `packages/components/src/lib/lucide-icon-names.ts` — the names as DATA,
  generated from the installed lucide by `scripts/gen-lucide-icon-names.mjs`
  and re-derived from that same install by a test, so the mirror cannot age
  silently.
- `lazy-icon.tsx` answers the synchronous `isLucideIconName` from that mirror
  and reaches the map through `import()` on the first icon that renders.
- The two transcriptions of `getLazyIcon` (`app-shell`, `apps/console`) become
  delegations, so one resolver reads one vocabulary.
- `check-lucide-icon-record-names.mjs` learns the two spellings it could not
  see (`import()`, the catalogue binding) and gains an EMPTY
  `DECLARED_EAGER_DYNAMIC_IMPORTERS`, so a static import is named on the commit
  that adds it.

Part of #9204

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

The card's -45,749 B ablation does not reproduce. Three console builds in one
container put the map at 8,253 B gzipped of the eager `ui-components` chunk and
the icon-name catalogue that has to replace it at 9,176 B in the same chunk, so
deferring the map while `isLucideIconName` stays a synchronous exact-membership
predicate is net +923 B. Say so in the files that carry the claim, and add the
changeset the diff owes.

Part of #9204

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

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.3 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 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) 527.91KB 123.65KB
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.88KB 62.48KB
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.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
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.84KB 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.04KB 13.93KB
plugin-charts (index.js) 71.52KB 19.98KB
plugin-chatbot (index.js) 195.35KB 46.52KB
plugin-dashboard (index.js) 131.27KB 34.62KB
plugin-designer (index.js) 215.82KB 44.33KB
plugin-detail (index.js) 253.38KB 65.87KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.95KB 41.04KB
plugin-grid (index.js) 211.62KB 57.50KB
plugin-kanban (index.js) 46.10KB 14.33KB
plugin-list (index.js) 112.59KB 27.66KB
plugin-map (index.js) 20.43KB 6.81KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 94.03KB 31.02KB
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.27KB 5.47KB
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

Development

Successfully merging this pull request may close these issues.

1 participant