diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 4d4d3dc2d3a..afe294189a1 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -70,8 +70,8 @@ pnpm benchmark:bundle-size:analyze --id react-router.minimal --top-sources 30 ## CI Reporting - PR workflow generates a sticky comment with: - - current gzip values - - baseline delta + - current gzip, initial gzip, raw, and Brotli values + - per-metric deltas from the `main` baseline - inline sparkline trend - Pushes to `main` publish historical chart data to GitHub Pages via `benchmark-action/github-action-benchmark`. diff --git a/scripts/benchmarks/bundle-size/pr-report.mjs b/scripts/benchmarks/bundle-size/pr-report.mjs index 8c585632305..eda88d22f04 100644 --- a/scripts/benchmarks/bundle-size/pr-report.mjs +++ b/scripts/benchmarks/bundle-size/pr-report.mjs @@ -10,15 +10,11 @@ const DEFAULT_MARKER = '' const INT_FORMAT = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0, }) -const FIXED_2_FORMAT = new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}) -const PERCENT_FORMAT = new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, +const FIXED_1_FORMAT = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, }) +const METRIC_KEYS = ['gzipBytes', 'initialGzipBytes', 'rawBytes', 'brotliBytes'] function parseArgs(argv) { const { values } = parseNodeArgs({ @@ -106,14 +102,14 @@ function formatBytes(bytes, opts = {}) { let value if (absBytes < 1024) { - value = `${INT_FORMAT.format(absBytes)} B` + value = `${INT_FORMAT.format(absBytes)} B` } else { const kib = absBytes / 1024 if (kib < 1024) { - value = `${FIXED_2_FORMAT.format(kib)} KiB` + value = `${FIXED_1_FORMAT.format(kib)} KiB` } else { const mib = kib / 1024 - value = `${FIXED_2_FORMAT.format(mib)} MiB` + value = `${FIXED_1_FORMAT.format(mib)} MiB` } } @@ -126,9 +122,11 @@ function formatDelta(current, baseline) { } const delta = current - baseline - const ratio = baseline === 0 ? 0 : Math.abs(delta / baseline) - const sign = delta > 0 ? '+' : delta < 0 ? '-' : '' - return `${formatBytes(delta, { signed: true })} (${sign}${PERCENT_FORMAT.format(ratio)})` + return formatBytes(delta, { signed: true }) +} + +function formatMetricCell(current, baseline) { + return `${formatBytes(current)}
${formatDelta(current, baseline)}` } function sparkline(values) { @@ -192,6 +190,27 @@ function buildSeriesByScenario(historyEntries) { return map } +function parseBenchmarkExtra(extra) { + if (typeof extra !== 'string') { + return {} + } + + const values = {} + for (const part of extra.split(';')) { + const [key, rawValue] = part.trim().split('=') + const value = Number(rawValue) + if (key && Number.isFinite(value)) { + values[key] = value + } + } + + return { + rawBytes: values.raw, + brotliBytes: values.brotli, + initialGzipBytes: values.initial_gzip, + } +} + function resolveBaselineFromHistory(historyEntries, baseSha) { if (!historyEntries.length) { return { @@ -212,7 +231,10 @@ function resolveBaselineFromHistory(historyEntries, baseSha) { const benchesByName = new Map() for (const bench of baseEntry?.benches || []) { if (typeof bench?.name === 'string' && Number.isFinite(bench?.value)) { - benchesByName.set(bench.name, Number(bench.value)) + benchesByName.set(bench.name, { + gzipBytes: Number(bench.value), + ...parseBenchmarkExtra(bench.extra), + }) } } @@ -228,7 +250,7 @@ function resolveBaselineFromCurrentJson(currentJson) { const benchesByName = new Map() for (const metric of currentJson?.metrics || []) { if (typeof metric?.id === 'string' && Number.isFinite(metric?.gzipBytes)) { - benchesByName.set(metric.id, Number(metric.gzipBytes)) + benchesByName.set(metric.id, metric) } } @@ -272,9 +294,15 @@ async function main() { const rows = [] for (const metric of metrics) { - const baselineValue = baseline.benchesByName.get(metric.id) + const baselineMetric = baseline.benchesByName.get(metric.id) + const hasBaseline = METRIC_KEYS.every((key) => + Number.isFinite(baselineMetric?.[key]), + ) - if (Number.isFinite(baselineValue) && metric.gzipBytes === baselineValue) { + if ( + hasBaseline && + METRIC_KEYS.every((key) => metric[key] === baselineMetric[key]) + ) { continue } @@ -292,12 +320,20 @@ async function main() { rows.push({ id: metric.id, - current: metric.gzipBytes, - raw: metric.rawBytes, - brotli: metric.brotliBytes, - initial: metric.initialGzipBytes, - hasBaseline: Number.isFinite(baselineValue), - deltaCell: formatDelta(metric.gzipBytes, baselineValue), + currentCell: formatMetricCell( + metric.gzipBytes, + baselineMetric?.gzipBytes, + ), + initialCell: formatMetricCell( + metric.initialGzipBytes, + baselineMetric?.initialGzipBytes, + ), + rawCell: formatMetricCell(metric.rawBytes, baselineMetric?.rawBytes), + brotliCell: formatMetricCell( + metric.brotliBytes, + baselineMetric?.brotliBytes, + ), + hasBaseline, trendCell: sparkline(historySeries.slice(-args.trendPoints)), }) } @@ -330,13 +366,13 @@ async function main() { ) lines.push('') lines.push( - '| Scenario | Current (gzip) | Delta vs baseline | Initial gzip | Raw | Brotli | Trend |', + '| Scenario | Current (gzip) | Initial (gzip) | Raw | Brotli | Trend |', ) - lines.push('| --- | ---: | ---: | ---: | ---: | ---: | --- |') + lines.push('| --- | ---: | ---: | ---: | ---: | --- |') for (const row of rows) { lines.push( - `| \`${row.id}\` | ${formatBytes(row.current)} | ${row.deltaCell} | ${formatBytes(row.initial)} | ${formatBytes(row.raw)} | ${formatBytes(row.brotli)} | ${row.trendCell} |`, + `| \`${row.id}\` | ${row.currentCell} | ${row.initialCell} | ${row.rawCell} | ${row.brotliCell} |
${row.trendCell}
|`, ) } diff --git a/scripts/benchmarks/bundle-size/pr-report.test.mjs b/scripts/benchmarks/bundle-size/pr-report.test.mjs index f70081452d5..e7e397ec3ed 100644 --- a/scripts/benchmarks/bundle-size/pr-report.test.mjs +++ b/scripts/benchmarks/bundle-size/pr-report.test.mjs @@ -9,13 +9,22 @@ import test from 'node:test' const execFileAsync = promisify(execFile) const reportScript = new URL('./pr-report.mjs', import.meta.url) -function metric(id, gzipBytes) { +function metric(id, gzipBytes, overrides = {}) { return { id, gzipBytes, rawBytes: gzipBytes * 3, brotliBytes: gzipBytes - 100, initialGzipBytes: gzipBytes - 10, + ...overrides, + } +} + +function historyBench(value) { + return { + name: value.id, + value: value.gzipBytes, + extra: `raw=${value.rawBytes}; brotli=${value.brotliBytes}; initial_gzip=${value.initialGzipBytes}`, } } @@ -80,8 +89,10 @@ test('renders a concise message when no measured scenario changed', async () => }) test('renders only scenarios that changed against the historical baseline', async () => { + const unchangedMetric = metric('react-router.minimal', 1_000) + const baselineMetric = metric('react-router.full', 1_100) const current = currentJson([ - metric('react-router.minimal', 1_000), + unchangedMetric, metric('react-router.full', 1_200), ]) const history = { @@ -90,8 +101,8 @@ test('renders only scenarios that changed against the historical baseline', asyn { commit: { id: 'baseline-sha' }, benches: [ - { name: 'react-router.minimal', value: 1_000 }, - { name: 'react-router.full', value: 1_100 }, + historyBench(unchangedMetric), + historyBench(baselineMetric), ], }, ], @@ -110,7 +121,12 @@ test('renders only scenarios that changed against the historical baseline', asyn assert.doesNotMatch(report, /`react-router\.minimal`/) assert.match( report, - /\| `react-router\.full` \| 1\.17 KiB \| \+100 B \(\+9\.09%\) \|/, + /\| Scenario \| Current \(gzip\) \| Initial \(gzip\) \| Raw \| Brotli \| Trend \|/, + ) + assert.doesNotMatch(report, /Delta vs baseline|%/) + assert.match( + report, + /\| `react-router\.full` \| 1\.2 KiB
\+100 B \| 1\.2 KiB
\+100 B \| 3\.5 KiB
\+300 B \| 1\.1 KiB
\+100 B \|
▁█<\/pre> \|/,
   )
 })
 
@@ -127,5 +143,22 @@ test('keeps scenarios that do not have baseline data visible', async () => {
     /The following scenarios have bundle-size changes or lack baseline data for comparison:/,
   )
   assert.doesNotMatch(report, /`react-router\.minimal`/)
-  assert.match(report, /\| `new-scenario` \| 900 B \| n\/a \|/)
+  assert.match(
+    report,
+    /\| `new-scenario` \| 900 B
n\/a \| 890 B
n\/a \| 2\.6 KiB
n\/a \| 800 B
n\/a \|/, + ) +}) + +test('renders scenarios when only a secondary metric changed', async () => { + const baselineMetric = metric('react-router.minimal', 1_000) + const current = currentJson([ + metric('react-router.minimal', 1_000, { rawBytes: 3_002 }), + ]) + const baseline = currentJson([baselineMetric]) + const report = await generateReport({ current, baseline }) + + assert.match( + report, + /\| `react-router\.minimal` \| 1,000 B
0 B \| 990 B
0 B \| 2\.9 KiB
\+2 B \| 900 B
0 B \|/, + ) })