Skip to content

Make the component library tree-shakeable - #402

Open
jasverix wants to merge 1 commit into
mainfrom
claude/laughing-cray-py9b1x
Open

Make the component library tree-shakeable#402
jasverix wants to merge 1 commit into
mainfrom
claude/laughing-cray-py9b1x

Conversation

@jasverix

Copy link
Copy Markdown
Collaborator

Change summary

Importing a single component pulled the whole library into the consumer bundle. A trivial BccBadge cost 823 kB, and 785 kB arrived before any component was referenced at all. The ES build now emits one file per module and the package declares sideEffects, so consumers pay only for what they import.

Measured against the packed tarball in a fresh consumer app, with a Vue-only baseline of 58 kB:

Consumer imports Before After
nothing — bare import '@bcc-code/component-library-vue' 785 kB
{ BccBadge } 823 kB 62 kB
{ BccBadge, BccButton, BccTag } 851 kB 164 kB
{ BccDataTable, BccColumn } 1171 kB 554 kB

Cost is now proportional to what you import: a Badge+Button+Tag bundle carries only the button, badge and ripple styles — no datatable, no galleria.

Why it was broken

Two causes, and fixing either one alone changes nothing:

  1. dist/component-library.js was a single 2.59 MB ES module. Bundlers tree-shake at module granularity, so with one module every top-level statement whose purity Rollup can't prove has to be kept. PrimeVue's per-component style modules call BaseStyle.extend() and the theme preset calls definePreset() at module top level; concatenated into one file those become impure top-level statements — unreachable, but not removable.
  2. package.json declared no sideEffects, so consumers had to assume the whole file was side-effectful.

Sourcemap-level breakdown of the 785 kB floor, plus the full investigation, is in #369.

What changed

  • vite.config.ts — ES build uses output.preserveModules, one file per module. Dependencies are re-rooted under dist/vendor/<package>/..., because npm strips node_modules directories from published tarballs and Rollup's default naming would put them under dist/node_modules/.pnpm/<pkg>@<version>_<hash>/.... cssCodeSplit: false keeps every stylesheet in a single dist/index.css, as the ./style.css export promises.
  • package.json — adds "sideEffects": ["**/*.css"]; module / exports point at ./dist/index.js.
  • vite.config.umd.ts (new) — preserveModules only supports the ES format, so the UMD bundle gets its own config.
  • scripts/check-bundle-size.mjs (new) + CI step — guardrail, described below.
  • README.md — a consumer-facing note on bundle size, and a maintainer-facing note on why the build output must not be flattened.

src/ is untouched, so the public API is identical — no breaking change. I initially assumed BccPreset, BccComponentLibrary and the entry's import './style.css' would have to move to subpath exports; they don't. In per-module output the theme preset becomes its own file inside the package, so our own sideEffects flag is enough for consumers to drop it.

Two things worth a reviewer's attention

PrimeVue deliberately stays bundled rather than external. Externalizing it looks like the obvious fix, but the @primevue/icons pnpm patch that swaps in @bcc-code/icons-vue only reaches consumers through our own build output — externalizing would silently revert every BCC icon. I measured both: identical results either way (62 kB / 164 kB), so keeping it bundled costs nothing. There's a comment in the config so this doesn't get "simplified" later.

The UMD output is byte-identical to main. cmp confirms it, including the runtime <style> injection that require() consumers rely on — Vite inlines the stylesheet for non-ES formats, and dropping that would have silently unstyled them.

Guardrail

This regresses invisibly, so pnpm run test:bundle-size bundles a probe app against the built dist and fails if one component costs more than 40 kB on top of Vue (currently 3.3 kB). It runs in CI after the build. Verified it works: with sideEffects removed it reports 746 kB and exits non-zero.

Not addressed

  • CSS is still not tree-shaken. style.css continues to carry rules for every PrimeVue component (~240 kB). The theme.css + Tailwind path already helps; a real fix needs its own issue.
  • quill is a devDependency yet lands in the published output via primevue/editor. Also its own issue.

Verification

  • pnpm lint, pnpm typecheck — clean
  • pnpm test:unit — 43 tests passing
  • pnpm build — clean; dist/index.css is byte-for-byte the same size as before (238,480 bytes)
  • storybook build — succeeds (confirmed preserveModules doesn't leak into Storybook's own build)
  • pnpm pack → installed the tarball in a fresh consumer app: 832 files, no node_modules paths, style.css resolves, both ESM and require() entry points load
  • SSR-rendered BccBadge + BccButton + BccTag with app.use(BccComponentLibrary) — correct classes and data-bcc-name attributes, 150 exports, BccPreset intact

Byte counts are from this environment (Vite 7.3.5, Rollup 4.59, PrimeVue 4.5.5) and are version-specific — worth a sanity check against a release build before publishing.

Change type

  • No review
  • Small PR
  • Big PR
  • Refactor

Closes #369

🤖 Generated with Claude Code

https://claude.ai/code/session_01HgbLt83q8RxauvLrQuXARD


Generated by Claude Code

Importing a single component pulled the whole library into the consumer
bundle: a trivial BccBadge cost 823 kB, and 785 kB arrived before any
component was referenced at all.

Two causes, and fixing either alone changes nothing:

- dist/component-library.js was a single 2.59 MB ES module. Bundlers shake
  at module granularity, so with one module every top-level statement whose
  purity Rollup cannot prove has to be kept. PrimeVue's per-component style
  modules call BaseStyle.extend() and the theme preset calls definePreset()
  at module top level; concatenated into one file those become impure
  top-level statements that are unreachable but not removable.
- package.json declared no sideEffects, so consumers had to assume the whole
  file was side-effectful.

The ES build now emits one file per module (output.preserveModules) and the
package declares "sideEffects": ["**/*.css"]. Dependencies are re-rooted
under dist/vendor/<package>/... because npm strips node_modules directories
from published tarballs and Rollup's default naming would put them there.

Measured against the packed tarball, with a Vue-only baseline of 58 kB:

  { BccBadge }                        823 kB -> 62 kB
  { BccBadge, BccButton, BccTag }     851 kB -> 164 kB
  { BccDataTable, BccColumn }        1171 kB -> 554 kB

Cost is now proportional to what is imported: a Badge+Button+Tag bundle
carries only the button, badge and ripple styles, no datatable or galleria.

src/ is unchanged, so the public API is identical. PrimeVue deliberately
stays bundled rather than external: the @primevue/icons pnpm patch that
swaps in @bcc-code/icons-vue only reaches consumers through our own build
output, so externalizing it would silently revert every BCC icon.

preserveModules only supports the ES format, so the UMD bundle moves to its
own config (vite.config.umd.ts). Its output is byte-identical to before,
including the runtime <style> injection that require() consumers rely on.

scripts/check-bundle-size.mjs bundles a probe app against dist and fails if
one component costs more than 40 kB on top of Vue; it runs in CI after the
build. Verified it reports 746 kB and exits non-zero when sideEffects is
removed.

Styles are still not tree-shaken; style.css continues to carry rules for
every component. That needs its own change.

Closes #369

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgbLt83q8RxauvLrQuXARD
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.

@bcc-code/component-library-vue is not tree-shakeable

2 participants