Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/design-library-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ jobs:
run: pnpm build
working-directory: component-library

# Guards issue #369: a consumer importing one component must not pull in
# the whole library. See scripts/check-bundle-size.mjs.
- name: Check tree-shakeability
run: pnpm run test:bundle-size
working-directory: component-library

- name: Resolve version
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV

Expand Down
23 changes: 23 additions & 0 deletions component-library/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ app.component('BccInput', BccInput);

The library exports both **custom BCC components** (e.g. `BccBadge`, `BccFrame`, `BccReact`) and **wrapped PrimeVue components** (e.g. `BccButton`, `BccDialog`, `BccDataTable`). PrimeVue services (Toast, Confirm, Dialog) are configured by `BccComponentLibrary`; use the composables `useToast`, `useConfirm`, and `useDialog` from the library when you need them.

## Bundle size

The ES build is tree-shakeable: you pay for the components you import, not for the whole library. Importing a single small component adds a few kB on top of Vue; heavier components (`BccDataTable`, `BccEditor`, `BccDatePicker`) cost proportionally more because they carry their PrimeVue implementation and styles.

Two things are needed on the consumer side:

- Import from the package root and let your bundler shake it — `import { BccBadge } from '@bcc-code/component-library-vue'`. There is no need to deep-import individual files.
- Build for production. Dev servers do not tree-shake, so `pnpm dev` will always look like the whole library is loaded.

Styles are **not** tree-shaken: `style.css` (Option 2) always contains the rules for every component. If CSS size matters, use Option 1 (`theme.css` + Tailwind in your app), where Tailwind only emits the utilities you actually use.

---

# Development
Expand All @@ -133,6 +144,18 @@ pnpm run build:llms # Regenerate AI docs from an existing storybook-static/ind
pnpm run build:vite # Vite build only (includes theme.css)
```

### Build output shape (do not flatten it)

`vite build` emits **one file per module** (`output.preserveModules`) rather than a single bundle, and `package.json` declares `"sideEffects": ["**/*.css"]`. Both are required for consumers to tree-shake the package, and either one alone does nothing — see [#369](https://github.com/bcc-code/bcc-design/issues/369).

The reason a single bundle cannot be shaken: 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 a consumer's bundler must keep, which dragged ~785 kB of unused code into every consumer bundle.

Consequences to keep in mind when touching the build:

- Dependencies are bundled and re-rooted under `dist/vendor/<package>/...`. PrimeVue in particular **must not** be made external: the `@primevue/icons` patch that swaps in BCC icons only reaches consumers through our own build output.
- `preserveModules` only supports the ES format, so the UMD bundle is built separately by `vite.config.umd.ts`.
- `pnpm run test:bundle-size` bundles a probe app against `dist` and fails if importing one component costs more than 40 kB. It runs in CI after the build.

### AI-ready docs outputs

`pnpm run docs:ai` generates the public AI documentation artifacts into `storybook-static/`:
Expand Down
11 changes: 8 additions & 3 deletions component-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@
"access": "public"
},
"packageManager": "pnpm@11.23.0",
"sideEffects": [
"**/*.css"
],
"files": [
"dist",
"dist-types",
"dist-css"
],
"main": "./dist/component-library.umd.cjs",
"module": "./dist/component-library.js",
"module": "./dist/index.js",
"types": "./dist-types/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist-types/index.d.ts",
"default": "./dist/component-library.js"
"default": "./dist/index.js"
},
"require": {
"types": "./dist-types/index.d.ts",
Expand All @@ -42,7 +45,8 @@
"build": "concurrently --kill-others-on-fail \"pnpm run typecheck\" \"pnpm run build:types\" \"pnpm run build:vite\"",
"docs:ai": "storybook build && pnpm run build:llms",
"build:llms": "node scripts/generate-llms-files.mjs",
"build:vite": "rimraf dist && vite build && pnpm run build:sfc-styles && pnpm run build:library-utilities && node scripts/build-archivo-font.mjs && node scripts/build-theme-css.mjs",
"build:vite": "rimraf dist && vite build && pnpm run build:umd && pnpm run build:sfc-styles && pnpm run build:library-utilities && node scripts/build-archivo-font.mjs && node scripts/build-theme-css.mjs",
"build:umd": "vite build --config vite.config.umd.ts && rimraf dist/umd-styles.css",
"build:sfc-styles": "vite build --config vite.config.sfc-styles.ts && rimraf dist/sfc-styles.js",
"build:library-utilities": "tailwindcss -i src/library-utilities-input.css -o dist/library-utilities.css --minify",
"build:types": "rimraf dist-types && vue-tsc -p tsconfig.build.json",
Expand All @@ -52,6 +56,7 @@
"generate:context-css": "node scripts/generate-context-css.mjs",
"generate": "pnpm run generate:semantic && pnpm run generate:semantic-css && pnpm run generate:context-modes && pnpm run generate:context-css",
"sync:primevue-icon-patches": "node scripts/sync-primevue-icon-patches.mjs",
"test:bundle-size": "node scripts/check-bundle-size.mjs",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src/**/*.ts src/**/*.vue",
"lint:fix": "eslint --fix src/**/*.ts src/**/*.vue",
Expand Down
150 changes: 150 additions & 0 deletions component-library/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Guards the tree-shakeability of the published ES build (issue #369).
*
* A consumer that imports one small component should pay for that component and
* nothing else. That property is easy to break by accident — dropping
* `sideEffects` from package.json, or going back to a single-file bundle, puts
* ~760 kB of unused PrimeVue styles and theme tokens into every consumer bundle
* again — and nothing else in CI would notice.
*
* How it works: bundle two tiny apps with Vite, one importing only `BccBadge`
* from the built `dist`, one importing nothing from the library at all. The
* difference is what the library actually costs. Comparing against a Vue-only
* baseline rather than an absolute number keeps the check stable across Vue
* upgrades.
*
* Expected to run AFTER `pnpm run build:vite`.
* Usage: node scripts/check-bundle-size.mjs
*/

import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';

const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');

/**
* Budget for the library's own contribution to a single-small-component bundle.
* At the time of writing `BccBadge` costs ~3.5 kB on top of Vue; the headroom
* absorbs normal growth while still catching a collapse back to no
* tree-shaking, which costs three orders of magnitude more.
*/
const BUDGET_BYTES = 40 * 1024;

/**
* The probe app has to resolve the library the way a real consumer does, by
* package name, so that the `exports` map and — crucially — the `sideEffects`
* field are honoured. A path alias would bypass both. So stage the built
* package under our own node_modules and import it from a directory inside the
* project, where its dependencies resolve.
*/
const PROBE_PACKAGE = '@bcc-code/component-library-tree-shake-probe';
const probePackageDir = join(projectRoot, 'node_modules', PROBE_PACKAGE);
const probeAppDir = join(projectRoot, 'node_modules', '.bundle-size-probe');

const distDir = join(projectRoot, 'dist');
if (!existsSync(join(distDir, 'index.js'))) {
console.error('check-bundle-size: dist/index.js not found. Run `pnpm run build:vite` first.');
process.exit(1);
}

const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));

function stageProbe() {
rmSync(probePackageDir, { recursive: true, force: true });
rmSync(probeAppDir, { recursive: true, force: true });
mkdirSync(probePackageDir, { recursive: true });
mkdirSync(join(probeAppDir, 'src'), { recursive: true });

cpSync(distDir, join(probePackageDir, 'dist'), { recursive: true });
writeFileSync(
join(probePackageDir, 'package.json'),
JSON.stringify(
{
name: PROBE_PACKAGE,
version: packageJson.version,
type: 'module',
sideEffects: packageJson.sideEffects,
module: packageJson.module,
exports: { '.': { import: { default: packageJson.module } } },
},
null,
2,
),
);

writeFileSync(
join(probeAppDir, 'src', 'baseline.js'),
["import { createApp, h } from 'vue';", "createApp({ render: () => h('div', 'hi') }).mount('#app');", ''].join(
'\n',
),
);
writeFileSync(
join(probeAppDir, 'src', 'one-component.js'),
[
"import { createApp, h } from 'vue';",
`import { BccBadge } from '${PROBE_PACKAGE}';`,
"createApp({ render: () => h(BccBadge, null, () => 'hi') }).mount('#app');",
'',
].join('\n'),
);
}

async function bundleSize(entry) {
const outDir = join(probeAppDir, `out-${entry}`);
await build({
root: probeAppDir,
configFile: false,
logLevel: 'error',
build: {
outDir,
emptyOutDir: true,
rollupOptions: { input: join(probeAppDir, 'src', `${entry}.js`) },
},
});

const assets = join(outDir, 'assets');
return readdirSync(assets)
.filter((file) => file.endsWith('.js'))
.reduce((total, file) => total + statSync(join(assets, file)).size, 0);
}

const kb = (bytes) => `${(bytes / 1024).toFixed(1)} kB`;

try {
stageProbe();

const baseline = await bundleSize('baseline');
const oneComponent = await bundleSize('one-component');
const libraryCost = oneComponent - baseline;

console.log(`check-bundle-size: Vue-only baseline ${kb(baseline)}`);
console.log(`check-bundle-size: baseline + BccBadge ${kb(oneComponent)}`);
console.log(`check-bundle-size: library contribution ${kb(libraryCost)} (budget ${kb(BUDGET_BYTES)})`);

if (libraryCost > BUDGET_BYTES) {
console.error(
[
'',
`check-bundle-size: FAILED — importing a single component pulls in ${kb(libraryCost)},`,
`over the ${kb(BUDGET_BYTES)} budget. The ES build is no longer tree-shakeable.`,
'',
'Most likely causes:',
' - package.json lost its "sideEffects" field',
' - vite.config.ts lost `output.preserveModules` (a single-file bundle cannot be shaken)',
' - a new module-level side effect became reachable from src/index.ts',
'',
'See https://github.com/bcc-code/bcc-design/issues/369',
].join('\n'),
);
process.exit(1);
}

console.log('check-bundle-size: OK');
} finally {
rmSync(probePackageDir, { recursive: true, force: true });
rmSync(probeAppDir, { recursive: true, force: true });
}
76 changes: 68 additions & 8 deletions component-library/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,52 @@ import { componentDataAttrPlugin } from './vite-plugin-component-attr';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

/**
* Maps a Rollup module id to its output path so the ES build stays tree-shakeable.
*
* `preserveModules` emits one file per module instead of one big bundle, which is
* what makes tree-shaking work for consumers (see the note on the build config
* below). Rollup's default naming would place bundled dependencies under
* `dist/node_modules/.pnpm/<pkg>@<version>_<hash>/...`, which is unusable: npm
* always strips `node_modules` directories from published tarballs, and the
* `.pnpm` path embeds dependency versions so file paths would churn on every
* upgrade. Everything from a dependency is therefore re-rooted under `dist/vendor`
* at its package-relative path.
*
* The mapping has to stay injective, or two modules collapse onto one file and
* Rollup fails with a module importing itself. Two things make ids collide once
* the path alone is used: a non-`.js` extension (a package shipping both
* `index.js` and `index.mjs`) and the `?commonjs-*` query suffixes that
* `@rollup/plugin-commonjs` appends to synthesize helper modules. Both are folded
* into the file name rather than dropped.
*/
function moduleFileName(id: string): string {
const [filePath, query] = id.split('?');
const lastNodeModules = filePath.lastIndexOf('node_modules/');

// Our own sources (and Rollup's virtual modules) keep Rollup's default naming,
// which is already relative to `preserveModulesRoot`.
if (lastNodeModules === -1) {
return query ? `[name].${sanitizeQuery(query)}.js` : '[name].js';
}

// `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/<path>` -> `<pkg>/<path>`
const packageRelative = filePath.slice(lastNodeModules + 'node_modules/'.length);
const extension = packageRelative.match(/\.(m|c)?js$/)?.[0];
const base = extension ? packageRelative.slice(0, -extension.length) : packageRelative;

const parts = [base];
// `.js` is the output extension anyway, so only record the others.
if (extension && extension !== '.js') parts.push(extension.slice(1));
if (query) parts.push(sanitizeQuery(query));

return `vendor/${parts.join('.')}.js`;
}

function sanitizeQuery(query: string): string {
return query.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '');
}

export default defineConfig({
plugins: [componentDataAttrPlugin(), vue(), tailwindcss()],
resolve: {
Expand All @@ -17,20 +63,34 @@ export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'BccComponentLibrary',
fileName: 'component-library',
formats: ['es', 'umd'],
// UMD is built separately (vite.config.umd.ts) because `preserveModules`
// only supports the ES format.
formats: ['es'],
},
rollupOptions: {
// Only Vue is external; PrimeVue and Tailwind are bundled so consumers need only this lib
// Only Vue is external; PrimeVue and Tailwind are bundled so consumers need only this lib.
// PrimeVue in particular must stay bundled: the `@primevue/icons` pnpm patch that swaps
// in `@bcc-code/icons-vue` only reaches consumers through our own build output.
external: ['vue'],
output: {
exports: 'named',
globals: {
vue: 'Vue',
},
// One output file per module. A single-file bundle is not tree-shakeable:
// PrimeVue's style modules and the theme preset run `BaseStyle.extend()` /
// `definePreset()` at module top level, and once concatenated those become
// impure top-level statements a consumer's bundler cannot drop. That put
// ~785 kB of unused code in every consumer bundle (see issue #369).
// This only pays off together with `sideEffects` in package.json.
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: (chunk) => moduleFileName(chunk.facadeModuleId ?? chunk.name),
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: (asset) =>
asset.names.some((name) => name.endsWith('.css')) ? 'index.css' : 'assets/[name][extname]',
},
},
cssCodeSplit: true,
// Keep every stylesheet in a single dist/index.css, as the `./style.css`
// export promises. With code splitting the CSS fragments across the
// per-module output instead.
cssCodeSplit: false,
},
});
57 changes: 57 additions & 0 deletions component-library/vite.config.umd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';
import { resolve } from 'path';
import { defineConfig } from 'vite';
import { componentDataAttrPlugin } from './vite-plugin-component-attr';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

/**
* Auxiliary Vite build that produces ONLY the single-file UMD bundle
* (dist/component-library.umd.cjs) served to `require()` consumers.
*
* Why a separate config: the main build (vite.config.ts) uses `preserveModules`
* so the ES output is tree-shakeable, and Rollup only supports that option for
* the ES format. UMD is inherently one file, so it cannot be tree-shaken either
* way — nothing is lost by keeping it as it was.
*
* `cssCodeSplit` stays true to match the previous combined build: for non-ES
* formats Vite inlines the stylesheet into the bundle and injects it at runtime
* via a <style> tag, so `require()` consumers get styles without importing
* `style.css` themselves. That costs ~240 kB of duplicated CSS inside the UMD
* file, but changing it would break those consumers.
*
* Because the CSS is inlined, no stylesheet asset is normally emitted here. The
* `assetFileNames` override is a guard: any asset that does get emitted would
* otherwise default to `index.css` and clobber the real dist/index.css written
* by the main build. The renamed copy is deleted by the `build:umd` script.
*/
export default defineConfig({
plugins: [componentDataAttrPlugin(), vue(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
build: {
emptyOutDir: false,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'BccComponentLibrary',
fileName: 'component-library',
formats: ['umd'],
},
rollupOptions: {
external: ['vue'],
output: {
exports: 'named',
globals: {
vue: 'Vue',
},
assetFileNames: 'umd-styles.css',
},
},
cssCodeSplit: true,
},
});