Skip to content

Stop plugin app bundles parsing icon barrels - #3918

Merged
ymichael merged 2 commits into
mainfrom
bb/investigate-uncached-pnpm-start-time-thr_63p2x8iha6
Sep 19, 2026
Merged

ymichael merged 2 commits into
mainfrom
bb/investigate-uncached-pnpm-start-time-thr_63p2x8iha6

Conversation

@ymichael

@ymichael ymichael commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

Plugin app bundles reached @hugeicons/core-free-icons through its ESM barrel, which re-exports 5123 separate modules, and lucide-react through its 1756-module one. esbuild has to resolve, open and parse every one before tree-shaking can drop them.

For the tasks plugin that was 6879 of 7263 modules and 7.98 MB of the 10.6 MB esbuild parsed, contributing 11 KB to a 1115 KB bundle. Tree-shaking worked fine; the cost was reaching the point where it could run. It is file-count-bound rather than byte-bound, which is why it showed up as system time.

lucide-react was never imported directly by apps/app or any plugin. It entered through twelve shadcn components in packages/shared-ui, each for one to three glyphs, of which only three were reachable (command, carousel, radio-group).

What changed

Icons are imported by subpath, not from the barrel — import BrainIcon from "@hugeicons/core-free-icons/BrainIcon". 340 imports across 24 files in packages/shared-ui, packages/plugin-api-map, apps/app, apps/web and four bundled plugins. The alias-only names (FolderIcon → Folder01Icon, GitPullRequestArrow → GitPullRequestIcon) were mapped from the barrel's own export table rather than assumed.

@hugeicons/core-free-icons bumped ^4.1.3 → ^4.3.4. Required: 4.1.3 ships only dist/types/index.d.ts, so every subpath import would be any under noImplicitAny. 4.3.4 ships 6072 per-icon declarations.

Every shared-ui shadcn component moves off lucide-react onto shared-ui's own Icon, which the build shims to the sharedUiIcon host runtime slot, so it costs a plugin bundle nothing. calendar.tsx loses 20 lines where three near-identical chevron branches collapse into one. ArrowLeft, Minus and GripVertical had no mapping; the first two were added, the third reuses the existing DragDropVertical. lucide-react leaves packages/shared-ui, packages/plugin-registry and pnpm-lock.yaml entirely.

packages/plugin-registry/r/*.json regenerated — lucide-react leaves every item's dependencies; @bb/icon joins their registryDependencies. An author running shadcn add @bb/command now gets the glyph from the host runtime instead of bundling an icon library.

The Plugin Guide's sidebar thread-list example (docs/plugin-sidebar-thread-list.md) was the last lucide-react import in tracked source. It now uses the subpath-imported Hugeicons idiom the bundled plugins use, with the glyphs ThreadStatusGlyph actually renders for those two indicators (Loading03Icon, HelpCircleIcon).

apps/app theme.css / disclosure.tsx — .lucide { stroke-width: var(--icon-stroke-width) } renamed .bb-icon-stroke, matching the bb- prefix the other app-owned classes in that file use. Its one caller also carried a dead strokeWidth="0.75" (a CSS rule beats a presentation attribute regardless of @layer, so the class always won).

Five Plugin Guide wireframes corrected. Splitting the grouped imports made visible that packages/plugin-api-map/src/wireframes.tsx drew several affordances with a different glyph than the surface it illustrates. Each now uses the icon the app actually renders:

wireframe affordance drew app renders
composer mic Mic01Icon Mic02Icon (Icon name="Mic")
settings Settings02Icon Settings01Icon (Settings)
edit PencilEdit01Icon Edit02Icon (Edit)
side chat MessageAdd01Icon MessageAdd02Icon (SideChat)
terminal TerminalIcon ComputerTerminal01Icon (Terminal)

All five predate this PR. A sweep of the file's 26 icons now finds one left outside the app's palette — SparklesIcon, for which the app has no equivalent, so it stays.

packages/plugin-build is untouched. BB's resolver behaviour is unchanged — git diff main -- packages/plugin-build is empty.

Numbers

Modules esbuild parses per plugin app bundle, from its metafile. Deterministic, not timing:

plugin files before after parsed before after
tasks 7263 400 10604 KB 2636 KB
docs 5366 257 8260 KB 1826 KB
secrets 5223 103 7004 KB 563 KB
theme-preview 5187 66 6709 KB 286 KB
plugin-api-docs 5143 72 6636 KB 239 KB
keep-awake 1788 32 1634 KB 97 KB
all 29 app bundles 31037 1997 45.3 MB 10.9 MB

−94% modules, −76% bytes parsed. Combined output across all 29 bundles moves 5124565 → 5121854 bytes (−0.05%) — the same artifacts, without reading 29k files to produce them.

prepare:bundled wall time, before/after interleaved per plugin, min of 5 runs. This machine is shared, so min is the least contaminated statistic; the last two rows are controls that touch neither barrel:

plugin before after
tasks 0.70s 0.31s
plugin-api-docs 0.47s 0.22s
theme-preview 0.48s 0.25s
secrets 0.46s 0.23s
docs 0.51s 0.27s
keep-awake 0.56s 0.47s
monaco-editor 0.30s 0.30s
account-pool 0.22s 0.22s

How you verified

  • pnpm exec turbo run typecheck lint test --filter='...[origin/main]' — 163 of 169 tasks pass; see the exception below.
  • After rebasing onto origin/main (13 commits, including plugin manifest edits from Improve CLI recovery, unify plugin commands, and retain terminal output #3913 that touch the same package.json files): rebase applied cleanly, pnpm install --frozen-lockfile reports the lockfile already consistent, and turbo run typecheck lint --filter='...[origin/main]' is green across all 33 in-scope packages (38/38 tasks).
  • Wireframe icons: resolved every icon in wireframes.tsx to its actual module (aliases like FolderIcon → Folder01Icon.js make names misleading) and compared against the app's palette. 25 of 26 now match; SparklesIcon is the deliberate exception.
  • Icon artwork across the bump: compared the path data of all 181 icons bb uses between 4.1.3 and 4.3.4. 174 are byte-identical; 7 were redrawn upstream (Copy01Icon, Mic01Icon, Mic02Icon, SquareUnlock02Icon, UserAdd01Icon, UserIcon, UserSwitchIcon). A first pass flagged all 181 because 4.3.4 drops the /*#__PURE__*/ annotation; the comparison ignores it.
  • All 181 names resolve as subpaths in 4.3.4, with declarations, including the two alias-only ones.
  • Output equivalence for the lucide swap: app.css byte-identical; app.js differs by 29 bytes of minifier naming; both bundles contain the same 43 SVG path strings.
  • Visual: rendered the three reachable components in Ladle and diffed the screenshots pixel-for-pixel — 399 of 1,118,720 pixels changed (0.036%), confined to the search icon, the carousel arrows and the radio dot. theme.css sets --icon-stroke-width: 1.75 and the old .lucide rule applied it, so lucide glyphs never rendered at their native stroke-2; the real change is 1.75 → hugeicons' baked 1.5.
  • Stroke rename is behaviour-preserving: computed style measured in a browser — old markup 1.75px, new markup 1.75px, neither-class control 0.75px. Re-confirmed in the real thread--timeline--rows--reasoning--completed story.
  • Plugin stylesheet unaffected: the rule lives in an @layer base block and generate-plugin-theme.mjs only extracts @theme blocks; regenerating confirms no change.

Not verified: @bb/desktop's preload-build.test.ts times out here — it spawns a real Electron window and reports no output (391 of 393 desktop tests pass). It has no dependency on anything in this diff and only re-runs because the lockfile edit invalidates turbo's global hash, but I could not get it green in this environment.

Behaviour changes worth a second opinion

  1. 7 icons redrawn upstream by the 4.1.3 → 4.3.4 bump, listed above. They appear wherever bb renders those names.
  2. Glyph swap in shared-ui CommandInput (machine / project / parent-thread / environment pickers, tasks plugin), Carousel (PluginMarketplaceListing) and RadioGroup (keep-awake), from the lucide removal.
  3. Third-party plugins importing lucide-react directly lose the host's 1.75 stroke normalisation and fall back to lucide's own stroke, since .lucide no longer exists.

Why not the production export condition

An earlier revision of this PR set conditions: ["production"] on the plugin app build, which resolves hugeicons to its prebuilt single-file bundle. Two findings killed it.

It is a no-op on any current hugeicons. 4.2.1 repointed that condition at the same tree-shakeable entry as default, so it only does anything on ≤4.2.0 — the version we were pinned to. Of 185 marketplace plugin apps, 103 depend on hugeicons and 97 of those declare ^4.1.3, which installs 4.3.4 today: no benefit for any of them.

More to the point, upstream removed it deliberately, in hugeicons/hugeicons#26 — "production export condition breaks tree-shaking on rspack/Vite". Their guidance is to keep the minified single-file build "for CDN/script use, just not as the bundler entry". Subpath imports are what the package is designed for, and they measure better anyway: 10.9 MB parsed versus 36.7 MB for the condition.

Checked while evaluating that revision: of the 119 distinct runtime dependencies across all 185 marketplace plugin apps, only two declare a production condition — hugeicons and @excalidraw/excalidraw, whose root entry lists default and production as the same file.

AGENT GENERATED

@ymichael
ymichael force-pushed the bb/investigate-uncached-pnpm-start-time-thr_63p2x8iha6 branch 4 times, most recently from c167752 to ac090b4 Compare September 18, 2026 23:46
ymichael and others added 2 commits September 18, 2026 20:50
Plugin app bundles reached `@hugeicons/core-free-icons` through its ESM
barrel, which re-exports 5123 separate modules, and lucide-react through
its 1756-module one. esbuild has to resolve, open and parse every one
before tree-shaking can drop them. Across the 29 bundled plugin apps that
was 31037 modules and 45.3 MB parsed to emit 5.1 MB.

Import icons by subpath instead of from the barrel, and move every
shared-ui shadcn component off lucide-react onto shared-ui's own `Icon`,
which the build shims to the host runtime. `lucide-react` leaves the
workspace entirely.

Subpath imports need `@hugeicons/core-free-icons` 4.3.x: 4.1.3 publishes
no per-icon type declarations, so every deep import would be `any`. The
bump is wanted regardless — 4.2.1 stopped pointing the `production`
export condition at the unshakeable prebuilt bundle
(hugeicons/hugeicons#26), which is the entry a bundler asking for that
condition would otherwise select.

Splitting the grouped imports exposed five Plugin Guide wireframes drawing
a different glyph than the surface they illustrate: the composer mic,
settings, edit, side-chat and terminal affordances each used a sibling
variant of the icon the app actually renders. They now use the app's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The complete example in the Plugin Guide's sidebar thread-list doc was
the last lucide-react import in tracked source. Use the subpath-imported
Hugeicons idiom the bundled plugins now use, with the glyphs bb's own
ThreadStatusGlyph renders for those two indicators.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ymichael
ymichael force-pushed the bb/investigate-uncached-pnpm-start-time-thr_63p2x8iha6 branch from ac090b4 to 734e8af Compare September 19, 2026 03:53
@ymichael
ymichael merged commit 593bb7d into main Sep 19, 2026
16 checks passed
@ymichael
ymichael deleted the bb/investigate-uncached-pnpm-start-time-thr_63p2x8iha6 branch September 19, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant