Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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