diff --git a/.github/workflows/design-library-tests.yml b/.github/workflows/design-library-tests.yml index d9e8aa72..da2fba73 100644 --- a/.github/workflows/design-library-tests.yml +++ b/.github/workflows/design-library-tests.yml @@ -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 diff --git a/component-library/README.md b/component-library/README.md index d3198c82..ae9fa3ac 100644 --- a/component-library/README.md +++ b/component-library/README.md @@ -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 @@ -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//...`. 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/`: diff --git a/component-library/package.json b/component-library/package.json index 71e9fd81..0a9a2506 100644 --- a/component-library/package.json +++ b/component-library/package.json @@ -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", @@ -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", @@ -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", diff --git a/component-library/scripts/check-bundle-size.mjs b/component-library/scripts/check-bundle-size.mjs new file mode 100644 index 00000000..314d2c05 --- /dev/null +++ b/component-library/scripts/check-bundle-size.mjs @@ -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 }); +} diff --git a/component-library/vite.config.ts b/component-library/vite.config.ts index c5078718..430ea209 100644 --- a/component-library/vite.config.ts +++ b/component-library/vite.config.ts @@ -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/@_/...`, 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/@/node_modules//` -> `/` + 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: { @@ -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, }, }); diff --git a/component-library/vite.config.umd.ts b/component-library/vite.config.umd.ts new file mode 100644 index 00000000..0a6b27f1 --- /dev/null +++ b/component-library/vite.config.umd.ts @@ -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