perf(components): defer lucide dynamic-import map — MEASURED net +923 B, do not merge as-is - #9250
perf(components): defer lucide dynamic-import map — MEASURED net +923 B, do not merge as-is#9250os-tesla wants to merge 2 commits into
Conversation
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
|
| 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) | |
| 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. TheCheck console performance budgetstep 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
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. Implementedfaithfully, it is net +923 B gzipped on
ui-components, andcheck-eager-closure-budget.mjsexits 2 on it:⛔ 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
91facaef6Instrument each time:
pnpm turbo run build --filter='./packages/*'→pnpm --filter @object-ui/console build→apps/console/dist/eager-closure.jsonread directly.ui-componentsgz91facaef6origin/mainAblation 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
iconNamesasObject.keys(dynamicIconImports), so the names cannot stay while the map goes.isLucideIconNameis a synchronous published predicate —notificationIconpicks 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.
icons-record import keeps all 1,767 icon modules inui-components, so thenow-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
DynamicIconitself, so that edgenever 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, ⛔ notdecided 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 asdata, 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.tsxanswers membership from that mirror and loads the map throughimport()on the first icon that renders. The published ESM keeps the externaldynamic import (
grep import\(\"lucide-react/dynamic.mjs\"\) dist/index.js→ 2), soconsumers get the split too.
getLazyIcon(app-shell,apps/console) becomedelegations: one resolver, one vocabulary, one memo. This also deletes a 576 B
eager chunk (51 → 50 eager chunks above).
check-lucide-icon-record-names.mjslearns the two spellings its census could notsee —
import('lucide-react/dynamic…')and the catalogue binding — and gains anempty
DECLARED_EAGER_DYNAMIC_IMPORTERS. Without the first half, moving the mapbehind
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 TERMrestore,each leg proved on disk (HEAD blob hash printed before, mutated hash after,
git diff --numstatnon-empty, grep anchor counts before/after) and each restore proved by anempty
git diff HEAD— printed, not assumed. Exit codes captured by redirect-then-capture.vitest run packages/components/ scripts/vitest run packages/app-shell/vitest run --project @object-ui/consoletype-check(components, app-shell, console)check:icon-record-namescheck:entry-guard--self-testthen plaincheck:eager-locale-cataloguescheck:doc-snippets/check:doc-examplescheck:changeset-presencecheck:readme-exports,check:unreferenced-sources,check:control-bytes,check:self-import,check:phantom-depscheck:eager-closurescripts/__tests__/check-side-effects-array.test.tsfails in this worktree and isnot this diff's doing: it reds on any tree where
packages/layout/dist/index.umd.cjsexists, because that form is classified
unmapped(and so counted as analternateFormats) only while it is absent from disk. Isolated by moving that onefile aside — test passes; moved back — test fails, with no source change either way.
Already filed as #9124.
Acceptance notes
needs:contract-reviewapplied. The PM landsthis, or closes it — the decision on perf(components): the eager
ui-componentschunk carries lucide's 1,767-entry dynamic-import map and the wholeiconsrecord — 45,749 B / 144,361 B gzipped, both measured #9204 comes first.resolveIcon, theiconsrecord), thepackages/fieldsbarrel, and any re-chunk ofreact-day-picker/react-hook-form/vaul/date-fns/embla-carousel/react-resizable-panels.vendor-icons-*group for lucide's per-icon modules would move~1.4 MB raw out of
ui-componentsand drop the row under its ceiling withoutremoving 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