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
5 changes: 3 additions & 2 deletions benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<framework>-start/plugin/vite` with router code-splitting enabled.
- Start Vite scenarios use `@tanstack/<framework>-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.
Expand Down
2 changes: 2 additions & 0 deletions benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
})
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
})
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

212 changes: 175 additions & 37 deletions scripts/benchmarks/bundle-size/measure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
Expand Down
Loading