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▁█<\/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 \|/,
+ )
})