fix(ci): report lazy bundle chunks separately (#1710)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sam Potts
2026-06-19 13:42:08 +10:00
committed by GitHub
co-authored by Cursor Agent
parent edfec1c93e
commit 17d6ebfc30
3 changed files with 288 additions and 68 deletions
+125 -33
View File
@@ -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('<details>');
lines.push(`<summary><b>${label} (${catEntries.length})</b></summary>`);
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('<details>');
lines.push(`<summary><b>Entries (${entries.length})</b></summary>`);
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('<details>');
lines.push('<summary>️ How to interpret</summary>');
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('</details>');
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');
+154 -33
View File
@@ -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 === '<stdin>',
)
.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',
+9 -2
View File
@@ -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