Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
90 changes: 63 additions & 27 deletions scripts/benchmarks/bundle-size/pr-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,11 @@ const DEFAULT_MARKER = '<!-- bundle-size-benchmark -->'
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({
Expand Down Expand Up @@ -106,14 +102,14 @@ function formatBytes(bytes, opts = {}) {

let value
if (absBytes < 1024) {
value = `${INT_FORMAT.format(absBytes)} B`
value = `${INT_FORMAT.format(absBytes)}&nbsp;B`
} else {
const kib = absBytes / 1024
if (kib < 1024) {
value = `${FIXED_2_FORMAT.format(kib)} KiB`
value = `${FIXED_1_FORMAT.format(kib)}&nbsp;KiB`
} else {
const mib = kib / 1024
value = `${FIXED_2_FORMAT.format(mib)} MiB`
value = `${FIXED_1_FORMAT.format(mib)}&nbsp;MiB`
}
}

Expand All @@ -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)}<br>${formatDelta(current, baseline)}`
}

function sparkline(values) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
})
}
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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)),
})
}
Expand Down Expand Up @@ -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} | <pre>${row.trendCell}</pre> |`,
)
}

Expand Down
45 changes: 39 additions & 6 deletions scripts/benchmarks/bundle-size/pr-report.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}
}

Expand Down Expand Up @@ -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 = {
Expand All @@ -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),
],
},
],
Expand All @@ -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&nbsp;KiB<br>\+100&nbsp;B \| 1\.2&nbsp;KiB<br>\+100&nbsp;B \| 3\.5&nbsp;KiB<br>\+300&nbsp;B \| 1\.1&nbsp;KiB<br>\+100&nbsp;B \| <pre>▁█<\/pre> \|/,
)
})

Expand All @@ -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&nbsp;B<br>n\/a \| 890&nbsp;B<br>n\/a \| 2\.6&nbsp;KiB<br>n\/a \| 800&nbsp;B<br>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&nbsp;B<br>0&nbsp;B \| 990&nbsp;B<br>0&nbsp;B \| 2\.9&nbsp;KiB<br>\+2&nbsp;B \| 900&nbsp;B<br>0&nbsp;B \|/,
)
})
Loading