diff --git a/scripts/bench-builds.mts b/scripts/bench-builds.mts index d02fe276..e8cb233c 100644 --- a/scripts/bench-builds.mts +++ b/scripts/bench-builds.mts @@ -13,6 +13,7 @@ import { } from './benchmark/dev-server.mjs'; import { generateSyntheticFixture } from './benchmark/fixture.mts'; import { profiles } from './benchmark/profiles.mjs'; +import { collectBuildOutputStats } from './benchmark/bundle-size.mjs'; import { parsePluginReports, parseTimeStats, @@ -550,6 +551,12 @@ const runBenchmarkIteration = (benchmarkContext, index) => const pluginReports = parsePluginReports( `${commandResult.stdout}\n${commandResult.stderr}` ); + const bundleSize = + args.mode === 'build' && measured && commandResult.status === 0 + ? yield* tryPromise(() => + collectBuildOutputStats(path.join(fixtureRoot, 'build')) + ) + : null; if (commandResult.status !== 0 && args.failFast) { yield* Effect.fail( @@ -574,6 +581,7 @@ const runBenchmarkIteration = (benchmarkContext, index) => userMs: timeStats.userMs ?? null, sysMs: timeStats.sysMs ?? null, maxRssKb: timeStats.maxRssKb ?? null, + bundleSize, pluginReports, rspackProfiles, rspackTraceOutput: diff --git a/scripts/benchmark/bundle-size.mjs b/scripts/benchmark/bundle-size.mjs new file mode 100644 index 00000000..0381bc19 --- /dev/null +++ b/scripts/benchmark/bundle-size.mjs @@ -0,0 +1,108 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { gzip as gzipCallback } from 'node:zlib'; + +const gzip = promisify(gzipCallback); + +export const bundleSizeMetricKeys = [ + 'fileCount', + 'totalBytes', + 'totalGzipBytes', + 'clientBytes', + 'clientGzipBytes', + 'clientJsBytes', + 'clientJsGzipBytes', + 'clientCssBytes', + 'clientCssGzipBytes', + 'serverBytes', + 'serverGzipBytes', + 'sourceMapBytes', + 'sourceMapGzipBytes', +]; + +export const createEmptyBuildOutputStats = () => + Object.fromEntries(bundleSizeMetricKeys.map(key => [key, 0])); + +export const formatBytes = value => { + if (value == null) { + return '-'; + } + if (value >= 1024 * 1024) { + return `${(value / 1024 / 1024).toFixed(1)} MB`; + } + return `${(value / 1024).toFixed(1)} kB`; +}; + +export const getBundleSizeMedian = (benchmark, key) => + benchmark?.summary?.bundleSize?.[key]?.median ?? null; + +export const summarizeBundleSizeRuns = (runs, summarizeMetric) => + Object.fromEntries( + bundleSizeMetricKeys.map(key => [ + key, + summarizeMetric(runs.map(run => run.bundleSize?.[key])), + ]) + ); + +const addFileStats = (stats, relativePath, bytes, gzipBytes) => { + stats.fileCount += 1; + stats.totalBytes += bytes; + stats.totalGzipBytes += gzipBytes; + + const normalizedPath = relativePath.split(path.sep).join('/'); + const isSourceMap = normalizedPath.endsWith('.map'); + + if (isSourceMap) { + stats.sourceMapBytes += bytes; + stats.sourceMapGzipBytes += gzipBytes; + } + + if (normalizedPath.startsWith('client/')) { + stats.clientBytes += bytes; + stats.clientGzipBytes += gzipBytes; + if (normalizedPath.endsWith('.js')) { + stats.clientJsBytes += bytes; + stats.clientJsGzipBytes += gzipBytes; + } else if (normalizedPath.endsWith('.css')) { + stats.clientCssBytes += bytes; + stats.clientCssGzipBytes += gzipBytes; + } + } else if (normalizedPath.startsWith('server/')) { + stats.serverBytes += bytes; + stats.serverGzipBytes += gzipBytes; + } +}; + +export const collectBuildOutputStats = async buildRoot => { + const stats = createEmptyBuildOutputStats(); + + const visit = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + + await Promise.all( + entries.map(async entry => { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(filePath); + return; + } + if (!entry.isFile()) { + return; + } + + const buffer = await readFile(filePath); + const gzipped = await gzip(buffer); + addFileStats( + stats, + path.relative(buildRoot, filePath), + buffer.byteLength, + gzipped.byteLength + ); + }) + ); + }; + + await visit(buildRoot); + return stats; +}; diff --git a/scripts/benchmark/ci-report-markdown.mjs b/scripts/benchmark/ci-report-markdown.mjs index 881f43bd..fcd1535e 100644 --- a/scripts/benchmark/ci-report-markdown.mjs +++ b/scripts/benchmark/ci-report-markdown.mjs @@ -1,3 +1,5 @@ +import { formatBytes } from './bundle-size.mjs'; + const formatSeconds = value => typeof value === 'number' ? `${(value / 1000).toFixed(2)}s` : '-'; const formatPercent = value => @@ -26,8 +28,8 @@ const formatSignal = value => })[value] ?? '⚠️ insufficient data'; const productionBenchmarkTableHeader = [ - '| Benchmark | Runs | Base total | Head total | Delta | Head mean | Head p95 | Speedup | Head RSS p95 |', - '|---|---:|---:|---:|---:|---:|---:|---:|---:|', + '| Benchmark | Runs | Base total | Head total | Delta | Head mean | Head p95 | Speedup | Head RSS p95 | Head client JS gzip | Client JS gzip delta | Head total gzip |', + '|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|', ]; const devBenchmarkTableHeader = [ @@ -92,13 +94,13 @@ const appendSignalSummary = (lines, report) => { }; const renderBuildBenchmarkRow = benchmark => - `| \`${benchmark.id}\` | ${formatCount(benchmarkRunCount(benchmark))} | ${formatSeconds(benchmark.baseWallMs)} | ${formatSeconds(benchmark.headWallMs)} | ${formatPercent(benchmark.wallDeltaPercent)} | ${formatSeconds(benchmark.headWallMeanMs)} | ${formatSeconds(benchmark.headWallP95Ms)} | ${formatSpeedup(benchmark.wallSpeedup)} | ${formatRss(benchmark.headRssKb)} |`; + `| \`${benchmark.id}\` | ${formatCount(benchmarkRunCount(benchmark))} | ${formatSeconds(benchmark.baseWallMs)} | ${formatSeconds(benchmark.headWallMs)} | ${formatPercent(benchmark.wallDeltaPercent)} | ${formatSeconds(benchmark.headWallMeanMs)} | ${formatSeconds(benchmark.headWallP95Ms)} | ${formatSpeedup(benchmark.wallSpeedup)} | ${formatRss(benchmark.headRssKb)} | ${formatBytes(benchmark.headClientJsGzipBytes)} | ${formatPercent(benchmark.clientJsGzipDeltaPercent)} | ${formatBytes(benchmark.headTotalGzipBytes)} |`; const renderDevBenchmarkRow = benchmark => `| \`${benchmark.id}\` | ${formatCount(benchmarkRunCount(benchmark))} | ${formatSeconds(benchmark.baseWallMs)} | ${formatSeconds(benchmark.headWallMs)} | ${formatPercent(benchmark.wallDeltaPercent)} | ${formatSeconds(benchmark.baseReadyMs)} | ${formatSeconds(benchmark.headReadyMs)} | ${formatSeconds(benchmark.baseRouteTotalMs)} | ${formatSeconds(benchmark.headRouteTotalMs)} | ${formatSeconds(benchmark.baseUpdateMs)} | ${formatSeconds(benchmark.headUpdateMs)} | ${formatPercent(benchmark.updateDeltaPercent)} | ${formatSeconds(benchmark.headWallMeanMs)} | ${formatSeconds(benchmark.headWallP95Ms)} | ${formatSpeedup(benchmark.wallSpeedup)} | ${formatRss(benchmark.headRssKb)} |`; const renderSyntheticBuildBenchmarkRow = benchmark => - `| complex app | ${formatCount(benchmark.runs)} | ${formatDurationSeconds(benchmark.baseMedianSeconds)} | ${formatDurationSeconds(benchmark.headMedianSeconds)} | ${formatPercent(benchmark.deltaPercent)} | ${formatDurationSeconds(benchmark.headMeanSeconds)} | ${formatDurationSeconds(benchmark.headP95Seconds)} | ${formatSpeedup(benchmark.speedup)} | - |`; + `| complex app | ${formatCount(benchmark.runs)} | ${formatDurationSeconds(benchmark.baseMedianSeconds)} | ${formatDurationSeconds(benchmark.headMedianSeconds)} | ${formatPercent(benchmark.deltaPercent)} | ${formatDurationSeconds(benchmark.headMeanSeconds)} | ${formatDurationSeconds(benchmark.headP95Seconds)} | ${formatSpeedup(benchmark.speedup)} | - | - | - | - |`; const renderSyntheticDevBenchmarkRow = benchmark => `| complex app | ${formatCount(benchmark.runs)} | ${formatDurationSeconds(benchmark.baseMedianSeconds)} | ${formatDurationSeconds(benchmark.headMedianSeconds)} | ${formatPercent(benchmark.deltaPercent)} | ${formatSeconds(benchmark.baseReadyMs)} | ${formatSeconds(benchmark.headReadyMs)} | ${formatSeconds(benchmark.baseRouteTotalMs)} | ${formatSeconds(benchmark.headRouteTotalMs)} | ${formatSeconds(benchmark.baseUpdateMs)} | ${formatSeconds(benchmark.headUpdateMs)} | ${formatPercent(benchmark.updateDeltaPercent)} | ${formatDurationSeconds(benchmark.headMeanSeconds)} | ${formatDurationSeconds(benchmark.headP95Seconds)} | ${formatSpeedup(benchmark.speedup)} | - |`; diff --git a/scripts/benchmark/ci-report-model.mjs b/scripts/benchmark/ci-report-model.mjs index fcae3f81..ddfb6977 100644 --- a/scripts/benchmark/ci-report-model.mjs +++ b/scripts/benchmark/ci-report-model.mjs @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { getBundleSizeMedian } from './bundle-size.mjs'; import { classifyBenchmarkSignal } from './statistics.mjs'; export const readJson = async file => JSON.parse(await readFile(file, 'utf8')); @@ -145,6 +146,22 @@ const compareBenchmarks = (baseResult, headResult) => { const headRouteTotalMs = medianRouteTotal(headBenchmark); const baseUpdateMs = medianUpdate(baseBenchmark); const headUpdateMs = medianUpdate(headBenchmark); + const baseClientJsGzipBytes = getBundleSizeMedian( + baseBenchmark, + 'clientJsGzipBytes' + ); + const headClientJsGzipBytes = getBundleSizeMedian( + headBenchmark, + 'clientJsGzipBytes' + ); + const baseTotalGzipBytes = getBundleSizeMedian( + baseBenchmark, + 'totalGzipBytes' + ); + const headTotalGzipBytes = getBundleSizeMedian( + headBenchmark, + 'totalGzipBytes' + ); const stability = classifyBenchmarkSignal( (baseBenchmark?.runs ?? []).map(run => run.wallMs), (headBenchmark?.runs ?? []).map(run => run.wallMs) @@ -171,6 +188,14 @@ const compareBenchmarks = (baseResult, headResult) => { headCpuMs: cpuMedian(headBenchmark), baseRssKb: p95Rss(baseBenchmark), headRssKb: p95Rss(headBenchmark), + baseClientJsGzipBytes, + headClientJsGzipBytes, + clientJsGzipDeltaPercent: percentDelta( + baseClientJsGzipBytes, + headClientJsGzipBytes + ), + baseTotalGzipBytes, + headTotalGzipBytes, baseRunCount: baseBenchmark?.runs?.length ?? null, headRunCount: headBenchmark?.runs?.length ?? null, headWallMeanMs: headBenchmark?.summary?.wallMs?.mean ?? null, diff --git a/scripts/benchmark/results.mts b/scripts/benchmark/results.mts index 67c678be..2a9e570d 100644 --- a/scripts/benchmark/results.mts +++ b/scripts/benchmark/results.mts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { formatBytes, summarizeBundleSizeRuns } from './bundle-size.mjs'; export const parseTimeStats = stderr => { const user = stderr.match(/User time \(seconds\):\s*([\d.]+)/); @@ -64,6 +65,7 @@ export const summarizeRuns = runs => ({ userMs: summarizeMetric(runs.map(run => run.userMs)), sysMs: summarizeMetric(runs.map(run => run.sysMs)), maxRssKb: summarizeMetric(runs.map(run => run.maxRssKb)), + bundleSize: summarizeBundleSizeRuns(runs, summarizeMetric), }); const summarizeDevRequests = (runs, key) => { @@ -237,8 +239,8 @@ export const renderMarkdown = result => { ? [`- Rspack trace output: ${result.rspackTraceOutput}`] : []), '', - '| Benchmark | Routes | Variant | Median ready | Median route load | Median update/HMR | Median wall | Mean wall | p95 wall | Max RSS | Plugin reports (--log-performance) |', - '|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|', + '| Benchmark | Routes | Variant | Median ready | Median route load | Median update/HMR | Median wall | Mean wall | p95 wall | Max RSS | Client JS gzip | Total gzip | Plugin reports (--log-performance) |', + '|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|', ]; for (const benchmark of result.benchmarks) { @@ -254,6 +256,8 @@ export const renderMarkdown = result => { formatMs(benchmark.summary.wallMs.mean), formatMs(benchmark.summary.wallMs.p95), formatRss(benchmark.summary.maxRssKb.p95), + formatBytes(benchmark.summary.bundleSize.clientJsGzipBytes.median), + formatBytes(benchmark.summary.bundleSize.totalGzipBytes.median), benchmark.runs.reduce((sum, run) => sum + run.pluginReports.length, 0), ] .join(' | ') diff --git a/scripts/compare-benchmarks.mts b/scripts/compare-benchmarks.mts index 786f1568..616ea165 100644 --- a/scripts/compare-benchmarks.mts +++ b/scripts/compare-benchmarks.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseArgs } from 'node:util'; import { readJson } from './benchmark/ci-report-model.mjs'; +import { formatBytes } from './benchmark/bundle-size.mjs'; const parseInput = () => { const { values } = parseArgs({ @@ -113,6 +114,18 @@ const main = async () => { after: metric(afterBenchmark, 'summary.maxRssKb.p95'), format: formatKb, }, + { + label: 'Client JS gzip median', + before: metric(beforeBenchmark, 'summary.bundleSize.clientJsGzipBytes.median'), + after: metric(afterBenchmark, 'summary.bundleSize.clientJsGzipBytes.median'), + format: formatBytes, + }, + { + label: 'Total output gzip median', + before: metric(beforeBenchmark, 'summary.bundleSize.totalGzipBytes.median'), + after: metric(afterBenchmark, 'summary.bundleSize.totalGzipBytes.median'), + format: formatBytes, + }, ]; for (const operation of operations) { diff --git a/src/index.ts b/src/index.ts index 7144c910..2100e533 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,10 @@ import { createReactRouterDevRuntimeController } from './dev-runtime-controller. import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; import { registerReactRouterTypegen } from './typegen.js'; import { importConfigWithWatchPaths } from './config-imports.js'; +import { + createWebOptimizationConfig, + createWebOutputConfig, +} from './web-rspack-config.js'; import { createReactRouterRouteTopology, createReactRouterRouteWatchFiles, @@ -875,18 +879,8 @@ export const pluginReactRouter = ( } : {}), externalsType: 'module', - output: { - chunkFormat: 'module', - chunkLoading: 'import', - workerChunkLoading: 'import', - wasmLoading: 'fetch', - library: { type: 'module' }, - module: true, - }, - optimization: { - avoidEntryIife: true, - runtimeChunk: 'single', - }, + output: createWebOutputConfig(isBuild), + optimization: createWebOptimizationConfig(isBuild), }, }, }, diff --git a/src/web-rspack-config.ts b/src/web-rspack-config.ts new file mode 100644 index 00000000..0f8d31e3 --- /dev/null +++ b/src/web-rspack-config.ts @@ -0,0 +1,30 @@ +import type { Rspack } from '@rsbuild/core'; + +export const createWebOutputConfig = ( + isBuild: boolean +): NonNullable => ({ + chunkFormat: 'module', + chunkLoading: 'import', + ...(isBuild + ? { + chunkFilename: 'static/js/async/[id]-[contenthash:16].js', + } + : {}), + workerChunkLoading: 'import', + wasmLoading: 'fetch', + library: { type: 'module' }, + module: true, +}); + +export const createWebOptimizationConfig = ( + isBuild: boolean +): NonNullable => ({ + avoidEntryIife: true, + ...(isBuild + ? { + mangleExports: 'size', + usedExports: 'global', + } + : {}), + runtimeChunk: 'single', +}); diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts index 2b0092db..439ba17b 100644 --- a/tests/benchmark-fixture.test.ts +++ b/tests/benchmark-fixture.test.ts @@ -360,6 +360,49 @@ describe('benchmark fixture generator', () => { } }); + it('collects build output bundle size metrics', async () => { + const { collectBuildOutputStats } = await import( + '../scripts/benchmark/bundle-size.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-size-')); + + try { + mkdirSync(join(root, 'client/assets'), { recursive: true }); + mkdirSync(join(root, 'server'), { recursive: true }); + writeFileSync(join(root, 'client/assets/entry.js'), 'console.log("entry");'); + writeFileSync(join(root, 'client/assets/route.js'), 'export default 1;'); + writeFileSync(join(root, 'client/assets/styles.css'), '.route{color:red}'); + writeFileSync(join(root, 'client/assets/entry.js.map'), '{}'); + writeFileSync(join(root, 'server/index.js'), 'export default {};'); + + const stats = await collectBuildOutputStats(root); + + expect(stats.fileCount).toBe(5); + expect(stats.sourceMapBytes).toBe(2); + expect(stats.totalBytes).toBe(75); + expect(stats.clientBytes).toBe(57); + expect(stats.clientJsBytes).toBe(38); + expect(stats.clientCssBytes).toBe(17); + expect(stats.serverBytes).toBe(18); + expect(stats.totalGzipBytes).toBeGreaterThan(0); + expect(stats.clientJsGzipBytes).toBeGreaterThan(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects a missing build output root', async () => { + const { collectBuildOutputStats } = await import( + '../scripts/benchmark/bundle-size.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-missing-')); + rmSync(root, { recursive: true }); + + await expect(collectBuildOutputStats(root)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + it('accepts equals-form CLI options before benchmark selection', () => { const result = spawnSync( process.execPath, @@ -638,6 +681,12 @@ describe('benchmark fixture generator', () => { userMs: { median: 700 }, sysMs: { median: 100 }, maxRssKb: benchmark.summary.maxRssKb, + bundleSize: { + totalBytes: { median: 180000 }, + totalGzipBytes: { median: 60000 }, + clientJsBytes: { median: 120000 }, + clientJsGzipBytes: { median: 40000 }, + }, }, devRouteSummary: [], devUpdateRouteSummary: [], @@ -653,6 +702,12 @@ describe('benchmark fixture generator', () => { userMs: { median: 650 }, sysMs: { median: 90 }, maxRssKb: benchmark.summary.maxRssKb, + bundleSize: { + totalBytes: { median: 150000 }, + totalGzipBytes: { median: 50000 }, + clientJsBytes: { median: 90000 }, + clientJsGzipBytes: { median: 30000 }, + }, }, devRouteSummary: [], devUpdateRouteSummary: [], @@ -753,6 +808,9 @@ describe('benchmark fixture generator', () => { ); expect(comment).toContain('### Production Build Benchmarks'); expect(comment).toContain('Rendered 2 production build benchmarks.'); + expect(comment).toContain('Head client JS gzip'); + expect(comment).toContain('Head total gzip'); + expect(comment).toContain('| `large-355-ssr-esm` | 1 | 1.00s | 0.90s | -10.0% | 0.92s | 1.00s | 1.11x | 488 MB | 29.3 kB | -25.0% | 48.8 kB |'); expect(comment).toContain('### Dev Rollup'); expect(comment).toContain( '| All dev fixtures | 2 | 1.80s | 1.66s | -7.8% | 1.20s | 1.11s | -7.5% | 0.55s | 0.48s | -12.7% | 0.38s | 0.34s | -10.5% | 1.08x |' diff --git a/tests/index.test.ts b/tests/index.test.ts index 617e1683..5a3496fa 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -328,6 +328,34 @@ describe('pluginReactRouter', () => { expect( config.environments?.web?.tools?.rspack?.optimization?.avoidEntryIife ).toBe(true); + expect( + config.environments?.web?.tools?.rspack?.optimization?.mangleExports + ).toBeUndefined(); + expect( + config.environments?.web?.tools?.rspack?.optimization?.usedExports + ).toBeUndefined(); + }); + + it('configures production web builds to reduce browser output size', async () => { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect( + config.environments?.web?.tools?.rspack?.optimization + ).toMatchObject({ + avoidEntryIife: true, + mangleExports: 'size', + runtimeChunk: 'single', + usedExports: 'global', + }); + expect( + config.environments?.web?.tools?.rspack?.output?.chunkFilename + ).toBe('static/js/async/[id]-[contenthash:16].js'); }); it('reduces file size reporting overhead for medium split route builds by default', async () => {