diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 1ac80b5870..2f3f51ff77 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -8,7 +8,7 @@ This workspace contains deterministic bundle-size fixtures for: - `@tanstack/react-start` - `@tanstack/solid-start` -Each package has two scenarios: +Each package has `minimal` and `full` scenarios: - `minimal`: Small route app with `__root` + index route that renders `hello world` - `full`: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface @@ -18,7 +18,8 @@ Each package has two scenarios: - Scenarios use file-based routing as the default app style. - Router scenarios use `@tanstack/router-plugin/vite` with `autoCodeSplitting: true`. -- Start scenarios use `@tanstack/-start/plugin/vite` with router code-splitting enabled. +- Start Vite scenarios use `@tanstack/-start/plugin/vite` with router code-splitting enabled. +- React Start also includes Rsbuild scenarios using `@tanstack/react-start/plugin/rsbuild`. - Full-surface coverage is manually maintained (no strict export-coverage gate). - Metrics are measured from initial-load JS graph only and reported as raw/gzip/brotli bytes. - Gzip is the primary tracking signal for PR deltas and historical charting. diff --git a/benchmarks/bundle-size/package.json b/benchmarks/bundle-size/package.json index 67d145b1d3..9a4b70b858 100644 --- a/benchmarks/bundle-size/package.json +++ b/benchmarks/bundle-size/package.json @@ -17,6 +17,8 @@ "vue": "^3.5.16" }, "devDependencies": { + "@rsbuild/core": "^2.0.1", + "@rsbuild/plugin-react": "^2.0.0", "@tanstack/router-plugin": "workspace:^", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", diff --git a/benchmarks/bundle-size/scenarios/react-start-full/rsbuild.config.ts b/benchmarks/bundle-size/scenarios/react-start-full/rsbuild.config.ts new file mode 100644 index 0000000000..641de56234 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-full/rsbuild.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +const outDir = process.env.BUNDLE_SIZE_DIST_DIR ?? 'dist-rsbuild' + +export default defineConfig({ + logLevel: 'silent', + plugins: [pluginReact({ splitChunks: false }), tanstackStart()], + output: { + distPath: { + root: outDir, + }, + cleanDistPath: true, + minify: true, + sourceMap: false, + }, + performance: { + printFileSize: false, + }, + environments: { + client: { + output: { + manifest: { + filename: 'manifest.json', + prefix: false, + }, + }, + }, + ssr: { + output: { + manifest: false, + }, + }, + }, +}) diff --git a/benchmarks/bundle-size/scenarios/react-start-minimal/rsbuild.config.ts b/benchmarks/bundle-size/scenarios/react-start-minimal/rsbuild.config.ts new file mode 100644 index 0000000000..641de56234 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-minimal/rsbuild.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +const outDir = process.env.BUNDLE_SIZE_DIST_DIR ?? 'dist-rsbuild' + +export default defineConfig({ + logLevel: 'silent', + plugins: [pluginReact({ splitChunks: false }), tanstackStart()], + output: { + distPath: { + root: outDir, + }, + cleanDistPath: true, + minify: true, + sourceMap: false, + }, + performance: { + printFileSize: false, + }, + environments: { + client: { + output: { + manifest: { + filename: 'manifest.json', + prefix: false, + }, + }, + }, + ssr: { + output: { + manifest: false, + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f59d00e82..8462425c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,12 @@ importers: specifier: ^3.5.16 version: 3.5.25(typescript@6.0.2) devDependencies: + '@rsbuild/core': + specifier: ^2.0.1 + version: 2.0.1(core-js@3.40.0) + '@rsbuild/plugin-react': + specifier: ^2.0.0 + version: 2.0.0(@rsbuild/core@2.0.1(core-js@3.40.0))(@rspack/core@2.0.0(@swc/helpers@0.5.21)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../packages/router-plugin diff --git a/scripts/benchmarks/bundle-size/measure.mjs b/scripts/benchmarks/bundle-size/measure.mjs index 2af3412d24..c5e37483e0 100644 --- a/scripts/benchmarks/bundle-size/measure.mjs +++ b/scripts/benchmarks/bundle-size/measure.mjs @@ -2,8 +2,9 @@ import fs from 'node:fs' import { promises as fsp } from 'node:fs' +import { createRequire } from 'node:module' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { parseArgs as parseNodeArgs } from 'node:util' import { brotliCompressSync, gzipSync } from 'node:zlib' import { execSync } from 'node:child_process' @@ -69,6 +70,24 @@ const SCENARIOS = [ packageName: '@tanstack/react-start', case: 'full', }, + { + id: 'react-start.rsbuild.minimal', + dir: 'react-start-minimal', + outDir: 'react-start-rsbuild-minimal', + toolchain: 'rsbuild', + framework: 'react', + packageName: '@tanstack/react-start', + case: 'minimal', + }, + { + id: 'react-start.rsbuild.full', + dir: 'react-start-full', + outDir: 'react-start-rsbuild-full', + toolchain: 'rsbuild', + framework: 'react', + packageName: '@tanstack/react-start', + case: 'full', + }, { id: 'solid-start.minimal', dir: 'solid-start-minimal', @@ -329,6 +348,153 @@ async function resolveManifestAndEntry(outDir, scenarioId) { ) } +async function importFromRoot(root, specifier) { + const requireFromRoot = createRequire( + path.join(root, 'bundle-size.config.cjs'), + ) + return import(pathToFileURL(requireFromRoot.resolve(specifier)).href) +} + +async function buildViteScenario({ root, outDir }) { + const configFile = path.join(root, 'vite.config.ts') + + await build({ + root, + configFile, + logLevel: 'silent', + define: { + 'process.env.NODE_ENV': '"production"', + }, + build: { + outDir, + emptyOutDir: true, + target: 'es2022', + minify: 'esbuild', + sourcemap: false, + reportCompressedSize: false, + manifest: true, + }, + }) +} + +async function buildRsbuildScenario({ root, outDir }) { + const configFile = path.join(root, 'rsbuild.config.ts') + const { createRsbuild, loadConfig } = await importFromRoot( + root, + '@rsbuild/core', + ) + const previousOutDir = process.env.BUNDLE_SIZE_DIST_DIR + + process.env.BUNDLE_SIZE_DIST_DIR = outDir + + try { + const { content } = await loadConfig({ + cwd: root, + path: configFile, + envMode: 'production', + }) + const rsbuild = await createRsbuild({ + cwd: root, + callerName: 'bundle-size-benchmark', + config: content, + }) + const result = await rsbuild.build() + await result.close() + } finally { + if (previousOutDir === undefined) { + delete process.env.BUNDLE_SIZE_DIST_DIR + } else { + process.env.BUNDLE_SIZE_DIST_DIR = previousOutDir + } + } +} + +async function buildScenario({ root, outDir, scenario }) { + const previousCwd = process.cwd() + process.chdir(root) + + try { + if (scenario.toolchain === 'rsbuild') { + await buildRsbuildScenario({ root, outDir }) + return + } + + await buildViteScenario({ root, outDir }) + } finally { + process.chdir(previousCwd) + } +} + +async function resolveRsbuildManifest(outDir, scenarioId) { + const manifestPath = path.join(outDir, 'client', 'manifest.json') + + if (!fs.existsSync(manifestPath)) { + throw new Error( + `No Rsbuild manifest file found for scenario: ${scenarioId}`, + ) + } + + const manifest = readJson(manifestPath) + const entryName = manifest.entries?.index + ? 'index' + : Object.keys(manifest.entries || {})[0] + + if (!entryName) { + throw new Error( + `Could not determine Rsbuild manifest entry for scenario: ${scenarioId}`, + ) + } + + return { + manifest, + entryKey: entryName, + manifestPath, + manifestOutDir: path.dirname(manifestPath), + } +} + +function collectRsbuildInitialJsFiles(manifest, entryKey) { + const files = manifest.entries?.[entryKey]?.initial?.js + + if (!Array.isArray(files)) { + return [] + } + + return files + .filter( + (file) => + typeof file === 'string' && + (file.endsWith('.js') || file.endsWith('.mjs')), + ) + .sort() +} + +async function resolveBundleFiles({ outDir, scenario }) { + if (scenario.toolchain === 'rsbuild') { + const manifestInfo = await resolveRsbuildManifest(outDir, scenario.id) + const jsFiles = collectRsbuildInitialJsFiles( + manifestInfo.manifest, + manifestInfo.entryKey, + ) + + return { + ...manifestInfo, + jsFiles, + } + } + + const manifestInfo = await resolveManifestAndEntry(outDir, scenario.id) + const jsFiles = collectInitialJsFiles( + manifestInfo.manifest, + manifestInfo.entryKey, + ) + + return { + ...manifestInfo, + jsFiles, + } +} + function getCurrentSha(providedSha) { if (providedSha) { return providedSha @@ -417,51 +583,23 @@ async function main() { for (const scenario of SCENARIOS) { const root = path.join(scenariosRoot, scenario.dir) - const outDir = path.join(distDir, scenario.dir) - const configFile = path.join(root, 'vite.config.ts') - - const previousCwd = process.cwd() - process.chdir(root) - - try { - await build({ - root, - configFile, - logLevel: 'silent', - define: { - 'process.env.NODE_ENV': '"production"', - }, - build: { - outDir, - emptyOutDir: true, - target: 'es2022', - minify: 'esbuild', - sourcemap: false, - reportCompressedSize: false, - manifest: true, - }, - }) - } finally { - process.chdir(previousCwd) - } + const outDir = path.join(distDir, scenario.outDir || scenario.dir) - const manifestInfo = await resolveManifestAndEntry(outDir, scenario.id) + await buildScenario({ root, outDir, scenario }) - const jsFiles = collectInitialJsFiles( - manifestInfo.manifest, - manifestInfo.entryKey, - ) - const sizes = bytesForFiles(manifestInfo.manifestOutDir, jsFiles) + const bundleInfo = await resolveBundleFiles({ outDir, scenario }) + const sizes = bytesForFiles(bundleInfo.manifestOutDir, bundleInfo.jsFiles) metrics.push({ id: scenario.id, scenarioDir: scenario.dir, + toolchain: scenario.toolchain || 'vite', framework: scenario.framework, packageName: scenario.packageName, case: scenario.case, - entryKey: manifestInfo.entryKey, - manifestPath: path.relative(outDir, manifestInfo.manifestPath), - jsFiles, + entryKey: bundleInfo.entryKey, + manifestPath: path.relative(outDir, bundleInfo.manifestPath), + jsFiles: bundleInfo.jsFiles, ...sizes, }) }