diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index 098dda07..46c32b66 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -7,8 +7,8 @@ * When --base is omitted, generates a local report showing current sizes. * When --base is provided, generates a comparison report with diffs. * - * Reads JSON arrays of { name, size, type, category?, format, standaloneSize? } - * entries produced by bundle-size.js. + * Reads JSON arrays of { name, size, type, category?, format, standaloneSize?, + * lazySize?, totalSize? } entries produced by bundle-size.js. */ import { readFileSync } from 'node:fs'; @@ -44,6 +44,56 @@ function statusIcon(current, previous) { return pct > 10 ? '🔴' : 'đŸ”ē'; } +function entryStatusIcon(current, previous) { + if (!previous) return '🆕'; + const currentSize = comparisonSize(current); + const previousSize = comparisonSize(previous); + if (Math.abs(currentSize - previousSize) > 300) { + return statusIcon(currentSize, previousSize); + } + const currentLazy = comparisonLazySize(current); + const previousLazy = comparisonLazySize(previous); + if (currentLazy !== previousLazy) { + return statusIcon(currentLazy, previousLazy); + } + return statusIcon(currentLazy, previousLazy); +} + +function lazySize(entry) { + if (!entry) return 0; + return ( + entry.lazySize ?? + Math.max(0, (entry.totalSize ?? entry.size) - entry.size) + ); +} + +function lazyLabel(entry) { + const lazy = lazySize(entry); + return lazy > 0 ? formatBytes(lazy) : '—'; +} + +function lazyDelta(current, previous) { + const currentLazy = lazySize(current); + const previousLazy = lazySize(previous); + if (currentLazy === 0 && previousLazy === 0) return '—'; + return formatDelta(currentLazy, previousLazy).bytes; +} + +function comparisonSize(entry) { + return entry?.standaloneSize ?? entry?.size ?? 0; +} + +function comparisonLazySize(entry) { + return entry?.standaloneLazySize ?? lazySize(entry); +} + +function comparisonLazyDelta(current, previous) { + const currentLazy = comparisonLazySize(current); + const previousLazy = comparisonLazySize(previous); + if (currentLazy === 0 && previousLazy === 0) return '—'; + return formatDelta(currentLazy, previousLazy).bytes; +} + /** Preferred display order for packages. Unlisted packages sort to the end. */ const PACKAGE_ORDER = ['html', 'react', 'core', 'element', 'store', 'utils']; @@ -112,26 +162,37 @@ function generateCategoryBreakdowns(entries, pkg) { const label = CATEGORY_LABELS[cat] ?? cat; const isSkin = cat === 'skin'; + const hasLazy = catEntries.some((entry) => lazySize(entry) > 0); lines.push('
'); lines.push(`${label} (${catEntries.length})`); lines.push(''); if (isSkin) { - lines.push('| Entry | Type | Size |'); - lines.push('|---|---|--:|'); + lines.push( + hasLazy + ? '| Entry | Type | Initial | Lazy |' + : '| Entry | Type | Initial |', + ); + lines.push(hasLazy ? '|---|---|--:|--:|' : '|---|---|--:|'); } else { - lines.push('| Entry | Size |'); - lines.push('|---|--:|'); + lines.push( + hasLazy ? '| Entry | Initial | Lazy |' : '| Entry | Initial |', + ); + lines.push(hasLazy ? '|---|--:|--:|' : '|---|--:|'); } for (const entry of catEntries) { const el = entryLabel(entry.name, pkg); const fmt = entry.format ?? 'js'; if (isSkin) { - lines.push(`| ${el} | ${fmt} | ${formatBytes(entry.size)} |`); + const cells = [`${el}`, fmt, formatBytes(entry.size)]; + if (hasLazy) cells.push(lazyLabel(entry)); + lines.push(`| ${cells.join(' | ')} |`); } else { - lines.push(`| ${el} | ${formatBytes(entry.size)} |`); + const cells = [`${el}`, formatBytes(entry.size)]; + if (hasLazy) cells.push(lazyLabel(entry)); + lines.push(`| ${cells.join(' | ')} |`); } } @@ -154,12 +215,15 @@ function generateFlatBreakdown(entries, pkg) { lines.push('
'); lines.push(`Entries (${entries.length})`); lines.push(''); - lines.push('| Entry | Size |'); - lines.push('|---|--:|'); + const hasLazy = entries.some((entry) => lazySize(entry) > 0); + lines.push(hasLazy ? '| Entry | Initial | Lazy |' : '| Entry | Initial |'); + lines.push(hasLazy ? '|---|--:|--:|' : '|---|--:|'); for (const entry of entries) { const el = entryLabel(entry.name, pkg); - lines.push(`| ${el} | ${formatBytes(entry.size)} |`); + const cells = [`${el}`, formatBytes(entry.size)]; + if (hasLazy) cells.push(lazyLabel(entry)); + lines.push(`| ${cells.join(' | ')} |`); } lines.push(''); @@ -174,8 +238,8 @@ function generateFlatBreakdown(entries, pkg) { // --------------------------------------------------------------------------- function generateComparisonReport(current, base) { - const baseMap = Object.fromEntries(base.map((e) => [e.name, e.size])); const currentMap = Object.fromEntries(current.map((e) => [e.name, e.size])); + const baseEntryMap = Object.fromEntries(base.map((e) => [e.name, e])); // Standalone size lookups — used to gate UI component diffs. // UI marginal sizes shift when root content changes (brotli compression is @@ -230,7 +294,12 @@ function generateComparisonReport(current, base) { // New entry — always surface if (prevStandalone === undefined) return true; const curStandalone = currentStandaloneMap[e.name]; - return Math.abs(curStandalone - prevStandalone) > 300; + const initialChanged = Math.abs(curStandalone - prevStandalone) > 300; + const currentLazy = comparisonLazySize(e); + const previousEntry = baseEntryMap[e.name]; + const previousLazy = comparisonLazySize(previousEntry); + const lazyChanged = Math.abs(currentLazy - previousLazy) > 300; + return initialChanged || lazyChanged; }); // Entries that existed in base but are missing in PR (removed) @@ -250,24 +319,27 @@ function generateComparisonReport(current, base) { lines.push(`## ${pkgIcon} @videojs/${pkg}`); lines.push(''); - lines.push('| Path | Base | PR | Diff | % | |'); - lines.push('|---|--:|--:|--:|--:|:-:|'); + lines.push('| Path | Base initial | PR initial | Diff | % | Lazy | |'); + lines.push('|---|--:|--:|--:|--:|--:|:-:|'); for (const entry of changed) { const el = entryLabel(entry.name, pkg); - const prev = baseMap[entry.name]; - const d = formatDelta(entry.size, prev); - const status = statusIcon(entry.size, prev); - const baseSize = prev !== undefined ? formatBytes(prev) : '—'; + const previousEntry = baseEntryMap[entry.name]; + const prevInitial = previousEntry ? comparisonSize(previousEntry) : undefined; + const currentInitial = comparisonSize(entry); + const d = formatDelta(currentInitial, prevInitial); + const status = entryStatusIcon(entry, previousEntry); + const baseSize = prevInitial !== undefined ? formatBytes(prevInitial) : '—'; lines.push( - `| ${el} | ${baseSize} | ${formatBytes(entry.size)} | ${d.bytes} | ${d.pct} | ${status} |`, + `| ${el} | ${baseSize} | ${formatBytes(currentInitial)} | ${d.bytes} | ${d.pct} | ${comparisonLazyDelta(entry, previousEntry)} | ${status} |`, ); } for (const entry of removed) { const el = entryLabel(entry.name, pkg); + const previousInitial = comparisonSize(entry); lines.push( - `| ${el} | ${formatBytes(entry.size)} | — | −${formatBytes(entry.size)} | −100% | đŸ—‘ī¸ |`, + `| ${el} | ${formatBytes(previousInitial)} | — | −${formatBytes(previousInitial)} | −100% | ${comparisonLazyDelta(undefined, entry)} | đŸ—‘ī¸ |`, ); } @@ -289,7 +361,9 @@ function generateComparisonReport(current, base) { lines.push('
'); lines.push('â„šī¸ How to interpret'); lines.push(''); - lines.push('All sizes are standalone totals (minified + brotli).'); + lines.push( + 'JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.', + ); lines.push(''); lines.push('| Icon | Meaning |'); lines.push('|---|---|'); @@ -299,7 +373,7 @@ function generateComparisonReport(current, base) { lines.push('| đŸ”Ŋ | Decreased |'); lines.push('| 🆕 | New (no baseline) |'); lines.push(''); - lines.push('Run `pnpm size` locally to check current sizes.'); + lines.push('Run `pnpm size` locally to check current initial sizes.'); lines.push('
'); return lines.join('\n'); @@ -392,13 +466,18 @@ function generateLocalReport(current) { const label = CATEGORY_LABELS[cat] ?? cat; const isSkin = cat === 'skin'; + const hasLazy = catEntries.some((entry) => lazySize(entry) > 0); lines.push(''); lines.push(` ${ansi.dim(label)}`); const header = isSkin - ? ['Entry', 'Type', 'Size'] - : ['Entry', 'Size']; + ? hasLazy + ? ['Entry', 'Type', 'Initial', 'Lazy'] + : ['Entry', 'Type', 'Initial'] + : hasLazy + ? ['Entry', 'Initial', 'Lazy'] + : ['Entry', 'Initial']; const rows = [header]; for (const entry of catEntries) { @@ -406,37 +485,50 @@ function generateLocalReport(current) { entry.name.replace(`@videojs/${pkg}`, '') || '.'; const fmt = entry.format ?? 'js'; if (isSkin) { - rows.push([ + const row = [ { text: subpath, style: ansi.cyan }, { text: fmt, style: ansi.dim }, colorSize(entry.size), - ]); + ]; + if (hasLazy) row.push(colorSize(lazySize(entry))); + rows.push(row); } else { - rows.push([ + const row = [ { text: subpath, style: ansi.cyan }, colorSize(entry.size), - ]); + ]; + if (hasLazy) row.push(colorSize(lazySize(entry))); + rows.push(row); } } lines.push(printTable(rows)); } } else { - const rows = [['Entry', 'Size']]; + const hasLazy = entries.some((entry) => lazySize(entry) > 0); + const rows = [ + hasLazy ? ['Entry', 'Initial', 'Lazy'] : ['Entry', 'Initial'], + ]; for (const entry of entries) { const subpath = entry.name.replace(`@videojs/${pkg}`, '') || '.'; - rows.push([ + const row = [ { text: subpath, style: ansi.cyan }, colorSize(entry.size), - ]); + ]; + if (hasLazy) row.push(colorSize(lazySize(entry))); + rows.push(row); } lines.push(printTable(rows)); } } lines.push(''); - lines.push(ansi.dim('Sizes are minified + brotli.')); + lines.push( + ansi.dim( + 'Initial sizes are minified + brotli; lazy chunks are shown separately.', + ), + ); lines.push(''); return lines.join('\n'); diff --git a/.github/scripts/bundle-size.js b/.github/scripts/bundle-size.js index 16dcc5e2..37367ef1 100644 --- a/.github/scripts/bundle-size.js +++ b/.github/scripts/bundle-size.js @@ -4,7 +4,9 @@ * Auto-discovers packages from `packages/`, reads their `exports` field to find * entry points, and externalizes `peerDependencies`. * - * All sizes are standalone totals (minified + brotli). + * JS sizes are initial static graph totals (minified + brotli). Lazy dynamic + * chunks are measured separately so they stay visible without counting as + * eager entry cost. * * Wildcard exports (e.g., `./ui/*`, `./media/⁕/index.js`) are resolved to * actual files on disk. Supports both file-level (`*.js`) and directory-level @@ -15,7 +17,7 @@ * Each entry includes a `category` field for grouped reporting in html/react: * preset, media, player, skin, ui, or feature. * - * Usage: node .github/scripts/bundle-size.js [--json output.json] + * Usage: node .github/scripts/bundle-size.js [--root repo-root] [--json output.json] */ import { build, transform } from 'esbuild'; @@ -26,11 +28,15 @@ import { writeFileSync, existsSync, } from 'node:fs'; -import { resolve, dirname, join, basename } from 'node:path'; +import { resolve, dirname, join, basename, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../..'); +const rootIndex = process.argv.indexOf('--root'); +const ROOT = + rootIndex !== -1 + ? resolve(process.argv[rootIndex + 1]) + : resolve(__dirname, '../..'); const PACKAGES_DIR = join(ROOT, 'packages'); const SKIP_PACKAGES = new Set([ @@ -150,9 +156,70 @@ function buildPresetEntry(pkgShortName, config, distDir) { * @property {string} [category] - preset, media, player, skin, ui, feature (only for html/react) * @property {'js' | 'css'} format * @property {number} [standaloneSize] - For UI components: standalone size used for stable diff gating + * @property {number} [totalSize] - Initial + lazy dynamic chunk size + * @property {number} [lazySize] - Lazy dynamic chunk size + * @property {number} [chunkCount] - Number of dynamic chunks + * @property {number} [standaloneTotalSize] - Standalone initial + lazy dynamic chunk size + * @property {number} [standaloneLazySize] - Standalone lazy dynamic chunk size */ -/** Bundle entry points with esbuild and return the minified + brotli size. */ +function compressSize(code) { + return brotliCompressSync(Buffer.from(code), { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + }, + }).length; +} + +function outputPath(path) { + return resolve(ROOT, path); +} + +function entryKey(path) { + return relative(ROOT, path).replaceAll('\\', '/'); +} + +function staticOutputs(metafile, entryPoints) { + const entryPointsSet = entryPoints + ? new Set(entryPoints.map((entry) => entryKey(entry))) + : null; + const outputs = new Set(); + const queue = Object.entries(metafile.outputs) + .filter(([, output]) => + entryPointsSet + ? entryPointsSet.has(output.entryPoint) + : output.entryPoint === '', + ) + .map(([path]) => path); + + for (const path of queue) { + if (outputs.has(path)) continue; + outputs.add(path); + + const output = metafile.outputs[path]; + for (const link of output.imports ?? []) { + if (link.kind === 'dynamic-import') continue; + if (metafile.outputs[link.path]) queue.push(link.path); + } + } + + return outputs; +} + +function sizeFields(measurement) { + return { + size: measurement.size, + ...(measurement.lazySize > 0 + ? { + totalSize: measurement.totalSize, + lazySize: measurement.lazySize, + chunkCount: measurement.chunkCount, + } + : {}), + }; +} + +/** Bundle entry points with esbuild and return initial and lazy sizes. */ async function measure(entryPoints, external = []) { const result = await build({ entryPoints, @@ -160,35 +227,44 @@ async function measure(entryPoints, external = []) { minify: true, treeShaking: true, format: 'esm', + splitting: true, + absWorkingDir: ROOT, write: false, outdir: '/tmp/bundle-size-out', external, + metafile: true, logLevel: 'silent', }); - const code = result.outputFiles.map((f) => f.text).join(''); - const compressed = brotliCompressSync(Buffer.from(code), { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - }, - }); + const sizeByPath = new Map( + result.outputFiles.map((file) => [file.path, compressSize(file.text)]), + ); + const staticPaths = staticOutputs(result.metafile, entryPoints); + let size = 0; + let totalSize = 0; - return compressed.length; + for (const [path] of Object.entries(result.metafile.outputs)) { + const bytes = sizeByPath.get(outputPath(path)) ?? 0; + totalSize += bytes; + if (staticPaths.has(path)) size += bytes; + } + + return { + size, + totalSize, + lazySize: Math.max(0, totalSize - size), + chunkCount: Math.max(0, result.outputFiles.length - staticPaths.size), + }; } /** Minify a CSS file with esbuild then brotli-compress it. */ async function measureCSS(filePath) { const content = readFileSync(filePath, 'utf8'); const result = await transform(content, { loader: 'css', minify: true }); - const compressed = brotliCompressSync(Buffer.from(result.code), { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - }, - }); - return compressed.length; + return compressSize(result.code); } -/** Bundle a virtual entry (source string) with esbuild and return the minified + brotli size. */ +/** Bundle a virtual entry (source string) with esbuild and return initial and lazy sizes. */ async function measureVirtual(code, resolveDir, external = []) { const result = await build({ stdin: { contents: code, resolveDir, loader: 'js' }, @@ -196,20 +272,34 @@ async function measureVirtual(code, resolveDir, external = []) { minify: true, treeShaking: true, format: 'esm', + splitting: true, + absWorkingDir: ROOT, write: false, outdir: '/tmp/bundle-size-out', external, + metafile: true, logLevel: 'silent', }); - const output = result.outputFiles.map((f) => f.text).join(''); - const compressed = brotliCompressSync(Buffer.from(output), { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - }, - }); + const sizeByPath = new Map( + result.outputFiles.map((file) => [file.path, compressSize(file.text)]), + ); + const staticPaths = staticOutputs(result.metafile); + let size = 0; + let totalSize = 0; - return compressed.length; + for (const [path] of Object.entries(result.metafile.outputs)) { + const bytes = sizeByPath.get(outputPath(path)) ?? 0; + totalSize += bytes; + if (staticPaths.has(path)) size += bytes; + } + + return { + size, + totalSize, + lazySize: Math.max(0, totalSize - size), + chunkCount: Math.max(0, result.outputFiles.length - staticPaths.size), + }; } /** @@ -447,12 +537,13 @@ async function main() { // Always measure root — needed for UI marginal calculations even when // the root itself is excluded from results (categorized packages skip // root because presets are measured as virtual bundles instead). - const rootSize = await measure([pkg.rootPath], pkg.external); + const rootMeasurement = await measure([pkg.rootPath], pkg.external); + const rootSize = rootMeasurement.size; if (rootCat !== '_skip') { results.push({ name: pkg.name, - size: rootSize, + ...sizeFields(rootMeasurement), type: 'root', ...(rootCat ? { category: rootCat } : {}), format: 'js', @@ -480,12 +571,29 @@ async function main() { // compression non-linearity, so diffs must gate on standalone. let size; let standaloneSize; + let measurement; + let standaloneMeasurement; if (cat === 'ui') { const combined = await measure([pkg.rootPath, sub.path], pkg.external); - size = Math.max(0, combined - rootSize); - standaloneSize = await measure([sub.path], pkg.external); + standaloneMeasurement = await measure([sub.path], pkg.external); + size = Math.max(0, combined.size - rootSize); + const lazySize = Math.max( + 0, + combined.lazySize - rootMeasurement.lazySize, + ); + measurement = { + size, + totalSize: size + lazySize, + lazySize, + chunkCount: Math.max( + 0, + combined.chunkCount - rootMeasurement.chunkCount, + ), + }; + standaloneSize = standaloneMeasurement.size; } else { - size = await measure([sub.path], pkg.external); + measurement = await measure([sub.path], pkg.external); + size = measurement.size; } results.push({ @@ -494,7 +602,20 @@ async function main() { type: 'subpath', ...(cat ? { category: cat } : {}), format: 'js', + ...(measurement && measurement.lazySize > 0 + ? { + totalSize: measurement.totalSize, + lazySize: measurement.lazySize, + chunkCount: measurement.chunkCount, + } + : {}), ...(standaloneSize !== undefined ? { standaloneSize } : {}), + ...(standaloneMeasurement && standaloneMeasurement.lazySize > 0 + ? { + standaloneTotalSize: standaloneMeasurement.totalSize, + standaloneLazySize: standaloneMeasurement.lazySize, + } + : {}), }); } @@ -507,10 +628,10 @@ async function main() { const code = buildPresetEntry(pkgShortName, config, distDir); if (!code) continue; - const size = await measureVirtual(code, distDir, pkg.external); + const measurement = await measureVirtual(code, distDir, pkg.external); results.push({ name: `${pkg.name}${config.label}`, - size, + ...sizeFields(measurement), type: 'subpath', category: 'preset', format: 'js', diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 4007da03..ad413b4f 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -54,6 +54,13 @@ jobs: with: ref: ${{ github.base_ref }} + - name: Checkout PR scripts + uses: actions/checkout@v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: .bundle-size-pr + - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -75,9 +82,9 @@ jobs: run: | pnpm install - if [ -f .github/scripts/bundle-size.js ]; then + if [ -f .bundle-size-pr/.github/scripts/bundle-size.js ]; then pnpm build:packages - node .github/scripts/bundle-size.js --json base-size.json + node .bundle-size-pr/.github/scripts/bundle-size.js --root . --json base-size.json else echo '[]' > base-size.json fi