Skip to content

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

Description

@baozhoutao

Filed bare (no assignee, no domain:*, no pm:*) out of the measurement-first round on objectui#7848, whose fence says a slimming that the census finds "is a second card and a second PR". This is that card. ⛔ Nothing here has been implemented and ⛔ no gate constant was moved.

All figures below are from four full console builds in one container on tip 1f4e02995a, read directly out of apps/console/dist/eager-closure.json. Every mutation was proved on disk (blob-hash before/after plus printed grep anchors) and restored with git checkout HEAD -- path, proved by an empty git diff HEAD.

The reading on 1f4e02995a

ui-components   395,032 gz   ceiling 399,000   headroom 3,968 = 0.0435x the 91,136 B regression
aggregate     3,186,729 gz   ceiling 3,210,000 headroom 23,271 = 0.2554x

ui-components is the only entry in EXHAUSTED_HEADROOM_ALLOWANCES (4,289). The gate itself prints the trip point: reds below 3378 bytes — so the live row has 590 bytes of runway before the ratchet fires. Clearing the 0.10x floor on its own needs the chunk at 389,886 or less, i.e. a saving of 5,146 gzipped bytes.

Where the bytes are

Census of the emitted chunk's own module list (2,240 modules, 2,926,967 B renderedLength):

area renderedLength share modules
lucide-react 1,480,797 50.6% 1,770
packages/components/src/renderers 396,868 13.6% 105
packages/fields/src/widgets 280,718 9.6% 72
packages/components/src/ui 112,206 3.8% 47
packages/components/src/custom 104,106 3.6% 29
react-day-picker 99,431 3.4% 98
react-hook-form 80,343 2.7% 1

Half the chunk is lucide, through two independent doors.

Door 1 — the dynamic-import map is EAGER (dynamicIconImports.mjs, 263,547 B rendered)

Four non-test modules statically import lucide-react/dynamic.mjs:

packages/components/src/lib/lazy-icon.tsx:24
packages/app-shell/src/views/metadata-admin/widgets.tsx:46
packages/app-shell/src/utils/getIcon.ts:16
apps/console/src/utils/getIcon.ts:11

The map is 1,767 () = arrow entries whose only job is to be called later. It does not need to be in the first payload.

Measured (ablation: the four imports stubbed, icons record left alone):

ui-components  395,032 -> 349,283   -45,749
aggregate    3,186,729 -> 3,141,057 -45,672
eager/total chunks  51/528 -> 51/528   (unchanged)

⭐ The two deltas agree within 77 bytes, so this is bytes LEAVING the page load, not bytes moving between columns. No new chunk, no extra request. ui-components headroom becomes 49,717 = 0.55x — above the 0.10x floor and still under 1.00x, so the ceiling stays sensitive rather than going blind. This one change alone is 8.9x the 5,146 bytes the row needs.

Door 2 — the whole 1,767-icon record is EAGER

packages/components/src/renderers/action/resolve-icon.ts:9

import { icons, type LucideIcon } from 'lucide-react';

icons is lucide's runtime record, published as export { index as icons } over a namespace import of a 1,767-line barrel. A namespace object has no dead members, so tree-shaking cannot help: every icon module is statically reachable. Verified on the emitted artifact rather than from source — the path data of air-vent, an icon this product does not use, appears exactly once in assets/ui-components-*.js.

It is also the ONLY non-test module that imports the record (the three other hits are test files), which matches check-lucide-icon-record-names.mjs's declared census of "this file alone".

Measured (ablation: resolveIcon returns null, record import removed):

ui-components  395,032 -> 254,749  -140,283
aggregate    3,186,729 -> 3,188,038  +1,309   (WORSE)
eager/total chunks  51/528 -> 176/2138

⚠️ The aggregate gets worse, and the cause is the co-tenancy mechanism objectui#6680 documented. Diffing the chunk membership shows eight icon modules, well under 3 KB, were placed inside three previously-lazy plugin chunks, which drags all of them eager:

plugin-dashboard  + arrow-up-right.mjs                                     -> 49,182 gz eager
plugin-gantt      + file-down, minimize-2, panel-left-close,
                    wand-sparkles, zoom-in, zoom-out                       -> 36,561 gz eager
plugin-report     + table-2.mjs                                            -> 10,417 gz eager

Adding one advancedChunks group per icon — the shape objectui#7479 used for the ten locale catalogues, and for the same stated reason — removes that:

ui-components  395,032 -> 250,671  -144,361
aggregate    3,186,729 -> 3,126,208  -60,521
eager/total chunks  51/528 -> 328/2294

⚠️ The price is 278 eager per-icon chunks summing 77,024 gz, i.e. 278 extra requests on first paint, and the ui-components headroom becomes 148,329 = 1.63x, which trips the gate's blindness leg in the other direction and would need the ceiling and its baseline re-pinned DOWN together. Not proposed as-is; the shape worth designing is one shared chunk for the eagerly-used icons plus per-icon chunks for the rest.

Ranked

  1. Door 1, lazy-load lucide-react/dynamic.mjs. -45,749 on the row, -45,672 on the aggregate, zero new chunks, ceiling stays sensitive. Clears the floor on its own. ⚠️ The number above is an ablation, so it is an UPPER bound: a real implementation keeps an async loader and some cheaper iconNames membership predicate.
  2. Door 2, retire the icons-record import in favour of the async resolver lazy-icon.tsx already implements. Bigger, but needs the chunking design first and a ceiling/baseline re-pin after. ⚠️ Contract change: resolveIcon is a maintainer-ruled synchronous seam (objectui#5935, ruling of 2026-09-03) returning LucideIcon | null, and four surfaces depend on their own fallbacks. That ruling has to be revisited before this is touched.
  3. Not the packages/fields barrel. scripts/vite-ineffective-dynamic-imports.ts already measured barrel surgery at +329 bytes and the maintainer ruled option C on 2026-08-22. Its option A is still on hold and is a prerequisite, not an alternative.
  4. Not a re-chunk of react-day-picker / react-hook-form / vaul / date-fns / embla-carousel / react-resizable-panels. These are in ui-components only because they match no vendor group; moving them shrinks the column without removing a byte, and the console config already rules on that in its own words: "This moves NO MODULE BYTES ... Nothing here may be read as headroom that was earned."

Reproduction

pnpm install --frozen-lockfile
pnpm turbo run build --filter='./packages/*'
pnpm --filter @object-ui/console build
node -e "const r=require('./apps/console/dist/eager-closure.json');console.log(r.eagerGzipBytes, r.files.find(f=>f.name==='ui-components').gzipBytes)"

Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpackage: componentspriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions