From 409f192cc3f9007d213b48566b8ad7e0766c5be3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:27:26 +0200 Subject: [PATCH 01/30] Add React Router plugin performance benchmarks --- .gitignore | 1 + benchmarks/README.md | 59 +++ package.json | 4 + scripts/bench-builds.mjs | 441 ++++++++++++++++ scripts/benchmark/fixture.mjs | 192 +++++++ src/export-utils.ts | 78 ++- src/index.ts | 874 +++++++++++++++++--------------- src/manifest.ts | 225 ++++---- src/performance.ts | 140 +++++ src/types.ts | 7 + tests/benchmark-fixture.test.ts | 99 ++++ tests/performance.test.ts | 76 +++ 12 files changed, 1689 insertions(+), 507 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 scripts/bench-builds.mjs create mode 100644 scripts/benchmark/fixture.mjs create mode 100644 src/performance.ts create mode 100644 tests/benchmark-fixture.test.ts create mode 100644 tests/performance.test.ts diff --git a/.gitignore b/.gitignore index 59214f8..18b9153 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build .unpack-cache/ .codex/ .tracedecay/ +.benchmark/ task/upstream/ task/output/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..aeca8c6 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,59 @@ +# Rsbuild React Router Performance Baselines + +This directory documents the repeatable benchmark workflow for +`rsbuild-plugin-react-router`. Benchmark artifacts are written to `.benchmark/`, +which is intentionally ignored. + +## Commands + +```sh +pnpm bench:smoke +pnpm bench:baseline +pnpm bench:full +``` + +`bench:smoke` is a one-iteration sanity check. `bench:baseline` is the default +comparison point for plugin performance work. `bench:full` adds larger route +counts for stress testing. + +All benchmark profiles generate deterministic synthetic React Router apps under +`.benchmark/fixtures/`, build the current plugin package once, then run Rsbuild +builds with `pluginReactRouter({ logPerformance: true })`. + +## Baseline Shape + +The synthetic fixture keeps app behavior simple and scales route count/export +shape deliberately: + +- `ssr-esm`: production SSR build with ESM server output. +- `ssr-esm-split`: same route set with `future.v8_splitRouteModules`. +- `spa`: `ssr: false` route transform path. +- `sourcemaps`: production client sourcemaps enabled. + +Routes include plain components, server data exports, client data exports, +split-route candidates, and `.client` / `.server` imports. This targets the +plugin paths that are expensive in large apps: per-route client entries, +`?react-router-route` transforms, client-only stubbing, split-route detection, +and manifest emission. + +## Outputs + +Each run writes: + +- `.benchmark/results//baseline.json` +- `.benchmark/results//baseline.md` + +The JSON includes wall time, optional GNU `/usr/bin/time -v` user/sys/RSS data, +and parsed `[react-router:performance]` reports from the plugin. + +## Hygiene + +Start and end with: + +```sh +git status --short +``` + +Benchmark output should stay inside ignored `.benchmark/`. If you need to clean +generated benchmark data, remove `.benchmark/` directly rather than using a broad +`git clean -fdX`, which can also delete `node_modules/` and TraceDecay indexes. diff --git a/package.json b/package.json index 5e504c8..9720181 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,10 @@ }, "scripts": { "build": "rslib build", + "bench": "node scripts/bench-builds.mjs", + "bench:smoke": "node scripts/bench-builds.mjs --profile smoke --iterations 1 --warmup 0 --format both --out .benchmark/results/smoke", + "bench:baseline": "node scripts/bench-builds.mjs --profile default --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/baseline", + "bench:full": "node scripts/bench-builds.mjs --profile full --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/full", "e2e": "pnpm build && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only}' test:e2e", "dev": "rslib build --watch", "test": "rstest run", diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs new file mode 100644 index 0000000..6ddd932 --- /dev/null +++ b/scripts/bench-builds.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import { generateSyntheticFixture } from './benchmark/fixture.mjs'; + +const rootDir = process.cwd(); +const benchmarkRoot = path.join(rootDir, '.benchmark'); +const rsbuildBin = path.join( + rootDir, + 'node_modules', + '@rsbuild', + 'core', + 'bin', + 'rsbuild.js' +); + +const profiles = { + smoke: [{ id: 'synthetic-48-ssr-esm', routeCount: 48, variant: 'ssr-esm' }], + default: [ + { id: 'synthetic-256-ssr-esm', routeCount: 256, variant: 'ssr-esm' }, + { + id: 'synthetic-256-ssr-esm-split', + routeCount: 256, + variant: 'ssr-esm-split', + }, + { id: 'synthetic-256-spa', routeCount: 256, variant: 'spa' }, + { + id: 'synthetic-256-sourcemaps', + routeCount: 256, + variant: 'ssr-esm', + sourceMap: true, + }, + ], + full: [ + { id: 'synthetic-48-ssr-esm', routeCount: 48, variant: 'ssr-esm' }, + { id: 'synthetic-256-ssr-esm', routeCount: 256, variant: 'ssr-esm' }, + { id: 'synthetic-1024-ssr-esm', routeCount: 1024, variant: 'ssr-esm' }, + { + id: 'synthetic-256-ssr-esm-split', + routeCount: 256, + variant: 'ssr-esm-split', + }, + { + id: 'synthetic-1024-ssr-esm-split', + routeCount: 1024, + variant: 'ssr-esm-split', + }, + { + id: 'synthetic-256-sourcemaps', + routeCount: 256, + variant: 'ssr-esm', + sourceMap: true, + }, + ], +}; + +const parseArgs = argv => { + const args = { + profile: 'default', + iterations: 5, + warmup: 1, + format: 'both', + out: path.join('.benchmark', 'results', 'baseline'), + clean: 'build', + filter: null, + failFast: false, + skipRootBuild: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const [arg, inlineValue] = argv[index].split('=', 2); + const next = () => inlineValue ?? argv[++index]; + if (arg === '--profile') args.profile = next(); + else if (arg === '--iterations') args.iterations = Number(next()); + else if (arg === '--warmup') args.warmup = Number(next()); + else if (arg === '--format') args.format = next(); + else if (arg === '--out') args.out = next(); + else if (arg === '--clean') args.clean = next(); + else if (arg === '--filter') args.filter = next(); + else if (arg === '--fail-fast') args.failFast = true; + else if (arg === '--skip-root-build') args.skipRootBuild = true; + else { + throw new Error(`Unknown benchmark argument: ${argv[index]}`); + } + } + + if (!profiles[args.profile]) { + throw new Error( + `Unknown profile "${args.profile}". Use smoke, default, or full.` + ); + } + if (!Number.isInteger(args.iterations) || args.iterations < 1) { + throw new Error('--iterations must be a positive integer.'); + } + if (!Number.isInteger(args.warmup) || args.warmup < 0) { + throw new Error('--warmup must be a non-negative integer.'); + } + if (!['json', 'md', 'markdown', 'both'].includes(args.format)) { + throw new Error('--format must be json, md, markdown, or both.'); + } + if (!['none', 'build', 'cold'].includes(args.clean)) { + throw new Error('--clean must be none, build, or cold.'); + } + + return args; +}; + +const hasGnuTime = async () => { + try { + await access('/usr/bin/time'); + return true; + } catch { + return false; + } +}; + +const runCommand = async ({ + command, + args, + cwd, + env = {}, + useTime = false, +}) => { + const startedAt = performance.now(); + const childCommand = useTime ? '/usr/bin/time' : command; + const childArgs = useTime ? ['-v', command, ...args] : args; + + return new Promise(resolve => { + const child = spawn(childCommand, childArgs, { + cwd, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += chunk; + process.stdout.write(chunk); + }); + child.stderr.on('data', chunk => { + stderr += chunk; + process.stderr.write(chunk); + }); + child.on('close', status => { + resolve({ + status, + stdout, + stderr, + wallMs: performance.now() - startedAt, + }); + }); + }); +}; + +const parseTimeStats = stderr => { + const user = stderr.match(/User time \(seconds\):\s*([\d.]+)/); + const sys = stderr.match(/System time \(seconds\):\s*([\d.]+)/); + const rss = stderr.match(/Maximum resident set size \(kbytes\):\s*(\d+)/); + return { + userMs: user ? Number(user[1]) * 1000 : null, + sysMs: sys ? Number(sys[1]) * 1000 : null, + maxRssKb: rss ? Number(rss[1]) : null, + }; +}; + +const parsePluginReports = output => { + const reports = []; + for (const line of output.split(/\r?\n/)) { + const markerIndex = line.indexOf('[react-router:performance]'); + if (markerIndex === -1) { + continue; + } + const jsonStart = line.indexOf('{', markerIndex); + if (jsonStart === -1) { + continue; + } + try { + reports.push(JSON.parse(line.slice(jsonStart))); + } catch { + // Keep raw build output useful even if one line is malformed. + } + } + return reports; +}; + +const summarizeMetric = values => { + const sorted = values + .filter(value => typeof value === 'number') + .sort((a, b) => a - b); + if (sorted.length === 0) { + return { min: null, median: null, mean: null, p95: null, stdev: null }; + } + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + const variance = + sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; + const percentileIndex = Math.min( + sorted.length - 1, + Math.ceil(sorted.length * 0.95) - 1 + ); + return { + min: sorted[0], + median: sorted[Math.floor(sorted.length / 2)], + mean, + p95: sorted[percentileIndex], + stdev: Math.sqrt(variance), + }; +}; + +const summarizeRuns = runs => ({ + wallMs: summarizeMetric(runs.map(run => run.wallMs)), + userMs: summarizeMetric(runs.map(run => run.userMs)), + sysMs: summarizeMetric(runs.map(run => run.sysMs)), + maxRssKb: summarizeMetric(runs.map(run => run.maxRssKb)), +}); + +const formatMs = value => + value == null ? '-' : `${(value / 1000).toFixed(2)}s`; +const formatRss = value => + value == null ? '-' : `${Math.round(value / 1024)} MB`; + +const renderMarkdown = result => { + const lines = [ + '# Rsbuild React Router Benchmark Baseline', + '', + `- Date: ${result.date}`, + `- Commit: ${result.commit}`, + `- Node: ${result.node}`, + `- pnpm: ${result.pnpm}`, + `- Platform: ${result.platform}`, + `- Profile: ${result.profile}`, + `- Iterations: ${result.iterations}`, + `- Warmup: ${result.warmup}`, + '', + '| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | Max RSS | Plugin reports |', + '|---|---:|---|---:|---:|---:|---:|---:|', + ]; + + for (const benchmark of result.benchmarks) { + lines.push( + [ + benchmark.id, + benchmark.routeCount, + benchmark.variant, + formatMs(benchmark.summary.wallMs.median), + formatMs(benchmark.summary.wallMs.mean), + formatMs(benchmark.summary.wallMs.p95), + formatRss(benchmark.summary.maxRssKb.p95), + benchmark.runs.reduce((sum, run) => sum + run.pluginReports.length, 0), + ] + .join(' | ') + .replace(/^/, '| ') + .replace(/$/, ' |') + ); + } + + lines.push(''); + return `${lines.join('\n')}\n`; +}; + +const writeOutputs = async (result, args) => { + const outPath = path.resolve(rootDir, args.out); + const format = args.format === 'markdown' ? 'md' : args.format; + const writeJson = format === 'json' || format === 'both'; + const writeMd = format === 'md' || format === 'both'; + + if (writeJson && writeMd) { + await mkdir(outPath, { recursive: true }); + await writeFile( + path.join(outPath, 'baseline.json'), + `${JSON.stringify(result, null, 2)}\n` + ); + await writeFile(path.join(outPath, 'baseline.md'), renderMarkdown(result)); + return; + } + + await mkdir(path.dirname(outPath), { recursive: true }); + if (writeJson) { + await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`); + } else { + await writeFile(outPath, renderMarkdown(result)); + } +}; + +const git = async args => { + const result = await runCommand({ + command: 'git', + args, + cwd: rootDir, + useTime: false, + }); + return result.status === 0 ? result.stdout.trim() : null; +}; + +const pnpmVersion = async () => { + const result = await runCommand({ + command: 'pnpm', + args: ['--version'], + cwd: rootDir, + useTime: false, + }); + return result.status === 0 ? result.stdout.trim() : null; +}; + +const cleanBuildOutputs = async fixtureRoot => { + await rm(path.join(fixtureRoot, 'build'), { recursive: true, force: true }); + await rm(path.join(fixtureRoot, '.react-router'), { + recursive: true, + force: true, + }); +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + const useTime = await hasGnuTime(); + const pluginImportPath = pathToFileURL( + path.join(rootDir, 'dist/index.js') + ).href; + const selectedBenchmarks = profiles[args.profile].filter(benchmark => + args.filter ? benchmark.id.includes(args.filter) : true + ); + + if (selectedBenchmarks.length === 0) { + throw new Error(`No benchmarks matched filter "${args.filter}".`); + } + + if (!args.skipRootBuild) { + console.log('Building plugin package before benchmarks...'); + const buildResult = await runCommand({ + command: 'pnpm', + args: ['build'], + cwd: rootDir, + }); + if (buildResult.status !== 0) { + process.exit(buildResult.status ?? 1); + } + } + + const benchmarks = []; + for (const benchmark of selectedBenchmarks) { + const fixtureRoot = path.join(benchmarkRoot, 'fixtures', benchmark.id); + await generateSyntheticFixture({ + root: fixtureRoot, + routeCount: benchmark.routeCount, + variant: benchmark.variant, + sourceMap: benchmark.sourceMap ?? false, + pluginImportPath, + }); + + const runs = []; + const totalRuns = args.warmup + args.iterations; + for (let index = 0; index < totalRuns; index += 1) { + const measured = index >= args.warmup; + if (args.clean !== 'none') { + await cleanBuildOutputs(fixtureRoot); + } + if (args.clean === 'cold') { + await rm(path.join(fixtureRoot, 'node_modules'), { + recursive: true, + force: true, + }); + } + + console.log( + `${measured ? 'Measuring' : 'Warming'} ${benchmark.id} (${index + 1}/${totalRuns})` + ); + const commandResult = await runCommand({ + command: process.execPath, + args: [rsbuildBin, 'build', '--config', 'rsbuild.config.mjs'], + cwd: fixtureRoot, + env: { + NODE_ENV: 'production', + REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE: '1', + }, + useTime, + }); + const timeStats = useTime ? parseTimeStats(commandResult.stderr) : {}; + const pluginReports = parsePluginReports( + `${commandResult.stdout}\n${commandResult.stderr}` + ); + + if (commandResult.status !== 0 && args.failFast) { + process.exit(commandResult.status ?? 1); + } + + if (measured) { + runs.push({ + status: commandResult.status, + wallMs: commandResult.wallMs, + userMs: timeStats.userMs ?? null, + sysMs: timeStats.sysMs ?? null, + maxRssKb: timeStats.maxRssKb ?? null, + pluginReports, + }); + } + } + + benchmarks.push({ + ...benchmark, + cwd: path.relative(rootDir, fixtureRoot), + command: + 'node /node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs', + runs, + summary: summarizeRuns(runs), + }); + } + + const result = { + repo: 'rsbuild-plugin-react-router', + commit: await git(['rev-parse', 'HEAD']), + date: new Date().toISOString(), + node: process.version, + pnpm: await pnpmVersion(), + platform: `${os.platform()} ${os.release()} ${os.arch()}`, + profile: args.profile, + iterations: args.iterations, + warmup: args.warmup, + benchmarks, + }; + + await writeOutputs(result, args); + console.log( + `Benchmark results written to ${path.resolve(rootDir, args.out)}` + ); + + if ( + benchmarks.some(benchmark => benchmark.runs.some(run => run.status !== 0)) + ) { + console.error('One or more measured benchmark builds failed.'); + process.exitCode = 1; + } +}; + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs new file mode 100644 index 0000000..54a84a6 --- /dev/null +++ b/scripts/benchmark/fixture.mjs @@ -0,0 +1,192 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const routeExportProfiles = [ + 'plain', + 'ssr-data', + 'split-client', + 'split-client', + 'ssr-data', + 'client-server-imports', +]; + +const padRoute = number => String(number).padStart(4, '0'); + +const routeFile = index => `routes/route-${padRoute(index)}.tsx`; + +const routeId = index => `route-${padRoute(index)}`; + +const routeComponentName = index => `Route${padRoute(index)}`; + +const createRouteModule = (index, profile) => { + const name = routeComponentName(index); + const shared = [ + `export const handle = { label: '${routeId(index)}' };`, + `export function meta() { return [{ title: '${routeId(index)}' }]; }`, + `export default function ${name}() { return null; }`, + ]; + + if (profile === 'ssr-data') { + return [ + `import { serverValue } from '../server-data.server';`, + ...shared, + `export async function loader() { return { id: '${routeId(index)}', serverValue }; }`, + `export async function action() { return { ok: true }; }`, + `export function headers() { return { 'x-route': '${routeId(index)}' }; }`, + `export function shouldRevalidate() { return false; }`, + ].join('\n'); + } + + if (profile === 'split-client') { + return [ + `import { clientValue } from '../client-data.client';`, + ...shared, + `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, + `export async function clientAction() { return { ok: true }; }`, + `export async function clientMiddleware() { return undefined; }`, + `export function HydrateFallback() { return null; }`, + ].join('\n'); + } + + if (profile === 'client-server-imports') { + return [ + `import { clientValue } from '../client-data.client';`, + `import { serverValue } from '../server-data.server';`, + ...shared, + `export async function loader() { return { id: '${routeId(index)}', serverValue }; }`, + `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, + ].join('\n'); + } + + return shared.join('\n'); +}; + +const createRoutesConfig = routeCount => { + const routes = []; + for (let index = 1; index <= routeCount; index += 1) { + const id = routeId(index); + const isIndex = index === 1; + routes.push( + [ + ' {', + ` id: '${id}',`, + ` file: '${routeFile(index)}',`, + isIndex ? ' index: true,' : ` path: '${id}',`, + ' },', + ].join('\n') + ); + } + + return [ + `import type { RouteConfigEntry } from '@react-router/dev/routes';`, + '', + 'export default [', + ...routes, + '] satisfies RouteConfigEntry[];', + '', + ].join('\n'); +}; + +const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { + const ssr = variant !== 'spa'; + + return [ + `import { defineConfig } from '@rsbuild/core';`, + `import { pluginReactRouter } from '${pluginImportPath}';`, + '', + 'export default defineConfig({', + ' plugins: [', + ' pluginReactRouter({', + ...(ssr ? [` serverOutput: 'module',`] : []), + ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, + ' }),', + ' ],', + ' output: {', + ` sourceMap: ${sourceMap ? 'true' : 'false'},`, + ' },', + '});', + '', + ].join('\n'); +}; + +const createReactRouterConfig = variant => { + const ssr = variant !== 'spa'; + const splitRouteModules = variant.includes('split'); + + return [ + `import type { Config } from '@react-router/dev/config';`, + '', + 'export default {', + ` ssr: ${ssr ? 'true' : 'false'},`, + ` future: {`, + ` v8_splitRouteModules: ${splitRouteModules ? 'true' : 'false'},`, + ' },', + '} satisfies Config;', + '', + ].join('\n'); +}; + +export async function generateSyntheticFixture({ + root, + routeCount, + variant, + sourceMap = false, + pluginImportPath = 'rsbuild-plugin-react-router', +}) { + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'app/routes'), { recursive: true }); + + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'module', private: true }, null, 2) + ); + await writeFile( + path.join(root, 'rsbuild.config.mjs'), + createRsbuildConfig({ variant, sourceMap, pluginImportPath }) + ); + await writeFile( + path.join(root, 'react-router.config.ts'), + createReactRouterConfig(variant) + ); + await writeFile( + path.join(root, 'app/routes.ts'), + createRoutesConfig(routeCount) + ); + await writeFile( + path.join(root, 'app/root.tsx'), + [ + `import { Outlet } from 'react-router';`, + `export default function Root() { return null; }`, + `export function Layout() { return null; }`, + `export function ErrorBoundary() { return null; }`, + '', + ].join('\n') + ); + await writeFile( + path.join(root, 'app/client-data.client.ts'), + `export const clientValue = 'client';\nexport * from './client-extra.client';\n` + ); + await writeFile( + path.join(root, 'app/client-extra.client.ts'), + `export const extraClientValue = 'extra-client';\n` + ); + await writeFile( + path.join(root, 'app/server-data.server.ts'), + `export const serverValue = 'server';\n` + ); + + for (let index = 1; index <= routeCount; index += 1) { + const profile = routeExportProfiles[index % routeExportProfiles.length]; + await writeFile( + path.join(root, 'app', routeFile(index)), + `${createRouteModule(index, profile)}\n` + ); + } + + return { + root, + routeCount, + variant, + sourceMap, + }; +} diff --git a/src/export-utils.ts b/src/export-utils.ts index d5f67d4..933a0f7 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,9 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { extname } from 'pathe'; import * as esbuild from 'esbuild'; import { init, parse as parseExports } from 'es-module-lexer'; import { JS_LOADERS } from './constants.js'; +type TransformCacheEntry = { + source: string; + transformed: Promise; +}; + +type RouteModuleAnalysis = { + source: string; + code: string; + exports: string[]; +}; + +type RouteModuleAnalysisCacheEntry = { + mtimeMs: number; + size: number; + analysis: Promise; +}; + +const transformCache = new Map(); +const routeModuleAnalysisCache = new Map< + string, + RouteModuleAnalysisCacheEntry +>(); + const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { const ext = extname(resourcePath) as keyof typeof JS_LOADERS; return JS_LOADERS[ext] ?? 'js'; @@ -13,14 +36,28 @@ export const transformToEsm = async ( code: string, resourcePath: string ): Promise => { - return ( - await esbuild.transform(code, { + const cached = transformCache.get(resourcePath); + if (cached?.source === code) { + return cached.transformed; + } + + const transformed = esbuild + .transform(code, { jsx: 'automatic', format: 'esm', platform: 'neutral', loader: getEsbuildLoader(resourcePath), }) - ).code; + .then(result => result.code) + .catch(error => { + if (transformCache.get(resourcePath)?.transformed === transformed) { + transformCache.delete(resourcePath); + } + throw error; + }); + + transformCache.set(resourcePath, { source: code, transformed }); + return transformed; }; export const getExportNames = async (code: string): Promise => { @@ -55,10 +92,37 @@ export const getExportNamesAndExportAll = async ( return { exportNames: Array.from(exportNames), exportAllModules }; }; +export const getRouteModuleAnalysis = async ( + resourcePath: string +): Promise => { + const stats = await stat(resourcePath); + const cached = routeModuleAnalysisCache.get(resourcePath); + if (cached?.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + return cached.analysis; + } + + const analysis = (async () => { + const source = await readFile(resourcePath, 'utf8'); + const code = await transformToEsm(source, resourcePath); + const exports = await getExportNames(code); + return { source, code, exports }; + })().catch(error => { + if (routeModuleAnalysisCache.get(resourcePath)?.analysis === analysis) { + routeModuleAnalysisCache.delete(resourcePath); + } + throw error; + }); + + routeModuleAnalysisCache.set(resourcePath, { + mtimeMs: stats.mtimeMs, + size: stats.size, + analysis, + }); + return analysis; +}; + export const getRouteModuleExports = async ( resourcePath: string ): Promise => { - const source = await readFile(resourcePath, 'utf8'); - const code = await transformToEsm(source, resourcePath); - return getExportNames(code); + return (await getRouteModuleAnalysis(resourcePath)).exports; }; diff --git a/src/index.ts b/src/index.ts index 651343a..4b72bc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ import { import { warnOnClientSourceMaps } from './warnings/warn-on-client-source-maps.js'; import { validatePluginOrderFromConfig } from './validation/validate-plugin-order.js'; import { getSsrExternals } from './ssr-externals.js'; +import { createReactRouterPerformanceProfiler } from './performance.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -127,6 +128,12 @@ export const pluginReactRouter = ( ...defaultOptions, ...options, }; + const logPerformance = pluginOptions.logPerformance === true; + const setupStartMs = logPerformance ? performance.now() : 0; + const performanceProfiler = createReactRouterPerformanceProfiler({ + enabled: logPerformance, + log: message => api.logger.info(message), + }); const nodeExternals = Array.from( new Set(['express', ...getSsrExternals(process.cwd())]) @@ -479,6 +486,12 @@ export const pluginReactRouter = ( fsExtra.copySync(serverBuildDir, ssrDir); } } + if (logPerformance) { + performanceProfiler.flush(environment.name, { + compilerLifecycleMs: + Math.round((performance.now() - setupStartMs) * 10) / 10, + }); + } }); // Determine prerender paths from config @@ -1256,28 +1269,38 @@ export const pluginReactRouter = ( { future, onManifest: (manifest, sri) => { - const baseServerManifest = { - ...manifest, - sri, - }; - latestServerManifest = baseServerManifest; - for (const [bundleId, bundleRoutes] of Object.entries( - routesByServerBundleId - )) { - if (!bundleRoutes) { - continue; + performanceProfiler.recordSync( + 'web', + 'manifest:stage', + 'virtual/react-router/browser-manifest', + () => { + const baseServerManifest = { + ...manifest, + sri, + }; + latestServerManifest = baseServerManifest; + for (const [ + bundleId, + bundleRoutes, + ] of Object.entries(routesByServerBundleId)) { + if (!bundleRoutes) { + continue; + } + const routeIds = new Set( + Object.keys(bundleRoutes) + ); + const filteredRoutes = Object.fromEntries( + Object.entries(manifest.routes).filter( + ([routeId]) => routeIds.has(routeId) + ) + ); + latestServerManifestsByBundleId[bundleId] = { + ...baseServerManifest, + routes: filteredRoutes, + }; + } } - const routeIds = new Set(Object.keys(bundleRoutes)); - const filteredRoutes = Object.fromEntries( - Object.entries(manifest.routes).filter( - ([routeId]) => routeIds.has(routeId) - ) - ); - latestServerManifestsByBundleId[bundleId] = { - ...baseServerManifest, - routes: filteredRoutes, - }; - } + ); }, } ) @@ -1311,445 +1334,506 @@ export const pluginReactRouter = ( { test: /virtual\/react-router\/(browser|server)-manifest/, }, - async args => { - // For browser manifest, return a placeholder that will be modified by the plugin - if (args.environment.name === 'web') { - return { - code: `window.__reactRouterManifest = "PLACEHOLDER";`, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'manifest:transform', + args.resource, + async () => { + // For browser manifest, return a placeholder that will be modified by the plugin + if (args.environment.name === 'web') { + return { + code: `window.__reactRouterManifest = "PLACEHOLDER";`, + }; + } - const bundleMatch = args.resource.match( - /virtual\/react-router\/server-manifest(?:-([^?]+))?/ - ); - const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); - - const manifest = - (isBuild && latestServerManifest - ? bundleId && latestServerManifestsByBundleId[bundleId] - ? latestServerManifestsByBundleId[bundleId] - : latestServerManifest - : null) ?? - (await getReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - )); - return { - code: `export default ${jsesc(manifest, { es6: true })};`, - }; - } + const bundleMatch = args.resource.match( + /virtual\/react-router\/server-manifest(?:-([^?]+))?/ + ); + const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); + + const manifest = + (isBuild && latestServerManifest + ? bundleId && latestServerManifestsByBundleId[bundleId] + ? latestServerManifestsByBundleId[bundleId] + : latestServerManifest + : null) ?? + (await getReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + )); + return { + code: `export default ${jsesc(manifest, { es6: true })};`, + }; + } + ) ); api.transform( { resourceQuery: /__react-router-build-client-route/, }, - async args => { - const code = await transformToEsm(args.code, args.resourcePath); - const exportNames = await getExportNames(code); - const isServer = args.environment?.name === 'node'; - const chunkedExports = - !isServer && isBuild && splitRouteModules - ? ( - await detectRouteChunksIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - code - ) - ).chunkedExports - : []; - const chunkedExportSet = new Set(chunkedExports); - const reexports = exportNames.filter(exp => { - if (chunkedExportSet.has(exp)) { - return false; + async args => + performanceProfiler.record( + args.environment?.name, + 'route:client-entry', + args.resource, + async () => { + const code = await transformToEsm(args.code, args.resourcePath); + const exportNames = await getExportNames(code); + const isServer = args.environment?.name === 'node'; + const chunkedExports = + !isServer && isBuild && splitRouteModules + ? ( + await detectRouteChunksIfEnabled( + routeChunkCache, + routeChunkConfig, + args.resourcePath, + code + ) + ).chunkedExports + : []; + const chunkedExportSet = new Set(chunkedExports); + const reexports = exportNames.filter(exp => { + if (chunkedExportSet.has(exp)) { + return false; + } + return ( + (CLIENT_ROUTE_EXPORTS as readonly string[]).includes(exp) || + (isServer && + (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes( + exp + )) + ); + }); + const target = `${args.resourcePath}?react-router-route`; + return { + code: `export { ${reexports.join(', ')} } from ${JSON.stringify( + target + )};`, + }; } - return ( - (CLIENT_ROUTE_EXPORTS as readonly string[]).includes(exp) || - (isServer && - (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes(exp)) - ); - }); - const target = `${args.resourcePath}?react-router-route`; - return { - code: `export { ${reexports.join(', ')} } from ${JSON.stringify( - target - )};`, - }; - } + ) ); api.transform( { resourceQuery: /route-chunk=/, }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - const preventEmptyChunkSnippet = (reason: string) => - `Math.random()<0&&console.log(${JSON.stringify(reason)});`; + async args => + performanceProfiler.record( + args.environment?.name, + 'route:chunk', + args.resource, + async () => { + if (args.environment?.name !== 'web') { + return { code: args.code, map: null }; + } + const preventEmptyChunkSnippet = (reason: string) => + `Math.random()<0&&console.log(${JSON.stringify(reason)});`; - if (!isBuild || !splitRouteModules) { - return { - code: preventEmptyChunkSnippet('Split route modules disabled'), - map: null, - }; - } + if (!isBuild || !splitRouteModules) { + return { + code: preventEmptyChunkSnippet('Split route modules disabled'), + map: null, + }; + } - const chunkName = getRouteChunkNameFromModuleId(args.resource); - if (!chunkName) { - throw new Error(`Invalid route chunk name in "${args.resource}"`); - } + const chunkName = getRouteChunkNameFromModuleId(args.resource); + if (!chunkName) { + throw new Error(`Invalid route chunk name in "${args.resource}"`); + } - const transformed = await transformToEsm(args.code, args.resourcePath); - const chunk = await getRouteChunkIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - chunkName, - transformed - ); + const transformed = await transformToEsm( + args.code, + args.resourcePath + ); + const chunk = await getRouteChunkIfEnabled( + routeChunkCache, + routeChunkConfig, + args.resourcePath, + chunkName, + transformed + ); - if (enforceSplitRouteModules && chunkName === 'main' && chunk) { - const exportNames = await getExportNames(chunk); - validateRouteChunks({ - config: routeChunkConfig, - id: args.resourcePath, - valid: { - clientAction: !exportNames.includes('clientAction'), - clientLoader: !exportNames.includes('clientLoader'), - clientMiddleware: !exportNames.includes('clientMiddleware'), - HydrateFallback: !exportNames.includes('HydrateFallback'), - }, - }); - } + if (enforceSplitRouteModules && chunkName === 'main' && chunk) { + const exportNames = await getExportNames(chunk); + validateRouteChunks({ + config: routeChunkConfig, + id: args.resourcePath, + valid: { + clientAction: !exportNames.includes('clientAction'), + clientLoader: !exportNames.includes('clientLoader'), + clientMiddleware: !exportNames.includes('clientMiddleware'), + HydrateFallback: !exportNames.includes('HydrateFallback'), + }, + }); + } - return { - code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), - map: null, - }; - } + return { + code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), + map: null, + }; + } + ) ); api.transform( { test: /\.[cm]?[jt]sx?$/, }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - if (!isBuild || !splitRouteModules) { - return { code: args.code, map: null }; - } - if ( - args.resource.includes(BUILD_CLIENT_ROUTE_QUERY_STRING) || - args.resource.includes('?react-router-route') || - args.resource.includes('route-chunk=') - ) { - return { code: args.code, map: null }; - } - const route = routeByFilePath.get(args.resourcePath); - if (!route) { - return { code: args.code, map: null }; - } - - const transformed = await transformToEsm(args.code, args.resourcePath); - const { hasRouteChunks, chunkedExports } = - await detectRouteChunksIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - transformed - ); - if (!hasRouteChunks) { - return { code: args.code, map: null }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:split-exports', + args.resource, + async () => { + if (args.environment?.name !== 'web') { + return { code: args.code, map: null }; + } + if (!isBuild || !splitRouteModules) { + return { code: args.code, map: null }; + } + if ( + args.resource.includes(BUILD_CLIENT_ROUTE_QUERY_STRING) || + args.resource.includes('?react-router-route') || + args.resource.includes('route-chunk=') + ) { + return { code: args.code, map: null }; + } + const route = routeByFilePath.get(args.resourcePath); + if (!route) { + return { code: args.code, map: null }; + } - const sourceExports = await getCachedRouteExports(args.resourcePath); - const chunkedExportSet = new Set(chunkedExports); - const isMainChunkExport = (name: string) => !chunkedExportSet.has(name); - const mainChunkReexports = sourceExports - .filter(isMainChunkExport) - .join(', '); - const chunkBasePath = `./${pathBasename(args.resourcePath)}`; + const transformed = await transformToEsm( + args.code, + args.resourcePath + ); + const { hasRouteChunks, chunkedExports } = + await detectRouteChunksIfEnabled( + routeChunkCache, + routeChunkConfig, + args.resourcePath, + transformed + ); + if (!hasRouteChunks) { + return { code: args.code, map: null }; + } - return { - code: [ - mainChunkReexports - ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( - chunkBasePath, - 'main' - )}";` - : null, - ...chunkedExports.map( - exportName => - `export { ${exportName} } from "${getRouteChunkModuleId( - chunkBasePath, - exportName - )}";` - ), - ] - .filter(Boolean) - .join('\n'), - map: null, - }; - } + const sourceExports = await getCachedRouteExports( + args.resourcePath + ); + const chunkedExportSet = new Set(chunkedExports); + const isMainChunkExport = (name: string) => + !chunkedExportSet.has(name); + const mainChunkReexports = sourceExports + .filter(isMainChunkExport) + .join(', '); + const chunkBasePath = `./${pathBasename(args.resourcePath)}`; + + return { + code: [ + mainChunkReexports + ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( + chunkBasePath, + 'main' + )}";` + : null, + ...chunkedExports.map( + exportName => + `export { ${exportName} } from "${getRouteChunkModuleId( + chunkBasePath, + exportName + )}";` + ), + ] + .filter(Boolean) + .join('\n'), + map: null, + }; + } + ) ); api.transform( { test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/, }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'module:server-only-guard', + args.resource, + async () => { + if (args.environment?.name !== 'web') { + return { code: args.code, map: null }; + } - const relativePath = relative(process.cwd(), args.resourcePath); - throw new Error( - `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` - ); - } + const relativePath = relative(process.cwd(), args.resourcePath); + throw new Error( + `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` + ); + } + ) ); api.transform( { test: /[\\/]\.client[\\/]|\.client(\.[cm]?[jt]sx?)?$/, }, - async args => { - if (args.environment?.name !== 'node') { - return { code: args.code, map: null }; - } - - const code = await transformToEsm(args.code, args.resourcePath); - const { exportNames: directExportNames, exportAllModules } = - await getExportNamesAndExportAll(code); - const exportNames = new Set(directExportNames); - const unresolvedExportAll = new Set(); - const visitedModules = new Set(); - - const resolveIndexFile = (dirPath: string): string | null => { - for (const ext of JS_EXTENSIONS) { - const candidate = resolve(dirPath, `index${ext}`); - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; + async args => + performanceProfiler.record( + args.environment?.name, + 'module:client-only-stub', + args.resource, + async () => { + if (args.environment?.name !== 'node') { + return { code: args.code, map: null }; } - } - return null; - }; - const resolvePathWithExtensions = (basePath: string): string | null => { - if (existsSync(basePath)) { - try { - const stats = statSync(basePath); - if (stats.isFile()) { - return basePath; + const code = await transformToEsm(args.code, args.resourcePath); + const { exportNames: directExportNames, exportAllModules } = + await getExportNamesAndExportAll(code); + const exportNames = new Set(directExportNames); + const unresolvedExportAll = new Set(); + const visitedModules = new Set(); + + const resolveIndexFile = (dirPath: string): string | null => { + for (const ext of JS_EXTENSIONS) { + const candidate = resolve(dirPath, `index${ext}`); + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + continue; + } } - if (stats.isDirectory()) { - return resolveIndexFile(basePath); + return null; + }; + + const resolvePathWithExtensions = ( + basePath: string + ): string | null => { + if (existsSync(basePath)) { + try { + const stats = statSync(basePath); + if (stats.isFile()) { + return basePath; + } + if (stats.isDirectory()) { + return resolveIndexFile(basePath); + } + } catch { + // Ignore invalid paths and fall back to extension probing. + } } - } catch { - // Ignore invalid paths and fall back to extension probing. - } - } - for (const ext of JS_EXTENSIONS) { - const candidate = `${basePath}${ext}`; - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; + for (const ext of JS_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + continue; + } } - } catch { - continue; - } - } - return resolveIndexFile(basePath); - }; + return resolveIndexFile(basePath); + }; + + const resolveExportAllModule = ( + specifier: string, + importerPath: string + ): string | null => { + if (specifier.startsWith('.') || specifier.startsWith('/')) { + const basePath = specifier.startsWith('/') + ? specifier + : resolve(dirname(importerPath), specifier); + const resolvedPath = resolvePathWithExtensions(basePath); + if (resolvedPath) { + return resolvedPath; + } + } - const resolveExportAllModule = ( - specifier: string, - importerPath: string - ): string | null => { - if (specifier.startsWith('.') || specifier.startsWith('/')) { - const basePath = specifier.startsWith('/') - ? specifier - : resolve(dirname(importerPath), specifier); - const resolvedPath = resolvePathWithExtensions(basePath); - if (resolvedPath) { - return resolvedPath; - } - } + try { + const resolver = createRequire( + pathToFileURL(importerPath).href + ); + return resolver.resolve(specifier); + } catch { + return null; + } + }; - try { - const resolver = createRequire(pathToFileURL(importerPath).href); - return resolver.resolve(specifier); - } catch { - return null; - } - }; + const collectExportNamesFromModule = async ( + modulePath: string + ): Promise => { + if (visitedModules.has(modulePath)) { + return; + } + visitedModules.add(modulePath); + const source = await readFile(modulePath, 'utf8'); + const moduleCode = await transformToEsm(source, modulePath); + const { + exportNames: moduleExportNames, + exportAllModules: moduleExportAll, + } = await getExportNamesAndExportAll(moduleCode); + for (const name of moduleExportNames) { + if (name !== 'default') { + exportNames.add(name); + } + } + for (const nestedSpecifier of moduleExportAll) { + const nestedPath = resolveExportAllModule( + nestedSpecifier, + modulePath + ); + if (!nestedPath) { + unresolvedExportAll.add(nestedSpecifier); + continue; + } + await collectExportNamesFromModule(nestedPath); + } + }; - const collectExportNamesFromModule = async ( - modulePath: string - ): Promise => { - if (visitedModules.has(modulePath)) { - return; - } - visitedModules.add(modulePath); - const source = await readFile(modulePath, 'utf8'); - const moduleCode = await transformToEsm(source, modulePath); - const { - exportNames: moduleExportNames, - exportAllModules: moduleExportAll, - } = await getExportNamesAndExportAll(moduleCode); - for (const name of moduleExportNames) { - if (name !== 'default') { - exportNames.add(name); - } - } - for (const nestedSpecifier of moduleExportAll) { - const nestedPath = resolveExportAllModule( - nestedSpecifier, - modulePath - ); - if (!nestedPath) { - unresolvedExportAll.add(nestedSpecifier); - continue; + for (const specifier of exportAllModules) { + const resolvedPath = resolveExportAllModule( + specifier, + args.resourcePath + ); + if (!resolvedPath) { + unresolvedExportAll.add(specifier); + continue; + } + await collectExportNamesFromModule(resolvedPath); } - await collectExportNamesFromModule(nestedPath); - } - }; - for (const specifier of exportAllModules) { - const resolvedPath = resolveExportAllModule( - specifier, - args.resourcePath - ); - if (!resolvedPath) { - unresolvedExportAll.add(specifier); - continue; + if (unresolvedExportAll.size > 0) { + throw new Error( + `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + + `unresolvable specifier(s): ${Array.from(unresolvedExportAll) + .map(spec => `\`${spec}\``) + .join(', ')}. ` + + `Please explicitly re-export named bindings in ` + + `\`${relative(process.cwd(), args.resourcePath)}\`.` + ); + } + return { + code: Array.from(exportNames) + .map(name => + name === 'default' + ? 'export default undefined;' + : `export const ${name} = undefined;` + ) + .join('\n'), + map: null, + }; } - await collectExportNamesFromModule(resolvedPath); - } - - if (unresolvedExportAll.size > 0) { - throw new Error( - `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + - `unresolvable specifier(s): ${Array.from(unresolvedExportAll) - .map(spec => `\`${spec}\``) - .join(', ')}. ` + - `Please explicitly re-export named bindings in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`.` - ); - } - return { - code: Array.from(exportNames) - .map(name => - name === 'default' - ? 'export default undefined;' - : `export const ${name} = undefined;` - ) - .join('\n'), - map: null, - }; - } + ) ); api.transform( { resourceQuery: /\?react-router-route/, }, - async args => { - let code: string; - try { - code = await transformToEsm(args.code, args.resourcePath); - } catch (error) { - console.error(args.resourcePath); - throw error; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:module', + args.resource, + async () => { + let code: string; + try { + code = await transformToEsm(args.code, args.resourcePath); + } catch (error) { + console.error(args.resourcePath); + throw error; + } - // Match React Router Vite behavior: - // In SPA mode, server-only route exports are invalid (except root `loader`), - // and `HydrateFallback` is only allowed on the root route. - // - // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan - // the ESBuild-transformed JS output. - if (args.environment.name === 'web' && !ssr && isSpaMode) { - const exportNames = await getExportNames(code); - - const isRootRoute = args.resourcePath === rootRoutePath; - - const invalidServerOnly = exportNames.filter(exp => { - if (isRootRoute && exp === 'loader') return false; - return (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes( - exp - ); - }); + // Match React Router Vite behavior: + // In SPA mode, server-only route exports are invalid (except root `loader`), + // and `HydrateFallback` is only allowed on the root route. + // + // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan + // the ESBuild-transformed JS output. + if (args.environment.name === 'web' && !ssr && isSpaMode) { + const exportNames = await getExportNames(code); + + const isRootRoute = args.resourcePath === rootRoutePath; + + const invalidServerOnly = exportNames.filter(exp => { + if (isRootRoute && exp === 'loader') return false; + return ( + SERVER_ONLY_ROUTE_EXPORTS as readonly string[] + ).includes(exp); + }); - if (invalidServerOnly.length > 0) { - const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); - throw new Error( - `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } + if (invalidServerOnly.length > 0) { + const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); + throw new Error( + `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + + `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } - if (!isRootRoute && exportNames.includes('HydrateFallback')) { - throw new Error( - `SPA Mode: Invalid \`HydrateFallback\` export found in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`. ` + - `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - } + if (!isRootRoute && exportNames.includes('HydrateFallback')) { + throw new Error( + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${relative(process.cwd(), args.resourcePath)}\`. ` + + `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + } - const defaultExportMatch = code.match( - /\n\s{0,}([\w\d_]+)\sas default,?/ - ); - if ( - defaultExportMatch && - typeof defaultExportMatch.index === 'number' - ) { - code = - code.slice(0, defaultExportMatch.index) + - code.slice(defaultExportMatch.index + defaultExportMatch[0].length); - code += `\nexport default ${defaultExportMatch[1]};`; - } + const defaultExportMatch = code.match( + /\n\s{0,}([\w\d_]+)\sas default,?/ + ); + if ( + defaultExportMatch && + typeof defaultExportMatch.index === 'number' + ) { + code = + code.slice(0, defaultExportMatch.index) + + code.slice( + defaultExportMatch.index + defaultExportMatch[0].length + ); + code += `\nexport default ${defaultExportMatch[1]};`; + } - const ast = parse(code, { sourceType: 'module' }); - if (args.environment.name === 'web') { - const mutableServerOnlyRouteExports = [...SERVER_ONLY_ROUTE_EXPORTS]; - removeExports(ast, mutableServerOnlyRouteExports); - } - transformRoute(ast); - if (args.environment.name === 'web') { - removeUnusedImports(ast); - } + const ast = parse(code, { sourceType: 'module' }); + if (args.environment.name === 'web') { + const mutableServerOnlyRouteExports = [ + ...SERVER_ONLY_ROUTE_EXPORTS, + ]; + removeExports(ast, mutableServerOnlyRouteExports); + } + transformRoute(ast); + if (args.environment.name === 'web') { + removeUnusedImports(ast); + } - return generate(ast, { - sourceMaps: true, - filename: args.resource, - sourceFileName: args.resourcePath, - }); - } + return generate(ast, { + sourceMaps: true, + filename: args.resource, + sourceFileName: args.resourcePath, + }); + } + ) ); }, }); diff --git a/src/manifest.ts b/src/manifest.ts index 961757a..08b8707 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve } from 'pathe'; import type { Route, PluginOptions, RouteManifestItem } from './types.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -13,7 +12,7 @@ import { type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; -import { getExportNames, transformToEsm } from './export-utils.js'; +import { getRouteModuleAnalysis } from './export-utils.js'; // Helper functions export function configRoutesToRouteManifest( @@ -161,116 +160,132 @@ export async function getReactRouterManifestForDev( return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined; }; - for (const [key, route] of Object.entries(routes)) { - const routeEntryName = getRouteEntryName(route); - const assets = getAssetsForChunk(routeEntryName); - const jsAssets = assets.filter(asset => asset.endsWith('.js')) || []; - let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; - // Read and analyze the route file to check for exports - const routeFilePath = resolve(context, route.file); - let exports = new Set(); - let hasRouteChunkByExportName: Record< - 'clientAction' | 'clientLoader' | 'clientMiddleware' | 'HydrateFallback', - boolean - > = { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - }; + const manifestEntries = await Promise.all( + Object.entries(routes).map(async ([key, route]) => { + const routeEntryName = getRouteEntryName(route); + const assets = getAssetsForChunk(routeEntryName); + const jsAssets = assets.filter(asset => asset.endsWith('.js')) || []; + let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; + // Read and analyze the route file to check for exports + const routeFilePath = resolve(context, route.file); + let exports = new Set(); + let hasRouteChunkByExportName: Record< + | 'clientAction' + | 'clientLoader' + | 'clientMiddleware' + | 'HydrateFallback', + boolean + > = { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }; - try { - const source = await readFile(routeFilePath, 'utf8'); - if ( - !isBuild && - cssAssets.length === 0 && - /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(source) - ) { - cssAssets = [ - `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, - ]; - } - const code = await transformToEsm(source, routeFilePath); - exports = new Set(await getExportNames(code)); + try { + const { + source, + code, + exports: exportNames, + } = await getRouteModuleAnalysis(routeFilePath); + if ( + !isBuild && + cssAssets.length === 0 && + /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(source) + ) { + cssAssets = [ + `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, + ]; + } + exports = new Set(exportNames); - if (isBuild && routeChunkConfig) { - const { hasRouteChunkByExportName: chunkInfo } = - await detectRouteChunksIfEnabled( - routeChunkOptions?.cache, - routeChunkConfig, - routeFilePath, - code - ); - hasRouteChunkByExportName = chunkInfo; + if (isBuild && routeChunkConfig) { + const { hasRouteChunkByExportName: chunkInfo } = + await detectRouteChunksIfEnabled( + routeChunkOptions?.cache, + routeChunkConfig, + routeFilePath, + code + ); + hasRouteChunkByExportName = chunkInfo; + } + } catch (error) { + console.error(`Failed to analyze route file ${routeFilePath}:`, error); } - } catch (error) { - console.error(`Failed to analyze route file ${routeFilePath}:`, error); - } - const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); - const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); - const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); - const hasHydrateFallback = exports.has(CLIENT_EXPORTS.HydrateFallback); - const hasDefaultExport = exports.has('default'); + const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); + const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); + const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); + const hasHydrateFallback = exports.has(CLIENT_EXPORTS.HydrateFallback); + const hasDefaultExport = exports.has('default'); + + if (isBuild && enforceSplitRouteModules && routeChunkConfig) { + validateRouteChunks({ + config: routeChunkConfig, + id: routeFilePath, + valid: { + clientAction: + !hasClientAction || hasRouteChunkByExportName.clientAction, + clientLoader: + !hasClientLoader || hasRouteChunkByExportName.clientLoader, + clientMiddleware: + !hasClientMiddleware || + hasRouteChunkByExportName.clientMiddleware, + HydrateFallback: + !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, + }, + }); + } - if (isBuild && enforceSplitRouteModules && routeChunkConfig) { - validateRouteChunks({ - config: routeChunkConfig, - id: routeFilePath, - valid: { - clientAction: - !hasClientAction || hasRouteChunkByExportName.clientAction, - clientLoader: - !hasClientLoader || hasRouteChunkByExportName.clientLoader, - clientMiddleware: - !hasClientMiddleware || hasRouteChunkByExportName.clientMiddleware, - HydrateFallback: - !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, + return [ + key, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: combineURLs(assetPrefix, jsAssets[0] || ''), + clientActionModule: + isBuild && hasRouteChunkByExportName.clientAction + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientAction') + ) + : undefined, + clientLoaderModule: + isBuild && hasRouteChunkByExportName.clientLoader + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientLoader') + ) + : undefined, + clientMiddlewareModule: + isBuild && hasRouteChunkByExportName.clientMiddleware + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientMiddleware') + ) + : undefined, + hydrateFallbackModule: + isBuild && hasRouteChunkByExportName.HydrateFallback + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'HydrateFallback') + ) + : undefined, + hasAction: exports.has(SERVER_EXPORTS.action), + hasLoader: exports.has(SERVER_EXPORTS.loader), + hasClientAction, + hasClientLoader, + hasClientMiddleware, + hasDefaultExport, + hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), + imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), + css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), }, - }); - } + ] as const; + }) + ); - result[key] = { - id: route.id, - parentId: route.parentId, - path: route.path, - index: route.index, - caseSensitive: route.caseSensitive, - module: combineURLs(assetPrefix, jsAssets[0] || ''), - clientActionModule: - isBuild && hasRouteChunkByExportName.clientAction - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientAction') - ) - : undefined, - clientLoaderModule: - isBuild && hasRouteChunkByExportName.clientLoader - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientLoader') - ) - : undefined, - clientMiddlewareModule: - isBuild && hasRouteChunkByExportName.clientMiddleware - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientMiddleware') - ) - : undefined, - hydrateFallbackModule: - isBuild && hasRouteChunkByExportName.HydrateFallback - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'HydrateFallback') - ) - : undefined, - hasAction: exports.has(SERVER_EXPORTS.action), - hasLoader: exports.has(SERVER_EXPORTS.loader), - hasClientAction, - hasClientLoader, - hasClientMiddleware, - hasDefaultExport, - hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), - imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), - css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), - }; + for (const [key, routeManifestItem] of manifestEntries) { + result[key] = routeManifestItem; } const entryAssets = getAssetsForChunk('entry.client'); diff --git a/src/performance.ts b/src/performance.ts new file mode 100644 index 0000000..5e482ca --- /dev/null +++ b/src/performance.ts @@ -0,0 +1,140 @@ +type OperationTiming = { + count: number; + totalMs: number; + maxMs: number; + slowest: Array<{ + durationMs: number; + resource: string; + }>; +}; + +type EnvironmentTimings = Map; + +export type ReactRouterPerformanceReport = { + environment: string; + compilerLifecycleMs?: number; + operations: Record; +}; + +export type ReactRouterPerformanceProfiler = { + record( + environment: string | undefined, + operation: string, + resource: string, + callback: () => Promise + ): Promise; + recordSync( + environment: string | undefined, + operation: string, + resource: string, + callback: () => T + ): T; + flush( + environment: string, + details?: Pick + ): void; +}; + +export const createReactRouterPerformanceProfiler = ({ + enabled, + log, +}: { + enabled: boolean; + log: (message: string) => void; +}): ReactRouterPerformanceProfiler => { + const timingsByEnvironment = new Map(); + + const getOperationTiming = ( + environment: string, + operation: string + ): OperationTiming => { + let timings = timingsByEnvironment.get(environment); + if (!timings) { + timings = new Map(); + timingsByEnvironment.set(environment, timings); + } + + let timing = timings.get(operation); + if (!timing) { + timing = { + count: 0, + totalMs: 0, + maxMs: 0, + slowest: [], + }; + timings.set(operation, timing); + } + return timing; + }; + + const recordDuration = ( + environment: string, + operation: string, + resource: string, + durationMs: number + ) => { + const roundedDuration = Math.round(durationMs * 10) / 10; + const timing = getOperationTiming(environment, operation); + timing.count += 1; + timing.totalMs = Math.round((timing.totalMs + roundedDuration) * 10) / 10; + timing.maxMs = Math.max(timing.maxMs, roundedDuration); + timing.slowest.push({ durationMs: roundedDuration, resource }); + timing.slowest.sort((a, b) => b.durationMs - a.durationMs); + timing.slowest = timing.slowest.slice(0, 5); + }; + + return { + async record(environment, operation, resource, callback) { + if (!enabled) { + return callback(); + } + + const start = performance.now(); + try { + return await callback(); + } finally { + recordDuration( + environment ?? 'unknown', + operation, + resource, + performance.now() - start + ); + } + }, + recordSync(environment, operation, resource, callback) { + if (!enabled) { + return callback(); + } + + const start = performance.now(); + try { + return callback(); + } finally { + recordDuration( + environment ?? 'unknown', + operation, + resource, + performance.now() - start + ); + } + }, + flush(environment, details = {}) { + if (!enabled) { + return; + } + + const timings = timingsByEnvironment.get(environment); + if (!timings || timings.size === 0) { + return; + } + + const operations = Object.fromEntries(timings.entries()); + const report: ReactRouterPerformanceReport = { + environment, + ...details, + operations, + }; + log(`[react-router:performance] ${JSON.stringify(report)}`); + }, + }; +}; diff --git a/src/types.ts b/src/types.ts index a8d3fca..aa985fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,13 @@ export type PluginOptions = { * Federation mode configuration */ federation?: boolean; + + /** + * Emit structured React Router plugin timing logs after each compiler + * environment finishes. + * @default false + */ + logPerformance?: boolean; }; /** diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts new file mode 100644 index 0000000..4d5a31d --- /dev/null +++ b/tests/benchmark-fixture.test.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from '@rstest/core'; + +describe('benchmark fixture generator', () => { + it('creates a deterministic synthetic React Router app', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 8, + variant: 'ssr-esm-split', + sourceMap: true, + }); + + expect(result.routeCount).toBe(8); + expect(result.variant).toBe('ssr-esm-split'); + expect(existsSync(join(root, 'app/routes.ts'))).toBe(true); + expect(existsSync(join(root, 'rsbuild.config.mjs'))).toBe(true); + + const routeConfig = readFileSync(join(root, 'app/routes.ts'), 'utf8'); + expect(routeConfig).toContain("id: 'route-0001'"); + expect(routeConfig).toContain("file: 'routes/route-0001.tsx'"); + expect(routeConfig).toContain("id: 'route-0008'"); + expect(existsSync(join(root, 'app/routes/route-0008.tsx'))).toBe(true); + + const routeModule = readFileSync( + join(root, 'app/routes/route-0003.tsx'), + 'utf8' + ); + expect(routeModule).toContain('export async function clientLoader'); + expect(routeModule).toContain('export default function Route0003'); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(rsbuildConfig).toContain('logPerformance'); + expect(rsbuildConfig).toContain('sourceMap: true'); + + const reactRouterConfig = readFileSync( + join(root, 'react-router.config.ts'), + 'utf8' + ); + expect(reactRouterConfig).toContain('v8_splitRouteModules'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('can point the benchmark config at an explicit built plugin import', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + pluginImportPath: 'file:///repo/dist/index.js', + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(rsbuildConfig).toContain( + "import { pluginReactRouter } from 'file:///repo/dist/index.js';" + ); + expect(rsbuildConfig).toContain("serverOutput: 'module'"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('accepts equals-form CLI options before benchmark selection', () => { + const result = spawnSync( + process.execPath, + [ + 'scripts/bench-builds.mjs', + '--profile=smoke', + '--iterations=1', + '--warmup=0', + '--filter=missing', + '--skip-root-build', + ], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('No benchmarks matched filter "missing".'); + expect(result.stderr).not.toContain('Unknown benchmark argument'); + }); +}); diff --git a/tests/performance.test.ts b/tests/performance.test.ts new file mode 100644 index 0000000..ac7f75b --- /dev/null +++ b/tests/performance.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from '@rstest/core'; +import { createReactRouterPerformanceProfiler } from '../src/performance'; + +describe('React Router performance profiler', () => { + it('aggregates operation timings by environment and logs structured JSON', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + await profiler.record('web', 'route:client-entry', 'app/routes/a.tsx', async () => { + return 'client-entry'; + }); + await profiler.record('web', 'route:client-entry', 'app/routes/b.tsx', async () => { + return 'client-entry'; + }); + await profiler.record('node', 'route:module', 'app/routes/a.tsx', async () => { + return 'route-module'; + }); + profiler.recordSync('web', 'manifest:stage', 'virtual/react-router/browser-manifest', () => { + return 'manifest'; + }); + + profiler.flush('web', { compilerLifecycleMs: 123.4 }); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('[react-router:performance]'); + + const report = JSON.parse(logs[0].replace(/^.*?\{/, '{')); + expect(report.environment).toBe('web'); + expect(report.compilerLifecycleMs).toBe(123.4); + expect(report.operations['route:client-entry'].count).toBe(2); + expect(report.operations['route:client-entry'].slowest).toHaveLength(2); + expect(report.operations['manifest:stage'].count).toBe(1); + expect(report.operations['route:module']).toBeUndefined(); + }); + + it('does not evaluate timers or log output when disabled', async () => { + const logs: string[] = []; + const originalNow = performance.now; + const nowCalls: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: false, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + nowCalls.push('now'); + throw new Error('disabled profiler should not read timers'); + }; + + const asyncResult = await profiler.record( + 'web', + 'route:module', + 'app/routes/a.tsx', + async () => 'unchanged' + ); + const syncResult = profiler.recordSync( + 'web', + 'manifest:stage', + 'virtual/react-router/browser-manifest', + () => 'sync-unchanged' + ); + profiler.flush('web'); + + expect(asyncResult).toBe('unchanged'); + expect(syncResult).toBe('sync-unchanged'); + expect(nowCalls).toEqual([]); + expect(logs).toEqual([]); + } finally { + performance.now = originalNow; + } + }); +}); From 7b7ae1a1705bd8bbdc4141906ff884a7c30c4c12 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:41:17 +0200 Subject: [PATCH 02/30] Summarize plugin benchmark operations --- benchmarks/README.md | 5 ++- scripts/bench-builds.mjs | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index aeca8c6..16918fb 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -44,7 +44,10 @@ Each run writes: - `.benchmark/results//baseline.md` The JSON includes wall time, optional GNU `/usr/bin/time -v` user/sys/RSS data, -and parsed `[react-router:performance]` reports from the plugin. +parsed `[react-router:performance]` reports from the plugin, and an aggregated +`pluginOperations` table per fixture. The markdown report includes the same +operation breakdown so route transforms and manifest work can be compared +without opening the raw JSON. ## Hygiene diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index 6ddd932..f5d36cf 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -218,8 +218,45 @@ const summarizeRuns = runs => ({ maxRssKb: summarizeMetric(runs.map(run => run.maxRssKb)), }); +const summarizePluginOperations = runs => { + const operations = new Map(); + + for (const run of runs) { + for (const report of run.pluginReports) { + for (const [operation, metrics] of Object.entries( + report.operations ?? {} + )) { + const key = `${report.environment}:${operation}`; + const current = operations.get(key) ?? { + environment: report.environment, + operation, + count: 0, + totalMs: 0, + maxMs: 0, + reports: 0, + }; + current.count += metrics.count ?? 0; + current.totalMs += metrics.totalMs ?? 0; + current.maxMs = Math.max(current.maxMs, metrics.maxMs ?? 0); + current.reports += 1; + operations.set(key, current); + } + } + } + + return [...operations.values()].sort((a, b) => { + if (b.totalMs !== a.totalMs) { + return b.totalMs - a.totalMs; + } + return `${a.environment}:${a.operation}`.localeCompare( + `${b.environment}:${b.operation}` + ); + }); +}; + const formatMs = value => value == null ? '-' : `${(value / 1000).toFixed(2)}s`; +const formatReportMs = value => (value == null ? '-' : `${value.toFixed(1)}ms`); const formatRss = value => value == null ? '-' : `${Math.round(value / 1024)} MB`; @@ -258,6 +295,34 @@ const renderMarkdown = result => { ); } + for (const benchmark of result.benchmarks) { + if (benchmark.pluginOperations.length === 0) { + continue; + } + lines.push( + '', + `## ${benchmark.id} Plugin Operations`, + '', + '| Environment | Operation | Count | Total | Max | Reports |', + '|---|---|---:|---:|---:|---:|' + ); + for (const operation of benchmark.pluginOperations.slice(0, 12)) { + lines.push( + [ + operation.environment, + operation.operation, + operation.count, + formatReportMs(operation.totalMs), + formatReportMs(operation.maxMs), + operation.reports, + ] + .join(' | ') + .replace(/^/, '| ') + .replace(/$/, ' |') + ); + } + } + lines.push(''); return `${lines.join('\n')}\n`; }; @@ -406,6 +471,7 @@ const main = async () => { 'node /node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs', runs, summary: summarizeRuns(runs), + pluginOperations: summarizePluginOperations(runs), }); } From aeae550904ab657e27b3aa2ad31e5853619c47e7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:18:01 +0200 Subject: [PATCH 03/30] Optimize route export analysis caches --- scripts/bench-builds.mjs | 3 ++- src/export-utils.ts | 49 ++++++++++++++++++++++++++++++++++------ src/index.ts | 11 +-------- src/performance.ts | 16 +++++++++++-- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index f5d36cf..e14d5f5 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -129,7 +129,7 @@ const runCommand = async ({ const childCommand = useTime ? '/usr/bin/time' : command; const childArgs = useTime ? ['-v', command, ...args] : args; - return new Promise(resolve => { + return new Promise((resolve, reject) => { const child = spawn(childCommand, childArgs, { cwd, env: { ...process.env, ...env }, @@ -154,6 +154,7 @@ const runCommand = async ({ wallMs: performance.now() - startedAt, }); }); + child.on('error', reject); }); }; diff --git a/src/export-utils.ts b/src/export-utils.ts index 933a0f7..48439de 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -22,11 +22,28 @@ type RouteModuleAnalysisCacheEntry = { }; const transformCache = new Map(); +const exportNamesCache = new Map>(); const routeModuleAnalysisCache = new Map< string, RouteModuleAnalysisCacheEntry >(); +const MAX_MODULE_ANALYSIS_CACHE_ENTRIES = 2048; + +const setBoundedCacheEntry = ( + cache: Map, + key: Key, + value: Value +) => { + if (!cache.has(key) && cache.size >= MAX_MODULE_ANALYSIS_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(key, value); +}; + const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { const ext = extname(resourcePath) as keyof typeof JS_LOADERS; return JS_LOADERS[ext] ?? 'js'; @@ -56,16 +73,34 @@ export const transformToEsm = async ( throw error; }); - transformCache.set(resourcePath, { source: code, transformed }); + setBoundedCacheEntry(transformCache, resourcePath, { + source: code, + transformed, + }); return transformed; }; export const getExportNames = async (code: string): Promise => { - await init; - const [, exportSpecifiers] = await parseExports(code); - return Array.from( - new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) - ); + const cached = exportNamesCache.get(code); + if (cached) { + return cached; + } + + const exports = (async () => { + await init; + const [, exportSpecifiers] = await parseExports(code); + return Array.from( + new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) + ); + })().catch(error => { + if (exportNamesCache.get(code) === exports) { + exportNamesCache.delete(code); + } + throw error; + }); + + setBoundedCacheEntry(exportNamesCache, code, exports); + return exports; }; export const getExportNamesAndExportAll = async ( @@ -113,7 +148,7 @@ export const getRouteModuleAnalysis = async ( throw error; }); - routeModuleAnalysisCache.set(resourcePath, { + setBoundedCacheEntry(routeModuleAnalysisCache, resourcePath, { mtimeMs: stats.mtimeMs, size: stats.size, analysis, diff --git a/src/index.ts b/src/index.ts index 4b72bc0..7220a17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -421,15 +421,6 @@ export const pluginReactRouter = ( route, ]) ); - const routeExportsCache = new Map(); - const getCachedRouteExports = async (filePath: string) => { - if (routeExportsCache.has(filePath)) { - return routeExportsCache.get(filePath)!; - } - const exports = await getRouteModuleExports(filePath); - routeExportsCache.set(filePath, exports); - return exports; - }; const webRouteEntries = Object.values(routes).reduce( (acc, route) => { @@ -1525,7 +1516,7 @@ export const pluginReactRouter = ( return { code: args.code, map: null }; } - const sourceExports = await getCachedRouteExports( + const sourceExports = await getRouteModuleExports( args.resourcePath ); const chunkedExportSet = new Set(chunkedExports); diff --git a/src/performance.ts b/src/performance.ts index 5e482ca..4f51d58 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -79,8 +79,20 @@ export const createReactRouterPerformanceProfiler = ({ timing.totalMs = Math.round((timing.totalMs + roundedDuration) * 10) / 10; timing.maxMs = Math.max(timing.maxMs, roundedDuration); timing.slowest.push({ durationMs: roundedDuration, resource }); - timing.slowest.sort((a, b) => b.durationMs - a.durationMs); - timing.slowest = timing.slowest.slice(0, 5); + for (let index = timing.slowest.length - 1; index > 0; index -= 1) { + if ( + timing.slowest[index].durationMs <= timing.slowest[index - 1].durationMs + ) { + break; + } + [timing.slowest[index - 1], timing.slowest[index]] = [ + timing.slowest[index], + timing.slowest[index - 1], + ]; + } + if (timing.slowest.length > 5) { + timing.slowest.pop(); + } }; return { From c39f5fae9687344cc8c62355abbf7ffd925a6ca3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:25:08 +0200 Subject: [PATCH 04/30] Fix SPA benchmark fixture generation --- scripts/benchmark/fixture.mjs | 32 ++++++++++++++++++++++++++------ tests/benchmark-fixture.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 54a84a6..02bb718 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -18,7 +18,7 @@ const routeId = index => `route-${padRoute(index)}`; const routeComponentName = index => `Route${padRoute(index)}`; -const createRouteModule = (index, profile) => { +const createRouteModule = (index, profile, { isSpa }) => { const name = routeComponentName(index); const shared = [ `export const handle = { label: '${routeId(index)}' };`, @@ -27,6 +27,13 @@ const createRouteModule = (index, profile) => { ]; if (profile === 'ssr-data') { + if (isSpa) { + return [ + ...shared, + `export function shouldRevalidate() { return false; }`, + ].join('\n'); + } + return [ `import { serverValue } from '../server-data.server';`, ...shared, @@ -44,11 +51,19 @@ const createRouteModule = (index, profile) => { `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, `export async function clientAction() { return { ok: true }; }`, `export async function clientMiddleware() { return undefined; }`, - `export function HydrateFallback() { return null; }`, + ...(isSpa ? [] : [`export function HydrateFallback() { return null; }`]), ].join('\n'); } if (profile === 'client-server-imports') { + if (isSpa) { + return [ + `import { clientValue } from '../client-data.client';`, + ...shared, + `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, + ].join('\n'); + } + return [ `import { clientValue } from '../client-data.client';`, `import { serverValue } from '../server-data.server';`, @@ -133,6 +148,8 @@ export async function generateSyntheticFixture({ sourceMap = false, pluginImportPath = 'rsbuild-plugin-react-router', }) { + const isSpa = variant === 'spa'; + await rm(root, { recursive: true, force: true }); await mkdir(path.join(root, 'app/routes'), { recursive: true }); @@ -155,9 +172,12 @@ export async function generateSyntheticFixture({ await writeFile( path.join(root, 'app/root.tsx'), [ - `import { Outlet } from 'react-router';`, - `export default function Root() { return null; }`, - `export function Layout() { return null; }`, + `import { createElement } from 'react';`, + `import { Outlet, Scripts } from 'react-router';`, + `export function Layout({ children }) {`, + ` return createElement('html', null, createElement('head'), createElement('body', null, children, createElement(Scripts)));`, + `}`, + `export default function Root() { return createElement(Outlet); }`, `export function ErrorBoundary() { return null; }`, '', ].join('\n') @@ -179,7 +199,7 @@ export async function generateSyntheticFixture({ const profile = routeExportProfiles[index % routeExportProfiles.length]; await writeFile( path.join(root, 'app', routeFile(index)), - `${createRouteModule(index, profile)}\n` + `${createRouteModule(index, profile, { isSpa })}\n` ); } diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts index 4d5a31d..d5fb8ff 100644 --- a/tests/benchmark-fixture.test.ts +++ b/tests/benchmark-fixture.test.ts @@ -75,6 +75,38 @@ describe('benchmark fixture generator', () => { } }); + it('omits server-only route exports from SPA benchmark fixtures', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + await generateSyntheticFixture({ + root, + routeCount: 8, + variant: 'spa', + }); + + const rootModule = readFileSync(join(root, 'app/root.tsx'), 'utf8'); + expect(rootModule).toContain('Scripts'); + + for (let index = 1; index <= 8; index += 1) { + const routeModule = readFileSync( + join(root, `app/routes/route-${String(index).padStart(4, '0')}.tsx`), + 'utf8' + ); + expect(routeModule).not.toContain('function loader'); + expect(routeModule).not.toContain('function action'); + expect(routeModule).not.toContain('function headers'); + expect(routeModule).not.toContain('HydrateFallback'); + expect(routeModule).not.toContain('server-data.server'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('accepts equals-form CLI options before benchmark selection', () => { const result = spawnSync( process.execPath, From c2452de1393264c2b01ef8aa03908077bce025db Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:08:25 +0200 Subject: [PATCH 05/30] perf: reduce benchmark and route analysis overhead --- scripts/bench-builds.mjs | 111 ++++++++++++++++------------------ scripts/benchmark/fixture.mjs | 17 +++--- src/export-utils.ts | 4 +- src/index.ts | 4 +- src/performance.ts | 15 ++++- tests/performance.test.ts | 10 +++ 6 files changed, 88 insertions(+), 73 deletions(-) diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index e14d5f5..834f43d 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -2,9 +2,10 @@ import { access, mkdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import { performance } from 'node:perf_hooks'; import { pathToFileURL } from 'node:url'; +import { parseArgs as parseCliArgs } from 'node:util'; +import { execa } from 'execa'; import { generateSyntheticFixture } from './benchmark/fixture.mjs'; const rootDir = process.cwd(); @@ -59,35 +60,38 @@ const profiles = { }; const parseArgs = argv => { + const { values } = parseCliArgs({ + args: argv, + allowPositionals: false, + strict: true, + options: { + profile: { type: 'string', default: 'default' }, + iterations: { type: 'string', default: '5' }, + warmup: { type: 'string', default: '1' }, + format: { type: 'string', default: 'both' }, + out: { + type: 'string', + default: path.join('.benchmark', 'results', 'baseline'), + }, + clean: { type: 'string', default: 'build' }, + filter: { type: 'string' }, + 'fail-fast': { type: 'boolean', default: false }, + 'skip-root-build': { type: 'boolean', default: false }, + }, + }); + const args = { - profile: 'default', - iterations: 5, - warmup: 1, - format: 'both', - out: path.join('.benchmark', 'results', 'baseline'), - clean: 'build', - filter: null, - failFast: false, - skipRootBuild: false, + profile: values.profile, + iterations: Number(values.iterations), + warmup: Number(values.warmup), + format: values.format, + out: values.out, + clean: values.clean, + filter: values.filter ?? null, + failFast: values['fail-fast'], + skipRootBuild: values['skip-root-build'], }; - for (let index = 0; index < argv.length; index += 1) { - const [arg, inlineValue] = argv[index].split('=', 2); - const next = () => inlineValue ?? argv[++index]; - if (arg === '--profile') args.profile = next(); - else if (arg === '--iterations') args.iterations = Number(next()); - else if (arg === '--warmup') args.warmup = Number(next()); - else if (arg === '--format') args.format = next(); - else if (arg === '--out') args.out = next(); - else if (arg === '--clean') args.clean = next(); - else if (arg === '--filter') args.filter = next(); - else if (arg === '--fail-fast') args.failFast = true; - else if (arg === '--skip-root-build') args.skipRootBuild = true; - else { - throw new Error(`Unknown benchmark argument: ${argv[index]}`); - } - } - if (!profiles[args.profile]) { throw new Error( `Unknown profile "${args.profile}". Use smoke, default, or full.` @@ -129,33 +133,22 @@ const runCommand = async ({ const childCommand = useTime ? '/usr/bin/time' : command; const childArgs = useTime ? ['-v', command, ...args] : args; - return new Promise((resolve, reject) => { - const child = spawn(childCommand, childArgs, { - cwd, - env: { ...process.env, ...env }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - child.stdout.on('data', chunk => { - stdout += chunk; - process.stdout.write(chunk); - }); - child.stderr.on('data', chunk => { - stderr += chunk; - process.stderr.write(chunk); - }); - child.on('close', status => { - resolve({ - status, - stdout, - stderr, - wallMs: performance.now() - startedAt, - }); - }); - child.on('error', reject); + const child = execa(childCommand, childArgs, { + cwd, + env, + reject: false, }); + + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); + + const result = await child; + return { + status: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + wallMs: performance.now() - startedAt, + }; }; const parseTimeStats = stderr => { @@ -373,11 +366,13 @@ const pnpmVersion = async () => { }; const cleanBuildOutputs = async fixtureRoot => { - await rm(path.join(fixtureRoot, 'build'), { recursive: true, force: true }); - await rm(path.join(fixtureRoot, '.react-router'), { - recursive: true, - force: true, - }); + await Promise.all([ + rm(path.join(fixtureRoot, 'build'), { recursive: true, force: true }), + rm(path.join(fixtureRoot, '.react-router'), { + recursive: true, + force: true, + }), + ]); }; const main = async () => { diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 02bb718..ec6d614 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -195,13 +195,16 @@ export async function generateSyntheticFixture({ `export const serverValue = 'server';\n` ); - for (let index = 1; index <= routeCount; index += 1) { - const profile = routeExportProfiles[index % routeExportProfiles.length]; - await writeFile( - path.join(root, 'app', routeFile(index)), - `${createRouteModule(index, profile, { isSpa })}\n` - ); - } + await Promise.all( + Array.from({ length: routeCount }, (_, routeIndex) => { + const index = routeIndex + 1; + const profile = routeExportProfiles[index % routeExportProfiles.length]; + return writeFile( + path.join(root, 'app', routeFile(index)), + `${createRouteModule(index, profile, { isSpa })}\n` + ); + }) + ); return { root, diff --git a/src/export-utils.ts b/src/export-utils.ts index 48439de..1836a21 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -28,14 +28,14 @@ const routeModuleAnalysisCache = new Map< RouteModuleAnalysisCacheEntry >(); -const MAX_MODULE_ANALYSIS_CACHE_ENTRIES = 2048; +const MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048; const setBoundedCacheEntry = ( cache: Map, key: Key, value: Value ) => { - if (!cache.has(key) && cache.size >= MAX_MODULE_ANALYSIS_CACHE_ENTRIES) { + if (!cache.has(key) && cache.size >= MAX_EXPORT_UTILS_CACHE_ENTRIES) { const oldestKey = cache.keys().next().value; if (oldestKey !== undefined) { cache.delete(oldestKey); diff --git a/src/index.ts b/src/index.ts index 7220a17..2e7a082 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1516,9 +1516,7 @@ export const pluginReactRouter = ( return { code: args.code, map: null }; } - const sourceExports = await getRouteModuleExports( - args.resourcePath - ); + const sourceExports = await getExportNames(transformed); const chunkedExportSet = new Set(chunkedExports); const isMainChunkExport = (name: string) => !chunkedExportSet.has(name); diff --git a/src/performance.ts b/src/performance.ts index 4f51d58..942b27e 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -96,21 +96,29 @@ export const createReactRouterPerformanceProfiler = ({ }; return { - async record(environment, operation, resource, callback) { + record(environment, operation, resource, callback) { if (!enabled) { return callback(); } const start = performance.now(); try { - return await callback(); - } finally { + return callback().finally(() => { + recordDuration( + environment ?? 'unknown', + operation, + resource, + performance.now() - start + ); + }); + } catch (error) { recordDuration( environment ?? 'unknown', operation, resource, performance.now() - start ); + return Promise.reject(error); } }, recordSync(environment, operation, resource, callback) { @@ -147,6 +155,7 @@ export const createReactRouterPerformanceProfiler = ({ operations, }; log(`[react-router:performance] ${JSON.stringify(report)}`); + timingsByEnvironment.delete(environment); }, }; }; diff --git a/tests/performance.test.ts b/tests/performance.test.ts index ac7f75b..5700e4d 100644 --- a/tests/performance.test.ts +++ b/tests/performance.test.ts @@ -34,6 +34,16 @@ describe('React Router performance profiler', () => { expect(report.operations['route:client-entry'].slowest).toHaveLength(2); expect(report.operations['manifest:stage'].count).toBe(1); expect(report.operations['route:module']).toBeUndefined(); + + await profiler.record('web', 'route:client-entry', 'app/routes/c.tsx', async () => { + return 'client-entry'; + }); + profiler.flush('web'); + + expect(logs).toHaveLength(2); + const secondReport = JSON.parse(logs[1].replace(/^.*?\{/, '{')); + expect(secondReport.operations['route:client-entry'].count).toBe(1); + expect(secondReport.operations['manifest:stage']).toBeUndefined(); }); it('does not evaluate timers or log output when disabled', async () => { From 969e6719e362cd81d12a653b507d36a51b0000d3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:45:22 +0200 Subject: [PATCH 06/30] perf: reduce route analysis overhead --- benchmarks/chunk-precompute-methodology.md | 370 +++++++ .../manifest-performance-methodology.md | 352 +++++++ package.json | 1 + performance-timing-semantics-analysis.md | 149 +++ route-analysis-duplication-audit.md | 356 +++++++ route-chunk-parse-traverse-analysis.md | 244 +++++ scripts/bench-builds.mjs | 9 +- scripts/compare-benchmarks.mjs | 128 +++ src/export-utils.ts | 87 ++ src/index.ts | 53 +- src/manifest.ts | 62 +- src/performance.ts | 108 +- src/route-chunks.ts | 981 ++++++++++-------- task/lexer-route-export-triage.md | 208 ++++ task/route-chunk-correctness-test-spec.md | 437 ++++++++ task/route-chunk-precompute-plan.md | 321 ++++++ ...fied-route-module-analysis-cache-triage.md | 598 +++++++++++ tests/export-utils.test.ts | 55 + tests/manifest-split-route-modules.test.ts | 231 +++-- tests/manifest.test.ts | 111 +- tests/performance.test.ts | 53 + tests/route-chunks-cache.test.ts | 122 +++ tests/route-chunks.test.ts | 553 ++++++++-- 23 files changed, 4913 insertions(+), 676 deletions(-) create mode 100644 benchmarks/chunk-precompute-methodology.md create mode 100644 benchmarks/manifest-performance-methodology.md create mode 100644 performance-timing-semantics-analysis.md create mode 100644 route-analysis-duplication-audit.md create mode 100644 route-chunk-parse-traverse-analysis.md create mode 100644 scripts/compare-benchmarks.mjs create mode 100644 task/lexer-route-export-triage.md create mode 100644 task/route-chunk-correctness-test-spec.md create mode 100644 task/route-chunk-precompute-plan.md create mode 100644 task/unified-route-module-analysis-cache-triage.md create mode 100644 tests/export-utils.test.ts create mode 100644 tests/route-chunks-cache.test.ts diff --git a/benchmarks/chunk-precompute-methodology.md b/benchmarks/chunk-precompute-methodology.md new file mode 100644 index 0000000..29859a2 --- /dev/null +++ b/benchmarks/chunk-precompute-methodology.md @@ -0,0 +1,370 @@ +# Benchmark Methodology: Precomputed `RouteChunkAnalysis` vs Per-Query/Per-Export Babel + +This document defines the exact commands, fixtures, metrics, and comparison +procedure to evaluate replacing the current **lazy per-query / per-export** +Babel parse→traverse→generate behavior with a **precomputed +`RouteChunkAnalysis`** approach for route module splitting +(`future.v8_splitRouteModules`). + +It is the methodology reference for downstream implementation tasks. No code +changes are required to run the **baseline** half; the **precompute** half needs +the implementation behind a toggle before its commands produce numbers. + +--- + +## 1. What we are comparing + +### Current behavior (lazy, per-query / per-export) + +Source of truth: `src/route-chunks.ts`, `src/index.ts`, `src/manifest.ts`. + +When `v8_splitRouteModules` is enabled, each route module is analyzed lazily +and redundantly across the build lifecycle: + +| Call site | Operation name | What it triggers | +| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ | +| `route:client-entry` transform (`index.ts:1383`) | `route:client-entry` | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | +| `route:split-exports` transform (`index.ts:1509`) | `route:split-exports` | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | +| manifest build (`manifest.ts:204`) | (inside manifest staging) | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | +| `?route-chunk=` query transform (`index.ts:1446`) | `route:chunk` | `getRouteChunkIfEnabled` → `getChunkedExport`/`omitChunkedExports` | + +Each `hasChunkableExport(name)` → `getExportDependencies()` → `codeToAst()` +(**Babel parse**) + `traverse()`. Each chunk extraction additionally calls +`generate()` and re-`codeToAst()`. + +The `RouteChunkCache` (`Map` keyed by `cacheKey::suffix`, versioned by the raw +code string) memoizes within a single build, so the _first_ call per +`(module, op)` pays the parse/traverse and subsequent calls hit the cache. +**However** `codeToAst()` runs `structuredClone(...)` on **every** access, +including cache hits (`route-chunks.ts:93`), which is O(AST size). There are +also up to 5 `?route-chunk=` queries per splittable route (`main` + 4 client +exports), each a separate lazy entry point. + +### Proposed behavior (precomputed `RouteChunkAnalysis`) + +Parse **once**, traverse **once**, and in a single coordinated pass per route +module compute: + +1. which of the 4 client exports are independently chunkable, and +2. the generated code string for every chunk (`main`, `clientAction`, + `clientLoader`, `clientMiddleware`, `HydrateFallback`) that is actually + present. + +The result is a single `RouteChunkAnalysis` object cached once per module; all +downstream call sites (`route:client-entry`, `route:split-exports`, manifest, +and each `?route-chunk=` query) read from it instead of re-entering the Babel +pipeline. This eliminates the repeated `structuredClone` and the redundant +`getExportDependencies` traversals across call sites. + +> The implementation lives behind a toggle so both halves can be measured on +> the same commit (see §3). + +--- + +## 2. Representative route modules (fixtures) + +Use the existing synthetic fixture generator (`scripts/benchmark/fixture.mjs`). +It produces deterministic route modules across a fixed export profile cycle: + +``` +['plain', 'ssr-data', 'split-client', 'split-client', 'ssr-data', 'client-server-imports'] +``` + +Only `split-client` and `client-server-imports` profiles emit client exports +(`clientAction`, `clientLoader`, `clientMiddleware`, `HydrateFallback`) — i.e. +**4 of every 6 routes (~67%) are splittable**. `plain` and `ssr-data` routes +exercise the early-exit fast path (`code.includes(exportName)` guard at +`route-chunks.ts:863`). This mix already represents the realistic distribution. + +**Why this is representative:** + +- `split-client`: all 4 client exports + a `.client` import — the worst case for + `generate()` (5 queries: main + 4 chunks). +- `client-server-imports`: mixed `.client`/`.server` imports — exercises import + specifier filtering in `omitChunkedExports`/`getChunkedExport`. +- `plain`/`ssr-data`: non-splittable, measuring the fast-path / early-exit cost + the precompute must not regress. + +The only variant that exercises the route-chunk code path is **`ssr-esm-split`** +(`v8_splitRouteModules: true`, web/client environment). The non-split `ssr-esm` +variant is the **control** — it must show no measurable difference between +baseline and precompute, confirming the toggle is inert when splitting is off. + +### Route counts + +| Count | Purpose | +| ----- | ----------------------------------------------------- | +| 48 | smoke / correctness | +| 256 | primary comparison (default profile scale) | +| 1024 | stress / scaling (does precompute win grow linearly?) | + +--- + +## 3. Toggle for A/B comparison + +The precompute implementation **must** be gated behind an opt-in so the same +commit can produce both halves of the comparison. Two acceptable shapes: + +- **Env var** (simplest, no public API surface): + `ROUTE_CHUNK_PRECOMPUTE=1` → precompute path; unset/`0` → current lazy path. +- **Future flag** under `pluginReactRouter({ future: { v8_routeChunkPrecompute } })`. + +The fixture generator's `rsbuild.config.mjs` and the bench harness pass this +through via the build environment. The methodology commands below assume the +**env var** shape; if a future flag is used instead, substitute the config +knob. + +--- + +## 4. Exact commands + +All commands run from the repo root +(`/home/zack/projects/rsbuild-plugin-react-router`). GNU `time` (`/usr/bin/time +-v`) is present and is auto-detected by the harness. + +### 4.1 Pre-flight (once per session) + +```sh +git status --short # confirm clean tree +pnpm install # ensure node_modules +pnpm build # build dist/ (harness builds it once anyway) +node --version # record Node version (v22.x here) +``` + +### 4.2 End-to-end build benchmark (primary comparison) + +This exercises the **full plugin** under a real Rsbuild production build — the +ground-truth measurement. It reuses `scripts/bench-builds.mjs` and the +`--filter` flag to isolate the split variant. + +Run the **full `default` profile** for each toggle value. The emitted JSON +contains all four variants in one file, so you compare the +`synthetic-256-ssr-esm-split` row (the code path that changes) **and** the +`synthetic-256-ssr-esm` row (the non-split control) from the same run — no +filtering needed. Avoid `--filter` for the control: the harness uses substring +matching (`benchmark.id.includes(filter)`), so `"synthetic-256-ssr-esm"` also +matches the `-split` variant. + +**Baseline (current lazy behavior):** + +```sh +ROUTE_CHUNK_PRECOMPUTE=0 pnpm bench:baseline \ + --profile default \ + --iterations 8 --warmup 2 \ + --clean build \ + --format both \ + --out .benchmark/results/lazy +``` + +**Precompute:** + +```sh +ROUTE_CHUNK_PRECOMPUTE=1 pnpm bench:baseline \ + --profile default \ + --iterations 8 --warmup 2 \ + --clean build \ + --format both \ + --out .benchmark/results/precompute +``` + +To save time when iterating, you may scope a single run to the split variant +with `--filter split` (matches only `synthetic-256-ssr-esm-split`), but the +definitive comparison uses the full profile so the control is captured +alongside. + +### 4.3 Scaling sweep (does the win grow with route count?) + +Use the `full` profile filtered to the split variant, which adds the 1024-route +fixture: + +```sh +for PRECOMPUTE in 0 1; do + ROUTE_CHUNK_PRECOMPUTE=$PRECOMPUTE pnpm bench:full \ + --profile full --filter split \ + --iterations 5 --warmup 1 \ + --clean build \ + --out .benchmark/results/scale-precompute-$PRECOMPUTE +done +``` + +### 4.4 Isolated micro-benchmark (parse/traverse/generate counts) + +The end-to-end build bundles the route-chunk Babel work inside the +`route:client-entry`, `route:chunk`, and `route:split-exports` operation +buckets. To attribute cost **directly** to the analysis (independent of Rspack +overhead), add a standalone micro-benchmark that imports the analysis +functions and runs them over generated route modules in-process. + +Proposed script: `scripts/bench-chunk-analysis.mjs` (to be created by the +benchmark-implementation task). It imports from the built package: + +```js +import { generateSyntheticFixture } from './benchmark/fixture.mjs'; +// route-chunks internals are not part of the public API; import the public +// entrypoints detectRouteChunksIfEnabled / getRouteChunkIfEnabled from dist, +// OR export a bench-only analyzeRouteModule() from src for direct timing. +``` + +Run shape: + +```sh +node scripts/bench-chunk-analysis.mjs \ + --routes 256 --variant ssr-esm-split \ + --iterations 50 --warmup 5 \ + --mode lazy \ + --out .benchmark/results/micro-lazy.json + +node scripts/bench-chunk-analysis.mjs \ + --routes 256 --variant ssr-esm-split \ + --iterations 50 --warmup 5 \ + --mode precompute \ + --out .benchmark/results/micro-precompute.json +``` + +High iteration count (50) is appropriate here because each iteration is a pure +in-memory function call (no process spawn), so variance is low and 50 samples +give a tight p95. + +--- + +## 5. Metrics to capture + +### 5.1 From the end-to-end harness (already wired) + +The harness writes `baseline.json` + `baseline.md` containing: + +| Metric | Source | What it tells us | +| ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------ | +| `wallMs` (min/median/mean/p95/stdev) | `performance.now()` | total build time | +| `userMs` | `/usr/bin/time -v` "User time" | CPU time in user mode | +| `sysMs` | `/usr/bin/time -v` "System time" | CPU time in kernel | +| `maxRssKb` | `/usr/bin/time -v` "Maximum resident set size" | peak memory | +| `pluginOperations[].count` | `[react-router:performance]` reports | **parse/traverse invocation counts** (operation granularity) | +| `pluginOperations[].totalMs` | same | cumulative time per operation | +| `pluginOperations[].maxMs` | same | slowest single invocation | + +**CPU time** = `userMs + sysMs` (summarized independently, then added for the +comparison). This isolates plugin work from I/O / Rspack scheduling. + +**Parse/traverse counts**: the relevant operation buckets are `route:chunk`, +`route:client-entry`, and `route:split-exports`. Their `.count` fields, +summed, are the proxy for "how many times the Babel pipeline was entered per +route." The precompute path should reduce `route:chunk` and +`route:split-exports` totalMs without changing `.count` semantics (count stays +≈ routes, but totalMs drops), **unless** the implementation also adds a +dedicated `route:chunk-analyze` operation to expose the precompute pass +explicitly — then compare that new bucket's single-pass cost against the sum +of the old buckets. + +**Generated-code cost**: the `route:chunk` operation's `totalMs` is dominated +by `generate()` plus the AST surgery in `getChunkedExport`/`omitChunkedExports`. +Compare `route:chunk.totalMs` between lazy and precompute directly. + +### 5.2 From the micro-benchmark + +| Metric | How | +| ----------------------- | ------------------------------------------------------------ | +| `parse` calls | counter incremented in the `codeToAst` path | +| `traverse` calls | counter in `getExportDependencies` | +| `generate` calls | counter in `getChunkedExport`/`omitChunkedExports` | +| `structuredClone` calls | counter in `codeToAst` (the per-access clone) | +| analysis `totalMs` | `performance.now()` around the full analyze-all-modules loop | +| per-route `meanMs` | `totalMs / routeCount` | +| heap delta | `process.memoryUsage().heapUsed` before/after the loop | + +These direct counters are the cleanest evidence that precompute collapses N +parses into 1 and removes the repeated `structuredClone`. + +### 5.3 Memory impact + +Two views: + +- **Peak RSS** from the end-to-end harness (`maxRssKb.p95`) — includes Rspack, + so expect a small relative delta; use this for the user-facing "did peak + memory get worse" question. +- **Heap delta** from the micro-benchmark — isolates the analysis's own + retained memory (the precomputed `RouteChunkAnalysis` objects are held for + the build lifetime; quantify their size vs the lazy cache's transient + entries). + +--- + +## 6. Iterations and warmup + +| Benchmark | Warmup | Measured | Rationale | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------- | +| End-to-end (`bench:baseline`) | 2 | 8 | process spawn + Rspack JIT warmup dominate; 2 warmups stabilize, 8 samples give a usable p95/stdev | +| Scaling (`bench:full`) | 1 | 5 | 1024-route builds are slow; 5 samples balance time vs signal | +| Micro (`bench-chunk-analysis`) | 5 | 50 | in-memory, low variance; tight statistics needed to see sub-millisecond wins | + +Always use `--clean build` for end-to-end runs (removes `build/` and +`.react-router/` between iterations) so each iteration is a cold plugin pass, +not a cache-rebuild. Do **not** use `--clean cold` (deletes `node_modules`) for +performance runs — it measures `pnpm install`, not the plugin. + +Run both halves (lazy + precompute) **back-to-back on the same machine with no +other load**, and pin the same Node version. Record `git rev-parse HEAD` (the +harness embeds `commit` in the JSON output automatically). + +--- + +## 7. Comparison procedure + +### 7.1 End-to-end + +1. Load `.benchmark/results/lazy/baseline.json` and + `.benchmark/results/precompute/baseline.json`. +2. For the `synthetic-256-ssr-esm-split` benchmark, compare: + - `summary.userMs.median` + `summary.sysMs.median` → **CPU time delta** + - `summary.wallMs.median` → total build delta + - `summary.maxRssKb.p95` → memory delta + - `pluginOperations` where `operation ∈ {route:chunk, route:client-entry, +route:split-exports}`: `totalMs` and `maxMs` deltas. +3. Repeat for the 1024-route split fixture from the scaling run. +4. Confirm the **non-split control** (`ssr-esm`, no split) shows no statistically + meaningful difference (medians within ~1 stdev). If it diverges, the toggle + is leaking into the non-split path — that's a bug, not a result. + +### 7.2 Micro + +1. Load the two micro JSON files. +2. Compare absolute counters: `parse`, `traverse`, `generate`, + `structuredClone` call counts per route. Expected: precompute shows + `parse = routeCount` (1 per module) vs lazy's `parse ≤ 5×routeCount` and + `structuredClone` ≈ 0 (precompute keeps one AST, not re-cloning). +3. Compare `per-route meanMs` and `heap delta`. + +### 7.3 Reporting + +Produce a single comparison table: + +``` +| Metric (256 routes, split) | Lazy | Precompute | Δ | +|-----------------------------------|-----------|------------|----------| +| CPU time median (s) | ... | ... | ...% | +| Wall median (s) | ... | ... | ...% | +| Peak RSS p95 (MB) | ... | ... | ...% | +| route:chunk totalMs | ... | ... | ...% | +| route:split-exports totalMs | ... | ... | ...% | +| micro: parse calls / route | ... | ... | ...% | +| micro: traverse calls / route | ... | ... | ...% | +| micro: generate calls / route | ... | ... | ...% | +| micro: structuredClone / route | ... | ... | ...% | +| micro: analyze mean ms / route | ... | ... | ...% | +| micro: heap delta (MB) | ... | ... | ...% | +``` + +Fill from real runs. A result is a **win** if CPU time and `route:chunk` +totalMs drop with no peak-RSS regression beyond the retained +`RouteChunkAnalysis` heap cost (quantified separately). + +--- + +## 8. Hygiene + +- Benchmark output lives under gitignored `.benchmark/`. Never commit results. +- Clean generated data with `rm -rf .benchmark/` — **not** `git clean -fdX`, + which also deletes `node_modules/` and `.tracedecay/` indexes. +- Start and end every comparison session with `git status --short`. +- Keep the fixture generator deterministic (no `Date.now()` / `Math.random()` + in route content) so lazy vs precompute run against byte-identical inputs. diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md new file mode 100644 index 0000000..86233eb --- /dev/null +++ b/benchmarks/manifest-performance-methodology.md @@ -0,0 +1,352 @@ +# Manifest-generation performance benchmark recipe + +Task: `t_6008a898` +Repo: `/home/zack/projects/rsbuild-plugin-react-router` +Head measured: `c2452de1393264c2b01ef8aa03908077bce025db` + +This document defines the reproducible commands and metric checklist for +measuring manifest-generation performance before and after the route-analysis / +manifest cache deduplication work. + +## Environment notes + +Use the same machine, branch, package manager, and Node version for both halves +of an A/B comparison. + +Measured head environment: + +- Branch: `perf/bundling-performance` +- Commit: `c2452de1393264c2b01ef8aa03908077bce025db` +- Node: `v22.22.3` +- pnpm: `9.15.3` +- Platform: `linux 6.8.0-124-generic x64` +- Rsbuild: `@rsbuild/core@2.0.15` +- Rspack: `@rspack/core@2.0.8` +- React Router packages: `7.13.0` +- Benchmark fixture size used for the baseline below: 256 routes plus the root + route, so route-level transforms report 257 calls per compiler environment. + +Fixture export-shape cycle from `scripts/benchmark/fixture.mjs`: + +```text +plain, ssr-data, split-client, split-client, ssr-data, client-server-imports +``` + +For 256 generated routes this yields: + +| Profile | Count | +| ------------------------------------------------------------ | ----: | +| plain | 42 | +| ssr-data | 86 | +| split-client | 86 | +| client-server-imports | 42 | +| splittable routes (`split-client` + `client-server-imports`) | 128 | + +## Existing benchmark harness + +The benchmark harness is `scripts/bench-builds.mjs`; package scripts are defined +in `package.json`: + +```sh +pnpm bench:smoke # 48-route smoke, 1 measured iteration +pnpm bench:baseline # 256-route default profile, 5 measured iterations +pnpm bench:full # 48/256/1024 route stress profile +``` + +The harness: + +1. builds the plugin package (`pnpm build`) unless `--skip-root-build` is passed; +2. generates deterministic fixtures under `.benchmark/fixtures/`; +3. runs `node node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs`; +4. sets `REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1`, enabling structured + `[react-router:performance]` plugin logs; +5. wraps builds in `/usr/bin/time -v` when available and records user/sys/RSS; +6. writes `.benchmark/results//baseline.json` and `baseline.md`. + +`rsbuild build --help` in this repo exposes `--log-level`, `--environment`, +`--mode`, and `--config`, but no dedicated benchmark/stats/profiling CLI flag. +Use the plugin `logPerformance` reports as the primary plugin-level source of +truth. If low-level Rspack stats are needed later, add them through fixture +`rsbuild.config.mjs`; do not depend on a non-existent CLI flag. + +## Pre-flight commands + +Run from the repo root: + +```sh +cd /home/zack/projects/rsbuild-plugin-react-router + +git status --short +git rev-parse HEAD +node --version +pnpm --version +pnpm install +pnpm build +``` + +Keep benchmark output under `.benchmark/`; it is gitignored. Do not use broad +`git clean -fdX` because it may delete `node_modules/` and TraceDecay indexes. + +## Primary benchmark commands + +Use the default 256-route profile for the canonical before/after comparison. It +includes the split fixture that exercises route-chunk/manifest analysis and the +non-split controls. + +Baseline/current behavior: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-baseline +``` + +Post-refactor behavior on the same branch/machine: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-after-cache-dedup +``` + +If the refactor is gated behind an environment flag, run both toggles on the +same commit instead: + +```sh +ROUTE_MANIFEST_CACHE_DEDUP=0 node scripts/bench-builds.mjs \ + --profile default --iterations 5 --warmup 1 --clean build --format both \ + --out .benchmark/results/manifest-dedup-off + +ROUTE_MANIFEST_CACHE_DEDUP=1 node scripts/bench-builds.mjs \ + --profile default --iterations 5 --warmup 1 --clean build --format both \ + --out .benchmark/results/manifest-dedup-on +``` + +For a quicker focused loop, isolate the split fixture: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --filter split \ + --iterations 3 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-split-smoke +``` + +For scaling validation after the refactor, use the full profile split fixtures: + +```sh +node scripts/bench-builds.mjs \ + --profile full \ + --filter split \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-scale +``` + +## Single-fixture command for manual debugging + +The harness command for each fixture build is: + +```sh +cd .benchmark/fixtures/synthetic-256-ssr-esm-split +REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1 NODE_ENV=production \ + /usr/bin/time -v \ + node /home/zack/projects/rsbuild-plugin-react-router/node_modules/@rsbuild/core/bin/rsbuild.js \ + build --config rsbuild.config.mjs --log-level info +``` + +Use this only for debugging logs. Use `scripts/bench-builds.mjs` for numbers +because it controls warmup, cleaning, aggregation, and output format. + +## Metric checklist + +### Already observable from `baseline.json` + +| Metric | Source | Why it matters | +| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Build wall time | `benchmarks[].summary.wallMs` | End-to-end user-visible build time. | +| CPU time | `summary.userMs` + `summary.sysMs` | Less noisy than wall time when the machine has minor scheduling variance. | +| Peak RSS | `summary.maxRssKb` | Ensures cache dedup does not regress memory. | +| Compiler lifecycle | each plugin report's `compilerLifecycleMs` | Plugin setup/build lifecycle timing per compiler environment. | +| Transform invocation counts | `pluginOperations[].count` | Counts route/manifest hook invocations. Counts should usually stay stable after dedup; timings should drop. | +| Transform cumulative time | `pluginOperations[].totalMs` | Primary signal for expensive plugin work moving out of duplicate paths. | +| Slowest transform | `pluginOperations[].maxMs` and `operations.*.slowest` in JSON | Catches per-route outliers hidden by totals. | + +Relevant existing operation buckets: + +- `manifest:transform`: virtual server/browser manifest module transform. +- `manifest:stage`: browser manifest staging callback in `modifyBrowserManifest`. +- `route:client-entry`: route client-entry transform; currently calls + `transformToEsm`, `getExportNames`, and, for web split builds, + `detectRouteChunksIfEnabled`. +- `route:split-exports`: route source rewrite for split-route modules; currently + calls `transformToEsm`, `detectRouteChunksIfEnabled`, and `getExportNames`. +- `route:chunk`: per-`?route-chunk=` transform; currently calls + `transformToEsm`, `getRouteChunkIfEnabled`, and, for enforce mode on `main`, + `getExportNames`. +- `route:module`: `?react-router-route` transform. +- `module:client-only-stub` and `module:server-only-guard`: import guard/stub + overhead, useful controls for unrelated plugin transform cost. + +### Add or instrument for the cache-dedup refactor + +The existing profiler is transform-bucket level. To prove manifest-generation +cache deduplication specifically, add direct counters around the lower-level +operations below, either as new `performanceProfiler.record*` operation names or +as a `counters` object in `ReactRouterPerformanceReport`. + +| Counter / metric | Suggested operation name | Expected baseline for 256-route default split build | Notes | +| --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------: | ------------------------------------------------------------------------------------------------------------------------ | +| Route-file stat calls | `manifest:route-stat` | 257 per build | `getRouteModuleAnalysis(resourcePath)` calls `stat` before cache lookup. Root + 256 routes. | +| Route-file reads | `manifest:route-read` | 257 per build on a cold build | Count the `readFile(resourcePath, 'utf8')` inside `getRouteModuleAnalysis` cache misses. | +| Route source transforms for manifest analysis | `manifest:route-transform-to-esm` | 257 per build on a cold build | Same cache-miss path as route reads. | +| Export extractions for manifest analysis | `manifest:route-export-extract` | 257 per build on a cold build | `getRouteModuleAnalysis` calls `getExportNames(code)` once per route-module analysis miss. | +| Manifest route analysis wall time | `manifest:route-analysis` | 257 samples; report total/mean/p95 | Wrap one route's `getRouteModuleAnalysis` + split detection inside `getReactRouterManifestForDev`. | +| Total manifest route-map wall time | `manifest:route-map` | 1 per manifest generation | Wrap the `Promise.all(Object.entries(routes).map(...))` block in `manifest.ts`. | +| Split-route detection calls from manifest | `manifest:route-chunk-detect` | 257 per split build | Only when `isBuild && routeChunkConfig`. Must drop duplicated work after dedup if manifest reuses cached route analysis. | +| Babel route-chunk parse calls | `route-chunk:parse` | currently at most 1 per `(route, code)` cache key, but direct count needed | Current code caches parse but still clones AST on each access; count parse separately from clone. | +| Babel route-chunk traverse calls | `route-chunk:traverse` | currently at most 1 per `(route, code)` cache key, but direct count needed | Wrap `getExportDependencies`. | +| AST structured clones | `route-chunk:structured-clone` | roughly 1 for dependency analysis + 1 per generated chunk for splittable modules | This is the expected direct win for RouteChunkAnalysis-style dedup. | +| Chunk code generations | `route-chunk:generate` | up to 5 per fully splittable route | Count `generate()` in `getChunkedExport` and `omitChunkedExports`. | +| Per-route analysis time | `manifest:route-analysis` / `route-chunk:analyze` slowest list | one resource entry per route | Keep `resource` as the route file path so `slowest` pinpoints outliers. | + +Acceptance rule: the refactor should reduce direct manifest/read/export-analysis +work or route-chunk analysis work without changing the externally visible route +transform invocation counts for the same fixture. If `pluginOperations[].count` +changes, explain why the module graph changed; otherwise compare `totalMs`, +`maxMs`, and direct counters. + +## Head baseline recorded on `c2452de` + +Command used: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-head-baseline +``` + +Output files: + +- `.benchmark/results/manifest-head-baseline/baseline.json` +- `.benchmark/results/manifest-head-baseline/baseline.md` + +Top-level summary: + +| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | p95 RSS | +| --------------------------- | -----: | ------------- | ----------: | --------: | -------: | ------: | +| synthetic-256-ssr-esm | 256 | ssr-esm | 1.56s | 1.58s | 1.67s | 485 MB | +| synthetic-256-ssr-esm-split | 256 | ssr-esm-split | 2.07s | 2.10s | 2.16s | 704 MB | +| synthetic-256-spa | 256 | spa | 6.53s | 6.56s | 6.62s | 476 MB | +| synthetic-256-sourcemaps | 256 | ssr-esm | 1.62s | 1.63s | 1.69s | 529 MB | + +Compiler lifecycle medians from the plugin reports: + +| Benchmark | web median | node median | +| --------------------------- | ---------: | ----------: | +| synthetic-256-ssr-esm | 1124.6ms | 1308.3ms | +| synthetic-256-ssr-esm-split | 1591.5ms | 1770.3ms | +| synthetic-256-spa | 1082.0ms | 1246.4ms | +| synthetic-256-sourcemaps | 1154.4ms | 1348.0ms | + +### Operation counts: `synthetic-256-ssr-esm-split` + +This is the primary manifest/cache-dedup comparison fixture because it enables +`future.v8_splitRouteModules`. + +| Environment | Operation | Total count (5 runs) | Per build | Total time | Max single | +| ----------- | -------------------------- | -------------------: | --------: | ---------: | ---------: | +| web | `route:chunk` | 1930 | 386.0 | 409899.2ms | 445.2ms | +| web | `route:client-entry` | 1285 | 257.0 | 363767.2ms | 445.9ms | +| web | `route:module` | 1285 | 257.0 | 1059.3ms | 7.8ms | +| node | `route:module` | 1285 | 257.0 | 453.6ms | 7.3ms | +| node | `manifest:transform` | 5 | 1.0 | 32.5ms | 7.3ms | +| node | `module:client-only-stub` | 5 | 1.0 | 21.4ms | 6.9ms | +| web | `route:split-exports` | 4595 | 919.0 | 0.8ms | 0.1ms | +| web | `module:client-only-stub` | 15 | 3.0 | 0.5ms | 0.1ms | +| node | `module:server-only-guard` | 10 | 2.0 | 0.0ms | 0.0ms | +| node | `route:split-exports` | 1390 | 278.0 | 0.0ms | 0.0ms | +| web | `manifest:stage` | 5 | 1.0 | 0.0ms | 0.0ms | +| web | `manifest:transform` | 5 | 1.0 | 0.0ms | 0.0ms | + +Baseline expectations for the same fixture after cache dedup: + +- `route:client-entry`, `route:module`, `route:split-exports`, and + `route:chunk` invocation counts should remain approximately the same because + the module graph and virtual modules are unchanged. +- `route:client-entry.totalMs` and `route:chunk.totalMs` are the hot buckets to + reduce. On head they dominate the split fixture: ~363.8s and ~409.9s summed + across five measured builds. +- Direct `manifest:route-read`, `manifest:route-export-extract`, and + `manifest:route-analysis` counters should show 257 route analyses per cold + build before dedup. If a new shared cache lets transform hooks and manifest + generation reuse one analysis result, the duplicated lower-level counters + should fall while the transform-level counts stay stable. +- Direct `route-chunk:structured-clone` should fall materially if the refactor + removes per-query AST cloning. + +### Control operation counts: `synthetic-256-ssr-esm` + +Use this as the non-split control. It should not materially change when the +split-route cache path changes. + +| Environment | Operation | Total count (5 runs) | Per build | Total time | Max single | +| ----------- | ------------------------- | -------------------: | --------: | ---------: | ---------: | +| web | `route:client-entry` | 1285 | 257.0 | 164444.8ms | 260.4ms | +| web | `route:module` | 1285 | 257.0 | 1076.2ms | 13.3ms | +| node | `route:module` | 1285 | 257.0 | 451.0ms | 7.7ms | +| node | `manifest:transform` | 5 | 1.0 | 28.4ms | 8.2ms | +| node | `module:client-only-stub` | 5 | 1.0 | 21.6ms | 7.9ms | +| node | `route:split-exports` | 1390 | 278.0 | 3.6ms | 3.6ms | +| web | `route:split-exports` | 2665 | 533.0 | 0.2ms | 0.1ms | +| web | `manifest:stage` | 5 | 1.0 | 0.0ms | 0.0ms | +| web | `manifest:transform` | 5 | 1.0 | 0.0ms | 0.0ms | + +## Comparison procedure + +1. Run the baseline and post-refactor commands back-to-back on the same machine. +2. Compare `synthetic-256-ssr-esm-split` first: + - wall median and p95; + - CPU median (`userMs + sysMs`); + - p95 RSS; + - `route:client-entry.totalMs`; + - `route:chunk.totalMs`; + - direct manifest/route-analysis counters added for the refactor. +3. Check `synthetic-256-ssr-esm` and `synthetic-256-sourcemaps` as controls. + Their route-chunk-specific direct counters should remain zero or unchanged. +4. Use `operations.*.slowest` in `baseline.json` to inspect outlier route files + if medians improve but max transform time regresses. +5. For a final report, include both absolute values and percentage deltas. + +Suggested report table: + +```text +| Metric (256 split fixture) | Before | After | Delta | +|---|---:|---:|---:| +| Wall median | 2.07s | ... | ... | +| CPU median (user+sys) | ... | ... | ... | +| Peak RSS p95 | 704 MB | ... | ... | +| route:client-entry totalMs | 363767.2ms | ... | ... | +| route:chunk totalMs | 409899.2ms | ... | ... | +| manifest route reads / build | 257 expected | ... | ... | +| manifest export extractions / build | 257 expected | ... | ... | +| route-chunk structuredClone calls / build | instrument | ... | ... | +| per-route analysis p95 | instrument | ... | ... | +``` diff --git a/package.json b/package.json index 9720181..881ceda 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "scripts": { "build": "rslib build", "bench": "node scripts/bench-builds.mjs", + "bench:compare": "node scripts/compare-benchmarks.mjs", "bench:smoke": "node scripts/bench-builds.mjs --profile smoke --iterations 1 --warmup 0 --format both --out .benchmark/results/smoke", "bench:baseline": "node scripts/bench-builds.mjs --profile default --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/baseline", "bench:full": "node scripts/bench-builds.mjs --profile full --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/full", diff --git a/performance-timing-semantics-analysis.md b/performance-timing-semantics-analysis.md new file mode 100644 index 0000000..f631721 --- /dev/null +++ b/performance-timing-semantics-analysis.md @@ -0,0 +1,149 @@ +# Profiler Timing Semantics & Concurrency Overcount Analysis + +**Task:** t_f5a0df72 — Decide profiler operation timing semantics and overcount risk +**Scope:** `src/performance.ts` and its 8 call sites in `src/index.ts`. Analysis only — no code changes. +**Branch:** perf/bundling-performance @ c2452de + +--- + +## 1. What the profiler measures today + +`createReactRouterPerformanceProfiler` exposes three methods: + +| Method | Clock | Wraps | Suspends? | +| --------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------- | +| `record(env, op, resource, () => Promise)` | `performance.now()` wall-clock: `start` before callback, delta captured in `.finally()` | an async callback | **Yes** — the callback `await`s off-thread work | +| `recordSync(env, op, resource, () => T)` | `performance.now()` wall-clock: `start` before, delta in `finally` | a sync callback | No | +| `flush(env, { compilerLifecycleMs })` | — | emits one JSON report per environment | — | + +Every measurement is a **wall-clock delta** (`performance.now()`). Nothing attempts CPU-exclusive accounting. `record` measures start→settle; `recordSync` measures start→return. + +`compilerLifecycleMs` (set in `index.ts:481-484`) is a single wall-clock span from `setupStartMs` (`performance.now()` at plugin setup, `index.ts:132`) to `onAfterEnvironmentCompile`. It is the **one authoritative end-to-end wall time** and is never summed, so it carries no internal double-count. + +### The 8 call sites (all in `src/index.ts`) + +| # | Op name | Method | Line | Hook trigger | Async waits in body | +| --- | -------------------------- | ------------ | ---- | --------------------------------------------------------------- | ------------------------------------------------------------------- | +| 1 | `manifest:stage` | `recordSync` | 1263 | `onManifest` callback (sync) | none (sync) | +| 2 | `manifest:transform` | `record` | 1329 | `api.transform` test: virtual manifest | `getReactRouterManifestForDev` (I/O) | +| 3 | `route:client-entry` | `record` | 1372 | `api.transform` resourceQuery: build-client-route | `transformToEsm`, `getExportNames`, `detectRouteChunksIfEnabled` | +| 4 | `route:chunk` | `record` | 1419 | `api.transform` resourceQuery: route-chunk= | `transformToEsm`, `parse` | +| 5 | `route:split-exports` | `record` | 1481 | `api.transform` test: `/.[cm]?[jt]sx?$/` (**every JS/TS file**) | `transformToEsm`, `detectRouteChunksIfEnabled`, `getExportNames` | +| 6 | `module:server-only-guard` | `record` | 1557 | `api.transform` test: `.server` files | none real — body throws/returns synchronously | +| 7 | `module:client-only-stub` | `record` | 1579 | `api.transform` test: `.client` files | `transformToEsm`, `getExportNamesAndExportAll`, recursive `resolve` | +| 8 | `route:module` | `record` | 1742 | `api.transform` resourceQuery: `?react-router-route` | `transformToEsm`, `getExportNames` | + +The async helpers (in `src/export-utils.ts`) are the suspension points: + +- `transformToEsm` → `esbuild.transform()` — **off-thread** (esbuild runs in a child thread/process); a genuine wait that yields the event loop. +- `getExportNames` → `es-module-lexer` `init` (WASM, async first call) + `parseExports` (sync). Yields at least one microtask. +- `getReactRouterManifestForDev`, `detectRouteChunksIfEnabled` → async I/O / cached analysis. + +--- + +## 2. The concurrency overcount mechanism + +All 7 `record()` sites are `api.transform()` hooks = **per-module** transforms. Rsbuild/Rspack processes the module graph with many modules in flight; the JS transform callbacks share the single Node.js event loop and **interleave at `await` points**. + +When module A's transform `await`s `esbuild.transform()` (off-thread), control returns to the event loop and module B's transform starts and runs. Both A's and B's `performance.now()` spans are "ticking" simultaneously: + +``` +event loop timeline ─────────────────────────────────────────► +A span: [████ await(esbuild A) ░░░░ run B's sync ░░░ ████ resume A ████] +B span: [██ run sync ░░░ await(esbuild B) ░░░ resume B ██] + ▲ overlap region ▲ +``` + +Each span's wall delta includes the **overlap region**. Effects on the aggregate fields in `OperationTiming`: + +- **`totalMs`** (sum of per-resource wall deltas) **overcounts.** Summing overlapping intervals bills the overlap to both operations. With N route modules transformed concurrently, `totalMs` for `route:module` can approach `N × (per-module wall)` instead of the true serial cost; in the worst case `Σ totalMs` across all operations **exceeds `compilerLifecycleMs`**, which is a physical impossibility for non-overlapping work — the giveaway that double-counting occurred. +- **`maxMs` and `slowest[]`** are **accurate per-resource** — they are single end-to-end wall deltas for one resource, never summed, so they carry no internal double-count. They remain valid for "which single resource is slowest." +- **`count`** is **accurate** — it is incremented once per invocation regardless of overlap. + +No `record()` callback contains an internal `Promise.all` over multiple modules (verified: the only `Promise.all` call sites are in `build-manifest.ts`, `manifest.ts`, `react-router-config.ts`, and `index.ts:977` — none inside a transform hook body). So the overlap is **sibling (peer) overlap between different modules**, not parent/child nesting within one span. + +--- + +## 3. Recommendation — what to report + +**Report BOTH wall-clock and a concurrency-aware "exclusive" aggregate, each clearly labeled, and make `compilerLifecycleMs` the headline total.** They answer different questions and neither alone is sufficient: + +| Metric | Question it answers | Verdict | +| ------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `compilerLifecycleMs` (wall, single span) | "How long did the user wait for this build?" | **Keep — authoritative total.** Promote it as the headline number. | +| `maxMs` / `slowest[]` (wall, per-resource) | "Which individual module is the worst offender?" | **Keep as-is — accurate, no double-count.** This is the most actionable field. | +| `count` | "How many modules hit this transform?" | **Keep — accurate.** | +| `totalMs` (sum of wall spans) | "What is this operation class's total cost?" | **Misleading as written** — overcounts under concurrency. Either rename to `totalWallMs` with an explicit caveat, or replace with an interval-aware aggregate (see below). | +| **NEW: `exclusiveMs` / `wallMs`** | "How much real serial time did this operation consume, deduped against overlap?" | **Add** — gives a cost number you can actually sum and compare. | + +**Why not "exclusive CPU only"?** Most of the wall time in these spans is **wait** on esbuild/Rspack threads (off-process), not synchronous JS CPU. An "exclusive CPU" metric would systematically understate the operations that actually dominate build time (the esbuild transforms), giving a false picture. The useful split is _wall-clock-per-resource_ (already correct) vs _concurrency-deduped aggregate_ (missing), not _CPU-vs-wall_. + +--- + +## 4. Practical approach for the concurrency-aware aggregate + +Ranked by practicality for this plugin. + +### Recommended: interval-union accounting in `flush()` (Option D) + +Store each `record()` span as a `[start, end]` interval keyed by `(environment, operation)`. At `flush()`, run a sweep-line: + +1. Sort the intervals for each operation by start. +2. Merge overlapping intervals into disjoint ranges; sum their lengths → **`wallMs`** = distinct wall time this operation occupied (deduped against its _own_ overlapping resources). +3. Optionally, for a cross-operation view, do the same sweep over **all** operations' intervals together and compare the union length to `compilerLifecycleMs` to report an **overcount ratio** (`Σ totalMs / unionWallMs`). + +Why this fits: all needed data (start/end per resource) is **already captured** — `record` already calls `performance.now()` twice. The change is to persist the interval instead of immediately collapsing to a scalar in `recordDuration`, then compute the union once at flush. Memory cost is O(total module × operation invocations), bounded and fine for builds with a few thousand modules. No per-`await` instrumentation needed; the 7 call sites stay untouched. + +``` +// sketch (not applied — analysis only) +type Interval = [start: number, end: number]; +// store intervalsByEnv: Map> +// in flush: sort + merge + sum → wallMs; report overcount = totalMs / wallMs +``` + +### Fallback: span-tree self-time subtraction (Option C) + +Use `AsyncLocalStorage` to maintain a stack of active spans; when a child span starts under an active parent, subtract the child's duration from the parent's "self" time (standard OpenTelemetry self-time). **Caveat:** this only fixes _parent/child nesting_; it does **not** fix sibling overlap, and here the dominant overcount is sibling overlap (two independent modules). So Option C alone is insufficient for this plugin. Use it only if you also want per-span self attribution alongside Option D. + +### Not recommended: `process.cpuUsage()` deltas (Option A) + +`process.cpuUsage()` is process-global and sampled per-span, but on a single-threaded event loop the CPU time between a span's start and end includes CPU time spent on _other_ interleaved spans' synchronous code — it attributes no better than `performance.now()` for overlapping spans. Worse, it would **undercount** the real cost drivers (esbuild/Rspack run in separate threads/processes, so their CPU time is invisible to the JS process's `cpuUsage`). It is useful for exactly one thing: a **process-level CPU-utilization sanity check** (`cpuUsage total / compilerLifecycleMs`) to show how much of the build wall time was JS-process CPU vs waiting. Use it for that ratio only, never for per-span attribution. + +### Not recommended: bracket every `await` (Option B) + +Manually accumulate on-CPU time across sync segments, stopping at each `await` suspension. Requires instrumenting multiple await points across 7 call sites — invasive, fragile, high maintenance. Skip. + +--- + +## 5. Documentation paragraph (ready to paste) + +> **Timing semantics — concurrency overcount caveat.** +> Operation timings reported by this profiler are measured with `performance.now()` wall-clock deltas: each `record()` call captures the interval from when an async transform callback starts to when its returned promise settles. Because Rsbuild/Rspack processes many modules concurrently and the per-module transform callbacks interleave on the Node.js event loop at `await` points (notably `esbuild.transform()` and `es-module-lexer` parsing), the wall-clock spans of different modules **overlap in time**. As a result, `totalMs` — the sum of per-resource wall deltas for an operation — **double-counts overlapping wait time** and can exceed the actual serial cost of that operation; summed across all operations it can even exceed `compilerLifecycleMs`, the single authoritative end-to-end build wall time. Treat `totalMs` as an upper bound on cost, not a precise attribution. The fields that remain accurate regardless of concurrency are `count` (invocations), `maxMs` (worst single resource), and `slowest[]` (per-resource wall deltas), because these are never summed across resources. `compilerLifecycleMs` is the ground-truth total wall time. When you need a concurrency-safe cost number that can be summed across operations, use the interval-union `wallMs` aggregate instead of `totalMs`. + +--- + +## 6. High-risk operations for overcount + +Risk = (resource count, i.e. how many modules trigger it) × (number/depth of genuine async suspension points, i.e. how much wall time is interleavable wait). + +| Op name | Risk | Why | +| ------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `route:split-exports` | **Very high** | Triggered by `test: /\.[cm]?[jt]sx?$/` — matches **every** JS/TS/JSX/TSX file in the build, not just routes. Highest `count` of any op. Body has 3 sequential awaits (`transformToEsm` → `detectRouteChunksIfEnabled` → `getExportNames`), each a suspension point. Maximum modules × maximum awaits = maximum overlap, so `totalMs` inflates the most here. | +| `route:module` | **High** | One per route module (`?react-router-route` query). Awaits `transformToEsm` (off-thread esbuild) + `getExportNames`. Many route modules transformed concurrently → many overlapping spans. | +| `route:client-entry` | **High** | One per client route module. Three awaits including off-thread `transformToEsm`. Same inter-module overlap pattern as `route:module`. | +| `route:chunk` | **Medium-high** | One per route-chunk export. Awaits `transformToEsm` + `parse`. Fewer resources than `route:module` (only when `splitRouteModules` is on), but still per-chunk concurrency. | +| `module:client-only-stub` | **Medium** | Few resources (`.client` modules are rare), but each span is long with many awaits (`transformToEsm`, `getExportNamesAndExportAll`, recursive synchronous `resolve` with `statSync`/`existsSync` bursts). Per-span wall is large, so even modest overlap distorts `totalMs`. | +| `manifest:transform` | **Medium-low** | Matches only virtual manifest resources (browser + per-bundle server) → very low `count`, so little _intra-operation_ overlap. But its `getReactRouterManifestForDev` await (I/O) overlaps with route transforms, so it contributes to _cross-operation_ overcount when sums are compared. | +| `module:server-only-guard` | **Low** | Callback body is effectively synchronous — it either throws immediately (web) or returns synchronously (node). No real `await` suspension, so spans are ~0 ms and do not meaningfully overlap. | +| `manifest:stage` (`recordSync`) | **None** | Synchronous by construction (`recordSync`). Wall-clock ≈ CPU; no concurrency, no overcount. | + +**Bottom line:** the three broad-trigger per-module transforms — `route:split-exports`, `route:module`, and `route:client-entry` — are where `totalMs` diverges most from real cost, because they combine high invocation counts with multiple off-thread await points. These are the operations that most need the interval-union `wallMs` treatment (Section 4) and whose `totalMs` should carry the explicit caveat in any report. + +--- + +## 7. Summary of deliverables + +1. **Recommendation:** Report both — keep wall-clock per-resource diagnostics (`maxMs`, `slowest`, `count`) and the authoritative `compilerLifecycleMs` total; add a concurrency-aware aggregate (`wallMs` via interval-union) to replace the misleading `totalMs` for any cross-operation or cost-summing use. Do **not** pursue CPU-exclusive-only measurement (it would hide the esbuild/Rspack wait that actually dominates build time). +2. **Exclusive-ish approach:** Interval-union accounting computed in `flush()` from already-captured `[start,end]` spans (Option D) — accurate, no await instrumentation, 7 call sites untouched. `process.cpuUsage()` only for an optional process-level CPU-utilization ratio, never per-span. +3. **Documentation paragraph:** Section 5 above, ready to paste as a code comment in `performance.ts` or a README section. +4. **High-risk ops:** `route:split-exports` (very high), `route:module` (high), `route:client-entry` (high), `route:chunk` (medium-high), `module:client-only-stub` (medium); `manifest:transform` (medium-low, cross-op only); `module:server-only-guard` (low); `manifest:stage` (none, sync). diff --git a/route-analysis-duplication-audit.md b/route-analysis-duplication-audit.md new file mode 100644 index 0000000..0865014 --- /dev/null +++ b/route-analysis-duplication-audit.md @@ -0,0 +1,356 @@ +# Route Analysis Duplication Audit + +Branch: `perf/bundling-performance` @ `c2452de` +Scope: every place a **route module file** is read from disk, parsed/transformed, +or mined for exports/metadata across the dev + build pipeline. +Companion to `route-chunk-parse-traverse-analysis.md` (which covers +`src/route-chunks.ts` internals in depth). + +--- + +## 1. Method & scope + +Cross-referenced four target files plus their shared helpers: + +| File | Role | +| -------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `src/export-utils.ts` | The only module that reads route files from disk; owns the transform + export-extraction caches. | +| `src/route-chunks.ts` | Babel parse/traverse/generate for route-chunk splitting (see companion doc). | +| `src/manifest.ts` | `getReactRouterManifestForDev` — per-route export analysis + chunk-metadata mapping. | +| `src/modify-browser-manifest.ts` | Rspack `emit` hook that (re)runs manifest generation + computes SRI over **built assets**. | +| `src/build-manifest.ts` | Server-bundle routing. **Does NOT read route files** — only path/id strings. | +| `src/index.ts` | Bundler `api.transform` hooks (the in-memory code path) + prerender validation + SRI/manifest staging. | + +Two fundamentally different code sources feed the same analysis primitives: + +- **Pipeline A — disk-read path** (`getRouteModuleAnalysis`): `stat → readFile(path) → transformToEsm(source) → getExportNames(code)`. Used by manifest generation and prerender validation. +- **Pipeline B — bundler-transform path** (`api.transform` hooks): receives `args.code` from the bundler (in-memory), calls `transformToEsm(args.code)` + `getExportNames(code)` + `detectRouteChunksIfEnabled`/`getRouteChunkIfEnabled` directly. + +--- + +## 2. Cache layers (the deduplication substrate) + +There are **four** independent caches. Understanding them is prerequisite to judging +what is actually duplicated vs. already-shared. + +### 2a. `export-utils.ts` — module-level, process-wide, shared across A and B + +| Cache | Location | Key | Version / invalidation | Bound | +| -------------------------- | ----------------------- | --------------------------------- | ---------------------------------------------- | ------------ | +| `transformCache` | `export-utils.ts:24` | `resourcePath` | input `code` string (`cached.source === code`) | 2048 (`:30`) | +| `exportNamesCache` | `export-utils.ts:25` | `code` string (content-addressed) | n/a (key IS the content) | 2048 | +| `routeModuleAnalysisCache` | `export-utils.ts:26-29` | `resourcePath` | `mtimeMs` + `size` from `stat()` | 2048 | + +`routeModuleAnalysisCache` wraps `transformToEsm` + `getExportNames` + the raw +`readFile`/`source`. It is the **only** consumer that pays `stat()` + `readFile()`. +The bundler path (Pipeline B) bypasses it entirely and hits `transformCache` + +`exportNamesCache` directly. + +### 2b. `route-chunks.ts` — per-build, passed by reference (`routeChunkCache`) + +Declared once per plugin invocation at `index.ts:403` +(`const routeChunkCache: RouteChunkCache = new Map()`), threaded into +`routeChunkOptions.cache` (`index.ts:408`) and every `*IfEnabled` call. +Keyed by `normalizeRelativeFilePath(id)` (`route-chunks.ts:826`, query string +stripped) + sub-key discriminator; versioned by the exact `code` string. +See companion doc §2/§5 for the full sub-key table. + +**Cross-cache consequence:** Pipeline A and Pipeline B share the _lower_ caches +(`transformCache`, `exportNamesCache`) but Pipeline A additionally owns +`routeModuleAnalysisCache`. For a route-chunk cache _hit_ to occur across the two +pipelines, the `code` they feed to `detectRouteChunksIfEnabled` must be byte-identical +(see §6, finding F-3). + +--- + +## 3. Per-code-path inventory: route-file → operations → call-sites + +Notation: R = read from disk, T = esbuild transform, L = lexer export extract, +B = Babel parse/traverse/generate (route-chunks), X = other extract. + +### 3a. Manifest generation — `getReactRouterManifestForDev` (`manifest.ts:110`) + +Per route, inside `Promise.all` over `routes` (`manifest.ts:163`): + +| Step | Line | Op | Primitive | +| ---------------------------- | ---------- | ------------------------- | ---------------------------------------------------- | +| resolve route file path | `:170` | — | `resolve(context, route.file)` | +| read + transform + extract | `:190` | R, T, L | `getRouteModuleAnalysis(routeFilePath)` | +| dev CSS fallback | `:191-199` | X (regex on raw `source`) | `/\.css.../ .test(source)` | +| chunk detection (build only) | `:204` | B | `detectRouteChunksIfEnabled(cache, cfg, path, code)` | +| chunk module-path mapping | `:249-272` | — | `getModulePathForChunk(getRouteChunkEntryName(...))` | + +**Needs from the file:** `source` (raw, for dev CSS regex), `code` (transformed, +for chunk detection), `exports` (full list → `hasAction`/`hasLoader`/`hasClient*`/ +`hasDefault`/`hasErrorBoundary` booleans), and chunk booleans → asset paths. + +Called from **3** sites (each iterates ALL routes): + +- `index.ts:869` — prerender block (`if (isPrenderEnabled)`) +- `index.ts:1352` — virtual server-manifest transform fallback (when `latestServerManifest` is null) +- `modify-browser-manifest.ts:39` — Rspack `emit` hook (web compilation) + +### 3b. Prerender export validation — `validateSsrFalsePrerenderExports` (`index.ts:733`) + +| Step | Line | Op | Primitive | +| ------------------ | ------ | ------- | ------------------------------------------------------------ | +| read route exports | `:761` | R, T, L | `getRouteModuleExports(filePath)` → `getRouteModuleAnalysis` | + +**Needs:** the **full export-name list** per route (`exports.includes('headers'|'action'|'loader')`, +`index.ts:769-782`). This runs _inside_ the prerender flow that already called +`getReactRouterManifestForDev` at `:869` — so the same route files are analyzed +twice in one prerender pass (second call is a `routeModuleAnalysisCache` hit, but +still pays `stat()` per route). + +### 3c. Client-entry transform — `?__react-router-build-client-route` (`index.ts:1367`) + +| Step | Line | Op | Primitive | +| --------------------------------- | ------- | --- | --------------------------------------------------------------------------- | +| transform | `:1377` | T | `transformToEsm(args.code, args.resourcePath)` | +| export extract | `:1378` | L | `getExportNames(code)` | +| chunk detection (build, web only) | `:1383` | B | `detectRouteChunksIfEnabled(routeChunkCache, cfg, args.resourcePath, code)` | + +**Needs:** export names to filter `CLIENT_ROUTE_EXPORTS`/`SERVER_ONLY_ROUTE_EXPORTS` +reexports (`:1392-1403`); `chunkedExports` to drop chunked names from reexports. + +### 3d. Route-chunk transform — `?route-chunk=` (`index.ts:1414`) + +| Step | Line | Op | Primitive | +| ------------------------------- | ------- | --- | ----------------------------------------------------------------------------------------- | +| transform | `:1442` | T | `transformToEsm(args.code, args.resourcePath)` | +| chunk generate | `:1446` | B | `getRouteChunkIfEnabled(routeChunkCache, cfg, args.resourcePath, chunkName, transformed)` | +| enforce validation (main chunk) | `:1455` | L | `getExportNames(chunk)` — over **generated** chunk code | + +**Needs:** the generated chunk body (`chunk`) to emit as module source; export names +of the _generated_ main chunk to validate enforce-split invariants (`:1454-1466`). +Fires once per chunk (main + N named) per route module. + +### 3e. Split-exports transform — `test /\.[cm]?[jt]sx?$/` (`index.ts:1476`) + +| Step | Line | Op | Primitive | +| --------------- | ------- | --- | ---------------------------------------------- | +| transform | `:1504` | T | `transformToEsm(args.code, args.resourcePath)` | +| chunk detection | `:1509` | B | `detectRouteChunksIfEnabled(...)` | +| export extract | `:1519` | L | `getExportNames(transformed)` | + +**Needs:** `hasRouteChunks` + `chunkedExports` to decide whether to rewrite the module +into reexports (`:1515-1547`); full export list to split main vs. chunked reexports. + +### 3f. `.client` stub transform — `test /\.client/` (`index.ts:1574`, node env only) + +| Step | Line | Op | Primitive | +| --------------------------- | ------- | ------- | ----------------------------------------------------------------------------------- | +| transform | `:1588` | T | `transformToEsm(args.code, args.resourcePath)` | +| export + export-all extract | `:1590` | L | `getExportNamesAndExportAll(code)` | +| recursive re-export walk | `:1677` | R, T, L | `readFile` + `transformToEsm` + `getExportNamesAndExportAll` per re-exported module | + +**Scope note:** operates on `.client` modules, **not route modules**. Included for +completeness because it is the only other place that does `readFile` + +`transformToEsm` + export extraction. The recursive `readFile` walk (`:1670-1699`) +is unique to this path and re-reads arbitrary dependency files. + +### 3g. SRI computation — `createModifyBrowserManifestPlugin` (`modify-browser-manifest.ts:103-124`) + +| Step | Line | Op | +| -------------------- | ---------- | --------------------------------------------- | +| hash built JS assets | `:116-122` | `createHash('sha384').update(asset.source())` | + +**Scope note:** reads **built bundle assets** (`compilation.assets`), NOT route source +files. Not a route-analysis duplication. The `onManifest(manifest, sri)` staging +callback (`index.ts:1262-1295`) just attaches `sri` to the already-computed manifest +and shards it per server bundle — no file reads. + +### 3h. `build-manifest.ts` — `getBuildManifest` (`:60`) / `getRoutesByServerBundleId` (`:149`) + +**No route-file reads, transforms, or export extraction.** Pure path/id manipulation: +resolves `route.file` (`:89`, `:112`), normalizes to root-relative (`:92`), and calls +the user-supplied `serverBundles({ branch })` function (`:108`). Routes are carried as +string metadata only. Listed here to **exclude** it from the duplication set. + +--- + +## 4. Route-file → operations → call-sites (consolidated table) + +For a single route module `R.tsx` with main + 2 chunkable exports, one production +build (splitRouteModules enabled, prerender enabled), the operations on `R.tsx`: + +| # | Call-site (file:line) | Pipeline | R | T | L | B-parse | B-traverse | B-generate | What it needs | +| --- | ------------------------------------------- | -------- | --- | --- | --- | ------- | ---------- | ---------- | ---------------------------------------- | +| 1 | `manifest.ts:190` (manifest gen ×3 callers) | A | ✓ | ✓ | ✓ | — | — | — | source (CSS), code, exports, chunk bools | +| 2 | `index.ts:761` (prerender validation) | A | ✓\* | ✓\* | ✓\* | — | — | — | full export list | +| 3 | `index.ts:1504` split-exports transform | B | — | ✓ | — | ✓ | ✓ | — | hasRouteChunks, chunkedExports, exports | +| 4 | `index.ts:1377` client-entry transform | B | — | ✓ | ✓ | ✓ | ✓ | — | chunkedExports, exports | +| 5 | `index.ts:1442` route-chunk `main` | B | — | ✓ | — | ✓ | ✓ | ✓ | generated main chunk body | +| 6 | `index.ts:1442` route-chunk `clientAction` | B | — | ✓ | — | ✓ | — | ✓ | generated named chunk body | +| 7 | `index.ts:1442` route-chunk `clientLoader` | B | — | ✓ | — | ✓ | — | ✓ | generated named chunk body | + +`*` = served from `routeModuleAnalysisCache` (mtime+size hit) — no actual `readFile`, +but `stat()` still runs. + +**Effective cost per cold route module (main + 2 chunks), thanks to caching:** + +- `readFile`: 1× (Pipeline A, cached thereafter) +- esbuild `transform`: 1× (`transformCache`, path+source keyed — shared across A & B + **iff** disk source === bundler `args.code`) +- lexer export extract: 1× (`exportNamesCache`, content-keyed) +- Babel `parse`: 1× (route-chunks `codeToAst`) +- Babel `traverse`: 1× (`getExportDependencies`) +- Babel `generate`: 3× (one per chunk — inherently per-chunk, see companion doc §4) +- `structuredClone`: 4× (companion doc §3a/§4 — the known redundant hot spot) + +--- + +## 5. Duplication findings + +Each finding: what is duplicated, the consumers, and whether it is safe to +consolidate or genuinely diverges. + +### F-1 — Export-name list extracted redundantly; manifest keeps only booleans + +**Sites:** `manifest.ts:190` (→ booleans), `index.ts:761` (→ full list), `index.ts:1378`, +`index.ts:1519`, `index.ts:1455` (generated chunk). +**Duplication:** the full export-name set for a route is computed by +`getExportNames`/`getRouteModuleAnalysis` in 4 separate call-sites for the _same_ +module source. The `exportNamesCache` (content-keyed) makes the lexer parse itself +run once, but each site issues the async call and pays a `Map` lookup. +**Divergence:** `manifest.ts` **discards** the list, storing only +`hasAction`/`hasLoader`/`hasClient*`/`hasDefault`/`hasErrorBoundary` booleans +(`manifest.ts:216-279`). The prerender validator (`index.ts:769-782`) needs names the +manifest does not carry (`headers`, raw `loader`), forcing a **second full pass** over +all route files (`index.ts:758-762`) that runs right after manifest generation +(`index.ts:869`). +**Consolidation:** SAFE to thread the full export-name list (or the `RouteModuleAnalysis`) +out of `getReactRouterManifestForDev` so `validateSsrFalsePrerenderExports` reuses it +instead of re-calling `getRouteModuleExports`. Eliminates the `:758-762` pass entirely. + +### F-2 — Manifest generation runs up to 3× per build, each iterating all routes + +**Sites:** `index.ts:869` (prerender), `index.ts:1352` (server-manifest transform +fallback), `modify-browser-manifest.ts:39` (emit hook). +**Duplication:** each invocation iterates `Object.entries(routes)` and calls +`getRouteModuleAnalysis` per route (`manifest.ts:163-190`). `routeModuleAnalysisCache` +(mtime+size keyed) absorbs the redundant `readFile`/`transform`/`extract` on the 2nd +and 3rd runs, but every route still pays `stat()` (`export-utils.ts:133`) per call, and +the whole `Promise.all` + chunk-detection + jsesc serialization repeats. +**Consolidation:** PARTIALLY SAFE. The emit-hook result (`modify-browser-manifest.ts:39`) +is already staged into `latestServerManifest` via `onManifest` (`index.ts:1262-1295`). +The server-manifest transform (`index.ts:1352`) already prefers that staged value and +only falls back to re-generation when it is absent. The prerender call (`index.ts:869`) +runs in `onAfterBuild` **before** the web `emit` hook has necessarily staged the +manifest, so it currently cannot reuse it. Ordering the prerender validation after the +manifest is staged (or capturing the manifest once and passing it down) would remove +one full generation. Investigate build-phase ordering before changing. + +### F-3 — Two code sources for the same route file (disk vs bundler) + +**Sites:** Pipeline A feeds `code = readFile(path)` (`export-utils.ts:140`); +Pipeline B feeds `code = args.code` (bundler-supplied, e.g. `index.ts:1377,1442,1504`). +**Duplication:** `transformToEsm` is invoked from both pipelines for the same path. +The `transformCache` is keyed by `resourcePath` and versioned by the input `code` +string (`export-utils.ts:56-59`), so: + +- if `args.code === diskSource` → cache **hit**, esbuild runs once (good); +- if they differ (preceding loader normalization, source-map injection, line-ending + changes) → cache **miss** that **overwrites** the entry, and the route-chunks cache + (versioned by `code`, `route-chunks.ts`) silently re-parses/re-traverses. + **Divergence:** correctness-relevant, not just performance. The equality of the two + code strings is **assumed, never asserted** (companion doc §5). Pipeline A also needs + the **raw `source`** for the dev CSS fallback (`manifest.ts:191-199`), which Pipeline B + does not have and does not replicate. + **Consolidation:** DO NOT collapse blindly. Safe hardening: have Pipeline A accept the + already-transformed `code` from the bundler when available (avoiding the separate + disk read), and make the code-source contract explicit. The raw-`source` dependency + (dev CSS regex) must be preserved or replaced with a transformed-code check. + +### F-4 — Dev CSS fallback uses raw source; nothing else does + +**Site:** `manifest.ts:191-199`. +**What it needs:** the **raw `source`** string to regex-test for `.css/.less/.sass/.scss` +import literals and synthesize a fallback asset path in dev (when `cssAssets` is empty). +**Divergence:** this is the **only** consumer of `RouteModuleAnalysis.source`. Every +other consumer uses `code` or `exports`. If Pipeline A were rewritten to skip the disk +read (F-3), this fallback would lose its input unless the CSS check is moved onto the +transformed `code` (esbuild preserves `import './x.css'` statements in ESM output, so a +transformed-code regex would work and remove the raw-source dependency entirely). +**Consolidation:** SAFE to migrate the regex onto `code` (transformed ESM), which then +unblocks dropping the raw `source` from the analysis shape. + +### F-5 — `transformToEsm` called in every transform hook (deduped, but noisy) + +**Sites:** `index.ts:1377, 1442, 1504, 1588`. +**Duplication:** each of the 4 transform hooks independently calls +`transformToEsm(args.code, args.resourcePath)`. All hit the same `transformCache` +(path+source keyed), so esbuild runs at most once per unique source per path. Not a +runtime duplicate, but a **call-site** duplicate: 4 places to maintain the same +"transform then analyze" prelude. +**Consolidation:** SAFE (refactor-only, no behavior change) to extract a shared +"analyze route module from bundler args" helper returning `{code, exports, +chunkInfo}`. Low priority — purely structural. + +### F-6 — `detectRouteChunksIfEnabled` called from 3 sites (fully deduped) + +**Sites:** `manifest.ts:204`, `index.ts:1383`, `index.ts:1509`. +**Duplication:** none at runtime — `routeChunkCache` (path+code keyed) makes the first +call cold and the rest warm (companion doc §4, sites #2/#3 are cheap warm reads). +**Consolidation:** NOT NEEDED. Already optimal; documented for completeness. + +### F-7 — `.client` stub transform re-reads dependency modules from disk + +**Site:** `index.ts:1670-1699` (recursive `collectExportNamesFromModule`). +**Duplication:** `readFile` + `transformToEsm` + `getExportNamesAndExportAll` per +re-exported module. The top-level `.client` module's transform/extract are deduped by +`transformCache`/`exportNamesCache`, but the **recursive walk** over `export *` +targets (`:1677`) reads each dependency fresh with no `routeModuleAnalysisCache`-style +mtime cache — every build re-stats and re-reads every transitively re-exported file. +**Scope:** `.client` modules, not route modules. **Consolidation:** SAFE (orthogonal +optimization) to add an mtime+size cache mirroring `routeModuleAnalysisCache` for the +recursive walk, or to reuse `getRouteModuleAnalysis` for the leaf reads. Separate from +the route-file duplication set but the highest-uncached I/O in the neighborhood. + +--- + +## 6. Summary: safe-to-consolidate vs. diverges + +| Finding | Duplicate? | Safe to consolidate? | Notes | +| ---------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------- | +| F-1 export list (manifest keeps booleans only) | Yes (call) | **YES** — thread the list/analysis out of manifest gen to prerender validator | Removes the `index.ts:758-762` pass | +| F-2 manifest gen ×3 | Yes (stat + serialize) | **PARTIAL** — depends on build-phase ordering; emit hook already staged via `onManifest` | Prerender call (`:869`) is the hard one | +| F-3 dual code source (disk vs bundler) | Conditional | **NO (blindly)** — make the contract explicit; raw-source dependency (F-4) blocks it | Correctness risk: silent cache misses | +| F-4 dev CSS fallback on raw `source` | Diverges | **YES** — move regex onto transformed `code` | Unblocks F-3 | +| F-5 `transformToEsm` in 4 hooks | Call-site only | **YES** (refactor) — structural, no perf gain | Low priority | +| F-6 `detectRouteChunksIfEnabled` ×3 | No (cached) | **NO** — already optimal | — | +| F-7 `.client` recursive re-reads | Yes (no mtime cache) | **YES** — orthogonal; add mtime cache or reuse `getRouteModuleAnalysis` | Not route files | + +**Recommended consolidation order** (each unblocks the next): + +1. **F-4** — migrate the dev CSS regex from raw `source` to transformed `code`. Removes + the only consumer of `RouteModuleAnalysis.source`. +2. **F-1** — expose the full export list from `getReactRouterManifestForDev` (or return + the per-route `RouteModuleAnalysis`) so prerender validation reuses it. Deletes the + `index.ts:758-762` re-extraction pass. +3. **F-3** — with F-4 done, Pipeline A can accept transformed `code` from the bundler + and drop the separate disk read, making the route-chunks cache version match + deterministically. Assert `args.code === diskSource` in dev as a guard. +4. **F-2** — investigate whether the prerender manifest call (`index.ts:869`) can reuse + the staged `latestServerManifest` instead of regenerating; requires confirming + `onAfterBuild`/`emit` ordering. +5. **F-7** (orthogonal) — add an mtime cache to the `.client` recursive walk. + +--- + +## 7. Correctness caveats (must-preserve invariants) + +1. **Raw `source` is load-bearing for dev CSS fallback** (`manifest.ts:191-199`). + Any consolidation that drops the disk read must relocate this check (F-4) or + preserve access to the raw source. +2. **Code-source equality is assumed, not enforced** (companion doc §5). Pipeline A's + `code` and Pipeline B's `args.code` must agree for the route-chunks cache to hit + across pipelines; a divergence silently re-parses rather than erroring. +3. **`structuredClone` in `codeToAst` is a correctness guard**, not a redundant cost — + each chunk consumer mutates `ast.program.body` in place (companion doc §6.1). +4. **Manifest stores booleans, not export lists** (`manifest.ts:216-279`). Downstream + consumers needing raw names (`headers`, raw `loader`) currently re-extract (F-1); + do not assume the manifest carries the full list. +5. **`getBuildManifest` and SRI do not touch route source files** (§3g/§3h) — they + operate on path/id metadata and built assets respectively. Excluded from the + duplication set. diff --git a/route-chunk-parse-traverse-analysis.md b/route-chunk-parse-traverse-analysis.md new file mode 100644 index 0000000..6886d82 --- /dev/null +++ b/route-chunk-parse-traverse-analysis.md @@ -0,0 +1,244 @@ +# Route Chunk Parse / Traverse / Generate Behavior — Current State + +Branch: `perf/bundling-performance` @ `c2452de` +Scope: `src/route-chunks.ts` + callers in `src/index.ts` and `src/manifest.ts` + +--- + +## 1. Public entry points and their dispatch + +All three public functions funnel into a layered set of private helpers, each +of which is memoized through `getOrSetFromCache`. The `*IfEnabled` wrappers are +the only entry points called from outside the module. + +| Public fn (src/route-chunks.ts) | Line | Delegates to | Cache key prefix | +| ------------------------------------------------------------ | ---- | -------------------------------------------------------- | ------------------------------- | +| `detectRouteChunksIfEnabled(cache, config, id, code)` | 834 | `detectRouteChunks` | `normalizeRelativeFilePath(id)` | +| `getRouteChunkIfEnabled(cache, config, id, chunkName, code)` | 870 | `getRouteChunkCode` | `normalizeRelativeFilePath(id)` | +| `getRouteChunkCode(code, chunkName, cache, cacheKey)` | 782 | `omitChunkedExports` (main) / `getChunkedExport` (named) | per-call | + +Both `*IfEnabled` wrappers compute `cacheKey = normalizeRelativeFilePath(id, config.appDirectory)` +(`relative` → `normalize` → `.split('?')[0]`), so **query strings are stripped** +before keying. A module reached as `foo.tsx`, `foo.tsx?route-chunk=main`, or +`foo.tsx?__react-router-build-client-route` all collide onto the **same cache key**. + +--- + +## 2. Cache structure and versioning + +```ts +type RouteChunkCacheEntry = { value: T; version: string }; +type RouteChunkCache = Map>; +``` + +`getOrSetFromCache(cache, key, version, getValue)` (line 69): + +- **Hit** only when an entry exists for `key` **and** `entry.version === version`. +- The `version` argument is **always the `code` string itself** at every call site. +- Therefore: cache reuse is keyed by `(normalized file path, full source code)`. + A different `code` string for the same path = full recompute. + +There is exactly **one** cache instance for the whole build: +`const routeChunkCache: RouteChunkCache = new Map();` (index.ts:403), +created once per plugin invocation and passed by reference to every consumer — +the manifest path (`routeChunkOptions.cache` → manifest.ts:205) and all three +Rspack transform hooks share it. + +--- + +## 3. Each parse / traverse / generate site + +### 3a. `codeToAst` — parse + clone (lines 87-95) + +```ts +const codeToAst = (code, cache, cacheKey) => { + return structuredClone( + getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => + parse(code, { sourceType: 'module' }) + ) + ); +}; +``` + +- **Parse** (`babel.parse`) runs only on a cache MISS — once per `(path, code)`. +- **`structuredClone` runs UNCONDITIONALLY on every call**, cache hit or miss. + This is the dominant redundant cost: a deep clone of the entire AST File + node happens every time `codeToAst` is invoked, even when the parse itself + was served from cache. +- Rationale for the clone: every consumer **mutates** `ast.program.body` in + place (filter + map + assign), so sharing one AST node would corrupt later + reads. The clone is a correctness guard, not an optimization. + +`codeToAst` is called from exactly three sites, each inside a +`getOrSetFromCache` miss-callback (so each fires at most once per distinct key +per build): + +| Caller | Line | Cache key | What it does with the AST | +| ----------------------- | ---- | --------------------------------------------- | --------------------------------------- | +| `getExportDependencies` | 170 | `${ck}::getExportDependencies` | `traverse(ast, { ExportDeclaration })` | +| `getChunkedExport` | 547 | `${ck}::getChunkedExport::${name}::{opts}` | filter `ast.program.body`, `generate()` | +| `omitChunkedExports` | 663 | `${ck}::omitChunkedExports::${names}::{opts}` | filter `ast.program.body`, `generate()` | + +### 3b. `getExportDependencies` — traverse (lines 158-315) + +- Cached at `${ck}::getExportDependencies`, version = `code`. +- On miss: calls `codeToAst` (→ clone), then runs **one** `traverse()` over the + AST visiting `ExportDeclaration`. Builds a `Map` + mapping each export name → `{ topLevelStatements, topLevelNonModuleStatements, +importedIdentifierNames, exportedVariableDeclarators }`. +- Helper `getDependentIdentifiersForPath` (317) walks scope to find all + identifier dependencies of an export; `getTopLevelStatementsForPaths` (385) + lifts those to their top-level owning statement. +- This is the single traversal pass; its result is reused by every chunkability + check and every chunk-extraction. + +### 3c. `hasChunkableExport` — dependency-overlap check (lines 460-516) + +- Cached at `${ck}::hasChunkableExport::${exportName}`, version = `code`. +- On miss: calls `getExportDependencies` (cache hit if already computed), then + checks that the export's top-level non-module statements don't overlap with + any other export's (using `setsIntersect`), and that it doesn't share a + variable declarator with siblings. Returns `false` if any overlap → that + export cannot be cleanly split out. +- Called 4× per `detectRouteChunks` (one per `routeChunkExportName`). + +### 3d. `getChunkedExport` — generate a single export chunk (lines 518-617) + +- Cached at `${ck}::getChunkedExport::${exportName}::${JSON.stringify(generateOptions)}`, + version = `code`. +- On miss: calls `hasChunkableExport` (hit), `getExportDependencies` (hit), + `codeToAst` (**clone**), then filters `ast.program.body` keeping only the + dependency statements, prunes import specifiers and export declarations, + and calls **`generate(ast, generateOptions)`**. + +### 3e. `omitChunkedExports` — generate the "main" chunk (lines 619-758) + +- Cached at `${ck}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify(generateOptions)}`, + version = `code`. +- On miss: calls `hasChunkableExport` for every export name (to classify + omit vs retain), `getExportDependencies` (hit), `codeToAst` (**clone**), + filters out omitted statements/declarators/specifiers, then **`generate()`**. +- Returns `undefined` if nothing remains (the caller substitutes a no-op + snippet). + +--- + +## 4. Who calls what — the per-module call sequence during a build + +The cache is shared, so for a given route module file the operations compose. +For a module that splits into **main + 2 chunkable exports** (e.g. +clientAction, clientLoader), across one build the code paths execute: + +| # | Caller site | Fns invoked (cold) | Redundant on warm | +| --- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| 1 | split-exports transform (index.ts:1509) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` → `getExportDependencies`(miss: parse+**clone**+traverse) | — | +| 2 | client-entry transform (index.ts:1383) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` (**hits**) | clones avoided (hasChunkableExport hit short-circuits before codeToAst) | +| 3 | manifest generation (manifest.ts:204) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` (**hits**) | — | +| 4 | route-chunk transform `main` (index.ts:1446) | `getRouteChunkIfEnabled` → `omitChunkedExports`(miss) → `hasChunkableExport`(hits), `getExportDependencies`(hit), `codeToAst`(**clone**), `generate()` | — | +| 5 | route-chunk transform `clientAction` (index.ts:1446) | `getRouteChunkIfEnabled` → `getChunkedExport`(miss) → `codeToAst`(**clone**), `generate()` | — | +| 6 | route-chunk transform `clientLoader` (index.ts:1446) | `getRouteChunkIfEnabled` → `getChunkedExport`(miss) → `codeToAst`(**clone**), `generate()` | — | + +**Net per cold module (main + 2 chunks):** + +- `parse()`: **1×** (cached at codeToAst). +- `structuredClone()`: **4×** — once in `getExportDependencies` (#1), once each + in `omitChunkedExports` (#4), `getChunkedExport` (#5, #6). Every clone is a + full deep copy of the AST, paid even though the _parse_ was cached. +- `traverse()`: **1×** (in `getExportDependencies`). +- `generate()`: **3×** — one per chunk (main + 2 named). Each operates on its + own cloned, filtered AST; cannot be shared because the program bodies differ. + +Sites #2 and #3 (client-entry, manifest) are cheap warm reads: `hasChunkableExport` +hits short-circuit before any `codeToAst`/clone. They add zero parse/clone/generate +cost on the second invocation. + +--- + +## 5. Input keys that determine reuse vs cache miss + +- **Identity key** = `normalizeRelativeFilePath(id)` → file path relative to + `appDirectory`, normalized, query string stripped. Two resources with the + same path stem (differing only by `?route-chunk=` / `?react-router-route` / + `?__react-router-build-client-route`) share **all** chunk-cache entries. +- **Version** = the exact `code` string. Any byte-level difference in the + transformed ESM string invalidates **every** entry for that path (re-parse, + re-traverse, re-generate), because all sites pass `code` as the version. +- **Sub-key discriminators** (appended after the path prefix): + - `::codeToAst` — parse result. + - `::getExportDependencies` — dependency map. + - `::hasChunkableExport::${name}` — per-export chunkability boolean. + - `::getChunkedExport::${name}::${JSON.stringify(generateOptions)}` — per-export generated code. + - `::omitChunkedExports::${names.join(',')}::${JSON.stringify(generateOptions)}` — main-chunk generated code. + All callers currently pass `generateOptions = {}`, so the JSON suffix is + constant `"{}"`. + +### Cache-miss triggers (correctness-relevant) + +- **Code-source divergence**: the transform path derives `code` via + `transformToEsm(args.code, args.resourcePath)` (bundler-supplied source), + while the manifest path derives it via `getRouteModuleAnalysis` → + `readFile(resourcePath)` → `transformToEsm(source, resourcePath)` (disk read). + If the bundler's `args.code` ever differs from the disk file content (e.g. + different source after a preceding loader, or normalization differences), + the `version` strings differ and the manifest path silently re-parses / + re-traverses instead of hitting the cache. In a clean build they coincide, + but the equality is **assumed, not enforced**. + +--- + +## 6. Correctness assumptions embedded in the flow + +1. **AST mutation requires isolation** — `structuredClone` in `codeToAst` + exists because `getChunkedExport` and `omitChunkedExports` rewrite + `ast.program.body` in place. Removing the clone without another isolation + strategy (e.g. per-consumer filtered views, or re-parsing) would corrupt + shared state across the main/named chunks of the same module. + +2. **`getExportDependencies` maps export name → dependency sets for ALL exports**, + and chunkability is defined by _pairwise non-overlap_ of top-level + statements and variable declarators. An export is only chunkable if its + statements/declarators are disjoint from every sibling's. `omitChunkedExports` + relies on the same map to know exactly which statements to remove for "main". + +3. **`t.isNodesEquivalent` is used for structural identity** when filtering + `ast.program.body` against the dependency sets (getChunkedExport:556, + omitChunkedExports:684,713). Because the dependency sets were built from a + _different_ AST clone than the one being filtered, node identity (`===`) + would fail; structural (deep) equivalence is required and is assumed to be + sound for the statement shapes Babel produces. + +4. **Chunkability is all-or-nothing per export** — if an export shares a + top-level statement with any sibling, it is reported as non-chunkable + (`hasChunkableExport` returns `false`) and stays in the main chunk. There is + no partial-split mode. + +5. **`generateOptions` is part of the cache key** (JSON-serialized) but always + `{}` at present, so the discriminator is inert. If a caller ever passed + non-default options (e.g. source maps), it would create a separate cache + entry and re-generate independently. + +6. **Root route module is always excluded** — `detectRouteChunksIfEnabled` + returns a no-chunks result for `isRootRouteModuleId` before any parse, so + `root.tsx` never enters the parse/clone/traverse pipeline. + +7. **Cheap pre-filter**: `detectRouteChunksIfEnabled` bails early if + `!code.includes(exportName)` for any of the 4 export names, skipping the + entire parse/traverse for modules with no chunkable exports. This is a + substring test, not a parse — fast but coarse. + +--- + +## 7. Summary of optimization-relevant findings + +- The **parse** is already well-cached (1 per module per build). +- The **traverse** is already well-cached (1 per module per build). +- **`structuredClone` is the redundant hot spot**: it runs once per chunk + (1 + N clones for a module with N chunkable exports), each cloning the full + AST. Since each chunk needs a _differently filtered_ AST, the clones aren't + avoidable in the current "clone-then-filter-then-generate" design — but the + clone cost scales with AST size × chunk count. +- **`generate`** runs once per chunk (main + N named) and is inherently + per-chunk (different program bodies). This is the floor of work. +- **Cross-caller reuse works correctly** for the dependency analysis + (`getExportDependencies`, `hasChunkableExport`) because those are pure reads + that don't mutate the AST — only the chunk _generation_ steps clone+mutate. diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index 834f43d..99979b9 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -226,11 +226,15 @@ const summarizePluginOperations = runs => { operation, count: 0, totalMs: 0, + wallMs: null, maxMs: 0, reports: 0, }; current.count += metrics.count ?? 0; current.totalMs += metrics.totalMs ?? 0; + if (typeof metrics.wallMs === 'number') { + current.wallMs = (current.wallMs ?? 0) + metrics.wallMs; + } current.maxMs = Math.max(current.maxMs, metrics.maxMs ?? 0); current.reports += 1; operations.set(key, current); @@ -297,8 +301,8 @@ const renderMarkdown = result => { '', `## ${benchmark.id} Plugin Operations`, '', - '| Environment | Operation | Count | Total | Max | Reports |', - '|---|---|---:|---:|---:|---:|' + '| Environment | Operation | Count | Total | Wall | Max | Reports |', + '|---|---|---:|---:|---:|---:|---:|' ); for (const operation of benchmark.pluginOperations.slice(0, 12)) { lines.push( @@ -307,6 +311,7 @@ const renderMarkdown = result => { operation.operation, operation.count, formatReportMs(operation.totalMs), + formatReportMs(operation.wallMs), formatReportMs(operation.maxMs), operation.reports, ] diff --git a/scripts/compare-benchmarks.mjs b/scripts/compare-benchmarks.mjs new file mode 100644 index 0000000..31192ac --- /dev/null +++ b/scripts/compare-benchmarks.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; + +const { values } = parseArgs({ + allowPositionals: false, + strict: true, + options: { + before: { type: 'string' }, + after: { type: 'string' }, + benchmark: { type: 'string', default: 'synthetic-256-ssr-esm-split' }, + operations: { + type: 'string', + default: 'route:chunk,route:client-entry,route:split-exports', + }, + }, +}); + +if (!values.before || !values.after) { + throw new Error( + 'Usage: node scripts/compare-benchmarks.mjs --before --after [--benchmark ] [--operations op,op]' + ); +} + +const readJson = async file => JSON.parse(await readFile(file, 'utf8')); +const before = await readJson(values.before); +const after = await readJson(values.after); +const operations = new Set(values.operations.split(',').filter(Boolean)); + +const findBenchmark = (result, id) => { + const benchmark = result.benchmarks?.find(item => item.id === id); + if (!benchmark) { + throw new Error( + `Benchmark "${id}" not found in ${result.date ?? 'input'}.` + ); + } + return benchmark; +}; + +const metric = (benchmark, path) => + path.split('.').reduce((value, key) => value?.[key], benchmark); + +const operationMetric = (benchmark, operation, key) => { + const matches = + benchmark.pluginOperations?.filter(item => item.operation === operation) ?? + []; + const values = matches + .map(item => item[key]) + .filter(value => typeof value === 'number'); + if (values.length === 0) { + return null; + } + return values.reduce((sum, value) => sum + value, 0); +}; + +const percentDelta = (beforeValue, afterValue) => { + if (beforeValue == null || afterValue == null || beforeValue === 0) { + return '-'; + } + return `${(((afterValue - beforeValue) / beforeValue) * 100).toFixed(1)}%`; +}; + +const formatNumber = value => (value == null ? '-' : value.toFixed(1)); +const formatMs = value => + value == null ? '-' : `${(value / 1000).toFixed(2)}s`; +const formatKb = value => + value == null ? '-' : `${Math.round(value / 1024)} MB`; + +const beforeBenchmark = findBenchmark(before, values.benchmark); +const afterBenchmark = findBenchmark(after, values.benchmark); + +const rows = [ + { + label: 'Wall median', + before: metric(beforeBenchmark, 'summary.wallMs.median'), + after: metric(afterBenchmark, 'summary.wallMs.median'), + format: formatMs, + }, + { + label: 'CPU median (user+sys)', + before: + metric(beforeBenchmark, 'summary.userMs.median') == null || + metric(beforeBenchmark, 'summary.sysMs.median') == null + ? null + : metric(beforeBenchmark, 'summary.userMs.median') + + metric(beforeBenchmark, 'summary.sysMs.median'), + after: + metric(afterBenchmark, 'summary.userMs.median') == null || + metric(afterBenchmark, 'summary.sysMs.median') == null + ? null + : metric(afterBenchmark, 'summary.userMs.median') + + metric(afterBenchmark, 'summary.sysMs.median'), + format: formatMs, + }, + { + label: 'Peak RSS p95', + before: metric(beforeBenchmark, 'summary.maxRssKb.p95'), + after: metric(afterBenchmark, 'summary.maxRssKb.p95'), + format: formatKb, + }, +]; + +for (const operation of operations) { + rows.push( + { + label: `${operation} totalMs`, + before: operationMetric(beforeBenchmark, operation, 'totalMs'), + after: operationMetric(afterBenchmark, operation, 'totalMs'), + format: formatNumber, + }, + { + label: `${operation} wallMs`, + before: operationMetric(beforeBenchmark, operation, 'wallMs'), + after: operationMetric(afterBenchmark, operation, 'wallMs'), + format: formatNumber, + } + ); +} + +console.log(`Benchmark comparison: ${values.benchmark}`); +console.log(''); +console.log('| Metric | Before | After | Delta |'); +console.log('|---|---:|---:|---:|'); +for (const row of rows) { + console.log( + `| ${row.label} | ${row.format(row.before)} | ${row.format(row.after)} | ${percentDelta(row.before, row.after)} |` + ); +} diff --git a/src/export-utils.ts b/src/export-utils.ts index 1836a21..a18efd7 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -3,12 +3,32 @@ import { extname } from 'pathe'; import * as esbuild from 'esbuild'; import { init, parse as parseExports } from 'es-module-lexer'; import { JS_LOADERS } from './constants.js'; +import { + detectRouteChunksIfEnabled, + type RouteChunkCache, + type RouteChunkConfig, + type RouteChunkInfo, +} from './route-chunks.js'; type TransformCacheEntry = { source: string; transformed: Promise; }; +export type BundlerRouteAnalysis = { + code: string; + getExportNames: () => Promise; + getRouteChunkInfo: ( + cache: RouteChunkCache | undefined, + config: RouteChunkConfig + ) => Promise; +}; + +type BundlerRouteAnalysisCacheEntry = { + source: string; + analysis: Promise; +}; + type RouteModuleAnalysis = { source: string; code: string; @@ -23,6 +43,10 @@ type RouteModuleAnalysisCacheEntry = { const transformCache = new Map(); const exportNamesCache = new Map>(); +const bundlerRouteAnalysisCache = new Map< + string, + BundlerRouteAnalysisCacheEntry +>(); const routeModuleAnalysisCache = new Map< string, RouteModuleAnalysisCacheEntry @@ -49,6 +73,9 @@ const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { return JS_LOADERS[ext] ?? 'js'; }; +const getRouteChunkConfigCacheKey = (config: RouteChunkConfig) => + `${String(config.splitRouteModules ?? false)}\0${config.appDirectory}\0${config.rootRouteFile}`; + export const transformToEsm = async ( code: string, resourcePath: string @@ -103,6 +130,66 @@ export const getExportNames = async (code: string): Promise => { return exports; }; +export const getBundlerRouteAnalysis = async ( + source: string, + resourcePath: string +): Promise => { + const cached = bundlerRouteAnalysisCache.get(resourcePath); + if (cached?.source === source) { + return cached.analysis; + } + + const analysis = (async () => { + const code = await transformToEsm(source, resourcePath); + let exportNames: Promise | undefined; + const routeChunkInfoCache = new Map>(); + + return { + code, + getExportNames: () => { + exportNames ??= getExportNames(code); + return exportNames; + }, + getRouteChunkInfo: ( + cache: RouteChunkCache | undefined, + config: RouteChunkConfig + ) => { + const cacheKey = getRouteChunkConfigCacheKey(config); + const cachedRouteChunkInfo = routeChunkInfoCache.get(cacheKey); + if (cachedRouteChunkInfo) { + return cachedRouteChunkInfo; + } + + const routeChunkInfo = detectRouteChunksIfEnabled( + cache, + config, + resourcePath, + code + ).catch(error => { + if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { + routeChunkInfoCache.delete(cacheKey); + } + throw error; + }); + + routeChunkInfoCache.set(cacheKey, routeChunkInfo); + return routeChunkInfo; + }, + }; + })().catch(error => { + if (bundlerRouteAnalysisCache.get(resourcePath)?.analysis === analysis) { + bundlerRouteAnalysisCache.delete(resourcePath); + } + throw error; + }); + + setBoundedCacheEntry(bundlerRouteAnalysisCache, resourcePath, { + source, + analysis, + }); + return analysis; +}; + export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { diff --git a/src/index.ts b/src/index.ts index 2e7a082..ff0012c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,18 +44,18 @@ import { } from './react-router-config.js'; import { getReactRouterManifestForDev, + getRouteManifestModuleExports, configRoutesToRouteManifest, } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { + getBundlerRouteAnalysis, getExportNames, getExportNamesAndExportAll, - getRouteModuleExports, transformToEsm, } from './export-utils.js'; import { - detectRouteChunksIfEnabled, getRouteChunkEntryName, getRouteChunkIfEnabled, getRouteChunkModuleId, @@ -755,11 +755,7 @@ export const pluginReactRouter = ( ); } - const routeExports: Record = {}; - for (const route of Object.values(routes)) { - const filePath = resolve(appDirectory, route.file); - routeExports[route.id] = await getRouteModuleExports(filePath); - } + const routeExports = getRouteManifestModuleExports(manifest); const errors: string[] = []; for (const [routeId, route] of Object.entries(manifest.routes)) { @@ -1374,17 +1370,18 @@ export const pluginReactRouter = ( 'route:client-entry', args.resource, async () => { - const code = await transformToEsm(args.code, args.resourcePath); - const exportNames = await getExportNames(code); + const analysis = await getBundlerRouteAnalysis( + args.code, + args.resourcePath + ); + const exportNames = await analysis.getExportNames(); const isServer = args.environment?.name === 'node'; const chunkedExports = !isServer && isBuild && splitRouteModules ? ( - await detectRouteChunksIfEnabled( + await analysis.getRouteChunkInfo( routeChunkCache, - routeChunkConfig, - args.resourcePath, - code + routeChunkConfig ) ).chunkedExports : []; @@ -1501,22 +1498,20 @@ export const pluginReactRouter = ( return { code: args.code, map: null }; } - const transformed = await transformToEsm( + const analysis = await getBundlerRouteAnalysis( args.code, args.resourcePath ); const { hasRouteChunks, chunkedExports } = - await detectRouteChunksIfEnabled( + await analysis.getRouteChunkInfo( routeChunkCache, - routeChunkConfig, - args.resourcePath, - transformed + routeChunkConfig ); if (!hasRouteChunks) { return { code: args.code, map: null }; } - const sourceExports = await getExportNames(transformed); + const sourceExports = await analysis.getExportNames(); const chunkedExportSet = new Set(chunkedExports); const isMainChunkExport = (name: string) => !chunkedExportSet.has(name); @@ -1745,8 +1740,16 @@ export const pluginReactRouter = ( args.resource, async () => { let code: string; + let exportNames: string[] | undefined; try { - code = await transformToEsm(args.code, args.resourcePath); + const analysis = await getBundlerRouteAnalysis( + args.code, + args.resourcePath + ); + code = analysis.code; + if (args.environment.name === 'web' && !ssr && isSpaMode) { + exportNames = await analysis.getExportNames(); + } } catch (error) { console.error(args.resourcePath); throw error; @@ -1759,11 +1762,12 @@ export const pluginReactRouter = ( // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan // the ESBuild-transformed JS output. if (args.environment.name === 'web' && !ssr && isSpaMode) { - const exportNames = await getExportNames(code); + const resolvedExportNames = + exportNames ?? (await getExportNames(code)); const isRootRoute = args.resourcePath === rootRoutePath; - const invalidServerOnly = exportNames.filter(exp => { + const invalidServerOnly = resolvedExportNames.filter(exp => { if (isRootRoute && exp === 'loader') return false; return ( SERVER_ONLY_ROUTE_EXPORTS as readonly string[] @@ -1779,7 +1783,10 @@ export const pluginReactRouter = ( ); } - if (!isRootRoute && exportNames.includes('HydrateFallback')) { + if ( + !isRootRoute && + resolvedExportNames.includes('HydrateFallback') + ) { throw new Error( `SPA Mode: Invalid \`HydrateFallback\` export found in ` + `\`${relative(process.cwd(), args.resourcePath)}\`. ` + diff --git a/src/manifest.ts b/src/manifest.ts index 08b8707..4d532c7 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -63,6 +63,32 @@ type RouteChunkManifestOptions = { cache?: RouteChunkCache; }; +export type ReactRouterManifestForDev = { + version: string; + url: string; + hmr?: { + runtime: string; + }; + entry: { + module: string; + imports: string[]; + css: string[]; + }; + sri?: Record; + routes: Record; +}; + +export type RouteManifestModuleExports = Record; + +const routeManifestModuleExports = new WeakMap< + ReactRouterManifestForDev, + RouteManifestModuleExports +>(); + +export const getRouteManifestModuleExports = ( + manifest: ReactRouterManifestForDev +): RouteManifestModuleExports => routeManifestModuleExports.get(manifest) ?? {}; + const DEFAULT_MANIFEST_DIR = 'static/js'; const getManifestDirFromEntryAsset = (entryModulePath?: string): string => { @@ -115,20 +141,7 @@ export async function getReactRouterManifestForDev( context: string, assetPrefix = '/', routeChunkOptions?: RouteChunkManifestOptions -): Promise<{ - version: string; - url: string; - hmr?: { - runtime: string; - }; - entry: { - module: string; - imports: string[]; - css: string[]; - }; - sri?: Record; - routes: Record; -}> { +): Promise { const result: Record = {}; const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false; const enforceSplitRouteModules = splitRouteModules === 'enforce'; @@ -169,6 +182,7 @@ export async function getReactRouterManifestForDev( // Read and analyze the route file to check for exports const routeFilePath = resolve(context, route.file); let exports = new Set(); + let routeModuleExports: string[] = []; let hasRouteChunkByExportName: Record< | 'clientAction' | 'clientLoader' @@ -183,20 +197,18 @@ export async function getReactRouterManifestForDev( }; try { - const { - source, - code, - exports: exportNames, - } = await getRouteModuleAnalysis(routeFilePath); + const { code, exports: exportNames } = + await getRouteModuleAnalysis(routeFilePath); if ( !isBuild && cssAssets.length === 0 && - /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(source) + /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code) ) { cssAssets = [ `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, ]; } + routeModuleExports = exportNames; exports = new Set(exportNames); if (isBuild && routeChunkConfig) { @@ -280,12 +292,15 @@ export async function getReactRouterManifestForDev( imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), }, + routeModuleExports, ] as const; }) ); - for (const [key, routeManifestItem] of manifestEntries) { + const routeModuleExportsByRouteId: RouteManifestModuleExports = {}; + for (const [key, routeManifestItem, routeModuleExports] of manifestEntries) { result[key] = routeManifestItem; + routeModuleExportsByRouteId[key] = routeModuleExports; } const entryAssets = getAssetsForChunk('entry.client'); @@ -307,7 +322,7 @@ export async function getReactRouterManifestForDev( entryModulePath: entryJsAssets[0], }); - return { + const manifest = { version, url: combineURLs(assetPrefix, manifestPath), hmr: undefined, @@ -315,4 +330,7 @@ export async function getReactRouterManifestForDev( sri: undefined, routes: result, }; + + routeManifestModuleExports.set(manifest, routeModuleExportsByRouteId); + return manifest; } diff --git a/src/performance.ts b/src/performance.ts index 942b27e..278aa1e 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -1,6 +1,7 @@ type OperationTiming = { count: number; totalMs: number; + wallMs: number; maxMs: number; slowest: Array<{ durationMs: number; @@ -8,7 +9,15 @@ type OperationTiming = { }>; }; -type EnvironmentTimings = Map; +type OperationInterval = { startMs: number; endMs: number }; + +type MutableOperationTiming = Omit & { + intervals: OperationInterval[]; +}; + +type EnvironmentTimings = Map; + +const MAX_SLOWEST_ENTRIES = 5; export type ReactRouterPerformanceReport = { environment: string; @@ -47,7 +56,7 @@ export const createReactRouterPerformanceProfiler = ({ const getOperationTiming = ( environment: string, operation: string - ): OperationTiming => { + ): MutableOperationTiming => { let timings = timingsByEnvironment.get(environment); if (!timings) { timings = new Map(); @@ -61,36 +70,68 @@ export const createReactRouterPerformanceProfiler = ({ totalMs: 0, maxMs: 0, slowest: [], + intervals: [], }; timings.set(operation, timing); } return timing; }; + const roundMs = (value: number) => Math.round(value * 10) / 10; + + const computeWallMs = (intervals: OperationInterval[]) => { + if (intervals.length === 0) { + return 0; + } + + const sortedIntervals = [...intervals].sort( + (a, b) => a.startMs - b.startMs || a.endMs - b.endMs + ); + let mergedStart = sortedIntervals[0].startMs; + let mergedEnd = sortedIntervals[0].endMs; + let wallMs = 0; + + for (const interval of sortedIntervals.slice(1)) { + if (interval.startMs <= mergedEnd) { + mergedEnd = Math.max(mergedEnd, interval.endMs); + continue; + } + + wallMs += mergedEnd - mergedStart; + mergedStart = interval.startMs; + mergedEnd = interval.endMs; + } + + wallMs += mergedEnd - mergedStart; + return roundMs(wallMs); + }; + + const toOperationTiming = ( + timing: MutableOperationTiming + ): OperationTiming => ({ + count: timing.count, + totalMs: timing.totalMs, + wallMs: computeWallMs(timing.intervals), + maxMs: timing.maxMs, + slowest: timing.slowest, + }); + const recordDuration = ( environment: string, operation: string, resource: string, - durationMs: number + startMs: number, + endMs: number ) => { - const roundedDuration = Math.round(durationMs * 10) / 10; + const roundedDuration = roundMs(endMs - startMs); const timing = getOperationTiming(environment, operation); timing.count += 1; - timing.totalMs = Math.round((timing.totalMs + roundedDuration) * 10) / 10; + timing.totalMs = roundMs(timing.totalMs + roundedDuration); timing.maxMs = Math.max(timing.maxMs, roundedDuration); + timing.intervals.push({ startMs, endMs }); timing.slowest.push({ durationMs: roundedDuration, resource }); - for (let index = timing.slowest.length - 1; index > 0; index -= 1) { - if ( - timing.slowest[index].durationMs <= timing.slowest[index - 1].durationMs - ) { - break; - } - [timing.slowest[index - 1], timing.slowest[index]] = [ - timing.slowest[index], - timing.slowest[index - 1], - ]; - } - if (timing.slowest.length > 5) { + timing.slowest.sort((a, b) => b.durationMs - a.durationMs); + if (timing.slowest.length > MAX_SLOWEST_ENTRIES) { timing.slowest.pop(); } }; @@ -101,23 +142,16 @@ export const createReactRouterPerformanceProfiler = ({ return callback(); } + const resolvedEnvironment = environment ?? 'unknown'; const start = performance.now(); try { return callback().finally(() => { - recordDuration( - environment ?? 'unknown', - operation, - resource, - performance.now() - start - ); + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); }); } catch (error) { - recordDuration( - environment ?? 'unknown', - operation, - resource, - performance.now() - start - ); + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); return Promise.reject(error); } }, @@ -126,16 +160,13 @@ export const createReactRouterPerformanceProfiler = ({ return callback(); } + const resolvedEnvironment = environment ?? 'unknown'; const start = performance.now(); try { return callback(); } finally { - recordDuration( - environment ?? 'unknown', - operation, - resource, - performance.now() - start - ); + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); } }, flush(environment, details = {}) { @@ -148,7 +179,12 @@ export const createReactRouterPerformanceProfiler = ({ return; } - const operations = Object.fromEntries(timings.entries()); + const operations = Object.fromEntries( + [...timings.entries()].map(([operation, timing]) => [ + operation, + toOperationTiming(timing), + ]) + ); const report: ReactRouterPerformanceReport = { environment, ...details, diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 2339729..b9813e2 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -29,6 +29,20 @@ export type RouteChunkInfo = { chunkedExports: RouteChunkExportName[]; }; +type ExportDependencyIndex = { + topLevelStatementIndices: ReadonlySet; + topLevelNonModuleStatementIndices: ReadonlySet; + importedIdentifierNames: ReadonlySet; + exportedVariableDeclaratorKeys: ReadonlySet; +}; + +type RouteChunkAnalysis = { + readonly ast: t.File; + readonly exports: ReadonlyMap; + readonly topLevel: readonly t.Statement[]; + readonly chunkableExports: ReadonlySet; +}; + export const routeChunkExportNames: RouteChunkExportName[] = [ 'clientAction', 'clientLoader', @@ -85,18 +99,6 @@ const getOrSetFromCache = ( return value; }; -const codeToAst = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string -) => { - return structuredClone( - getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => - parse(code, { sourceType: 'module' }) - ) - ); -}; - const assertNodePath: ( path: NodePath | NodePath[] | null | undefined ) => asserts path is NodePath = path => { @@ -149,172 +151,6 @@ const assertNodePathIsPattern: ( ); }; -type ExportDependencies = { - topLevelStatements: Set; - topLevelNonModuleStatements: Set; - importedIdentifierNames: Set; - exportedVariableDeclarators: Set; -}; - -const getExportDependencies = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string -): Map => { - return getOrSetFromCache( - cache, - `${cacheKey}::getExportDependencies`, - code, - () => { - const exportDependencies = new Map(); - const ast = codeToAst(code, cache, cacheKey); - - function handleExport( - exportName: string, - exportPath: NodePath, - identifiersPath: NodePath = exportPath - ) { - const identifiers = getDependentIdentifiersForPath(identifiersPath); - const topLevelStatements = new Set([ - exportPath.node as t.Statement, - ...getTopLevelStatementsForPaths(identifiers), - ]); - const topLevelNonModuleStatements = new Set( - Array.from(topLevelStatements).filter( - statement => - !t.isImportDeclaration(statement) && - !t.isExportDeclaration(statement) - ) - ); - const importedIdentifierNames = new Set(); - for (const identifier of identifiers) { - if ( - t.isIdentifier(identifier.node) && - identifier.parentPath?.parentPath?.isImportDeclaration() - ) { - importedIdentifierNames.add(identifier.node.name); - } - } - const exportedVariableDeclarators = new Set(); - for (const identifier of identifiers) { - if (identifier.parentPath?.isVariableDeclarator()) { - const parentPath = identifier.parentPath; - if (parentPath.parentPath?.parentPath?.isExportNamedDeclaration()) { - exportedVariableDeclarators.add( - parentPath.node as t.VariableDeclarator - ); - continue; - } - } - const isWithinExportDestructuring = Boolean( - identifier.findParent(path => - Boolean( - path.isPattern() && - path.parentPath?.isVariableDeclarator() && - path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() - ) - ) - ); - if (isWithinExportDestructuring) { - let currentPath: NodePath | null = identifier; - while (currentPath) { - if ( - currentPath.parentPath?.isVariableDeclarator() && - currentPath.parentKey === 'id' - ) { - exportedVariableDeclarators.add( - currentPath.parentPath.node as t.VariableDeclarator - ); - break; - } - currentPath = currentPath.parentPath; - } - } - } - exportDependencies.set(exportName, { - topLevelStatements, - topLevelNonModuleStatements, - importedIdentifierNames, - exportedVariableDeclarators, - }); - } - - traverse(ast, { - ExportDeclaration(exportPath) { - const { node } = exportPath; - if (t.isExportAllDeclaration(node)) { - return; - } - if (t.isExportDefaultDeclaration(node)) { - handleExport('default', exportPath); - return; - } - const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - const { declarations } = declaration; - for (let i = 0; i < declarations.length; i++) { - const declarator = declarations[i]; - if (t.isIdentifier(declarator.id)) { - const declaratorPath = exportPath.get( - `declaration.declarations.${i}` - ); - assertNodePathIsVariableDeclarator(declaratorPath); - handleExport(declarator.id.name, exportPath, declaratorPath); - continue; - } - if (t.isPattern(declarator.id)) { - const exportedPatternPath = exportPath.get( - `declaration.declarations.${i}.id` - ); - assertNodePathIsPattern(exportedPatternPath); - const identifiers = - getIdentifiersForPatternPath(exportedPatternPath); - for (const identifier of identifiers) { - if (!t.isIdentifier(identifier.node)) { - continue; - } - handleExport(identifier.node.name, exportPath, identifier); - } - } - } - return; - } - if ( - t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration) - ) { - invariant( - declaration.id, - 'Expected exported function or class declaration to have a name when not the default export' - ); - handleExport(declaration.id.name, exportPath); - return; - } - if (t.isExportNamedDeclaration(node)) { - for (const specifier of node.specifiers) { - if (t.isIdentifier(specifier.exported)) { - const name = specifier.exported.name; - const specifierPath = exportPath - .get('specifiers') - .find(path => path.node === specifier); - invariant( - specifierPath, - `Expected to find specifier path for ${name}` - ); - handleExport(name, exportPath, specifierPath); - } - } - return; - } - throw new Error('Unknown export node type'); - }, - }); - - return exportDependencies; - } - ); -}; - const getDependentIdentifiersForPath = ( path: NodePath, state?: { visited: Set; identifiers: Set } @@ -383,15 +219,55 @@ const getTopLevelStatementPathForPath = (path: NodePath) => { return topLevelStatement; }; -const getTopLevelStatementsForPaths = (paths: Set) => { - const topLevelStatements = new Set(); +const getTopLevelStatementIndexForPath = ( + path: NodePath, + topLevel: readonly t.Statement[] +) => { + const topLevelStatement = getTopLevelStatementPathForPath(path); + const index = topLevel.indexOf(topLevelStatement.node as t.Statement); + invariant( + index >= 0, + 'Expected top-level statement to exist in program body' + ); + return index; +}; + +const getTopLevelStatementIndicesForPaths = ( + paths: Set, + topLevel: readonly t.Statement[] +) => { + const indices = new Set(); for (const path of paths) { - const topLevelStatement = getTopLevelStatementPathForPath(path); - topLevelStatements.add(topLevelStatement.node as t.Statement); + indices.add(getTopLevelStatementIndexForPath(path, topLevel)); } - return topLevelStatements; + return indices; }; +const getExportedVariableDeclaratorKey = ( + path: NodePath, + topLevel: readonly t.Statement[] +) => { + const statementIndex = getTopLevelStatementIndexForPath(path, topLevel); + const declarationPath = path.parentPath; + invariant( + declarationPath?.isVariableDeclaration(), + 'Expected exported variable declarator to have a variable declaration parent' + ); + const declarationIndex = declarationPath.node.declarations.indexOf( + path.node as t.VariableDeclarator + ); + invariant( + declarationIndex >= 0, + 'Expected exported variable declarator to exist in its declaration' + ); + return `${statementIndex}:${declarationIndex}`; +}; + +const getExportedVariableDeclaratorKeyForIndex = ( + statementIndex: number, + declarationIndex: number +) => `${statementIndex}:${declarationIndex}`; + const getIdentifiersForPatternPath = ( patternPath: NodePath, identifiers: Set = new Set() @@ -443,7 +319,7 @@ const getExportedName = (exported: t.Identifier | t.StringLiteral) => { return t.isIdentifier(exported) ? exported.name : exported.value; }; -const setsIntersect = (set1: Set, set2: Set) => { +const setsIntersect = (set1: ReadonlySet, set2: ReadonlySet) => { let smallerSet = set1; let largerSet = set2; if (set1.size > set2.size) { @@ -458,22 +334,39 @@ const setsIntersect = (set1: Set, set2: Set) => { return false; }; -const hasChunkableExport = ( - code: string, - exportName: string, - cache: RouteChunkCache | undefined, - cacheKey: string +const getChunkableExports = ( + exportDependencies: ReadonlyMap ) => { - return getOrSetFromCache( - cache, - `${cacheKey}::hasChunkableExport::${exportName}`, - code, - () => { - const exportDependencies = getExportDependencies(code, cache, cacheKey); - const dependencies = exportDependencies.get(exportName); - if (!dependencies) { - return false; + const chunkableExports = new Set(); + + for (const exportName of routeChunkExportNames) { + const dependencies = exportDependencies.get(exportName); + if (!dependencies) { + continue; + } + + let isChunkable = true; + for (const [currentExportName, currentDependencies] of exportDependencies) { + if (currentExportName === exportName) { + continue; } + if ( + setsIntersect( + currentDependencies.topLevelNonModuleStatementIndices, + dependencies.topLevelNonModuleStatementIndices + ) + ) { + isChunkable = false; + break; + } + } + if (!isChunkable) { + continue; + } + if (dependencies.exportedVariableDeclaratorKeys.size > 1) { + continue; + } + if (dependencies.exportedVariableDeclaratorKeys.size > 0) { for (const [ currentExportName, currentDependencies, @@ -483,303 +376,481 @@ const hasChunkableExport = ( } if ( setsIntersect( - currentDependencies.topLevelNonModuleStatements, - dependencies.topLevelNonModuleStatements + currentDependencies.exportedVariableDeclaratorKeys, + dependencies.exportedVariableDeclaratorKeys ) ) { - return false; + isChunkable = false; + break; } } - if (dependencies.exportedVariableDeclarators.size > 1) { - return false; - } - if (dependencies.exportedVariableDeclarators.size > 0) { - for (const [ - currentExportName, - currentDependencies, - ] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.exportedVariableDeclarators, - dependencies.exportedVariableDeclarators - ) - ) { - return false; - } - } - } - return true; } - ); + if (isChunkable) { + chunkableExports.add(exportName); + } + } + + return chunkableExports; }; -const getChunkedExport = ( +const analyzeRouteModule = ( code: string, - exportName: string, - generateOptions: Record = {}, cache: RouteChunkCache | undefined, cacheKey: string -): string | undefined => { - return getOrSetFromCache( - cache, - `${cacheKey}::getChunkedExport::${exportName}::${JSON.stringify( - generateOptions - )}`, - code, - () => { - if (!hasChunkableExport(code, exportName, cache, cacheKey)) { - return undefined; - } - const exportDependencies = getExportDependencies(code, cache, cacheKey); - const dependencies = exportDependencies.get(exportName); - invariant(dependencies, 'Expected export to have dependencies'); - - const topLevelStatementsArray = Array.from( - dependencies.topLevelStatements - ); - const exportedVariableDeclaratorsArray = Array.from( - dependencies.exportedVariableDeclarators - ); - - const ast = codeToAst(code, cache, cacheKey); - ast.program.body = ast.program.body - .filter(node => - topLevelStatementsArray.some(statement => - t.isNodesEquivalent(node, statement) - ) - ) - .map(node => { - if (!t.isImportDeclaration(node)) { - return node; - } - if (dependencies.importedIdentifierNames.size === 0) { - return null; - } - node.specifiers = node.specifiers.filter(specifier => - dependencies.importedIdentifierNames.has(specifier.local.name) +): RouteChunkAnalysis => { + return getOrSetFromCache(cache, `${cacheKey}::analysis`, code, () => { + const exportDependencies = new Map(); + const ast = parse(code, { sourceType: 'module' }); + const topLevel = ast.program.body; + + function handleExport( + exportName: string, + exportPath: NodePath, + identifiersPath: NodePath = exportPath + ) { + const identifiers = getDependentIdentifiersForPath(identifiersPath); + const topLevelStatementIndices = new Set([ + getTopLevelStatementIndexForPath(exportPath, topLevel), + ...getTopLevelStatementIndicesForPaths(identifiers, topLevel), + ]); + const topLevelNonModuleStatementIndices = new Set( + Array.from(topLevelStatementIndices).filter(index => { + const statement = topLevel[index]; + return ( + !t.isImportDeclaration(statement) && + !t.isExportDeclaration(statement) ); - invariant( - node.specifiers.length > 0, - 'Expected import statement to have used specifiers' - ); - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { - return node; - } - if (t.isExportAllDeclaration(node)) { - return null; - } - if (t.isExportDefaultDeclaration(node)) { - return exportName === 'default' ? node : null; - } - const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - declaration.declarations = declaration.declarations.filter( - declarationNode => - exportedVariableDeclaratorsArray.some(declarator => - t.isNodesEquivalent(declarationNode, declarator) - ) + ); + const importedIdentifierNames = new Set(); + for (const identifier of identifiers) { + if ( + t.isIdentifier(identifier.node) && + identifier.parentPath?.parentPath?.isImportDeclaration() + ) { + importedIdentifierNames.add(identifier.node.name); + } + } + const exportedVariableDeclaratorKeys = new Set(); + for (const identifier of identifiers) { + if (identifier.parentPath?.isVariableDeclarator()) { + const parentPath = identifier.parentPath; + if (parentPath.parentPath?.parentPath?.isExportNamedDeclaration()) { + exportedVariableDeclaratorKeys.add( + getExportedVariableDeclaratorKey(parentPath, topLevel) ); - if (declaration.declarations.length === 0) { - return null; - } - return node; - } - if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) - ) { - return node.declaration.id?.name === exportName ? node : null; + continue; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return null; - } - node.specifiers = node.specifiers.filter( - specifier => getExportedName(specifier.exported) === exportName - ); - if (node.specifiers.length === 0) { - return null; + } + const isWithinExportDestructuring = Boolean( + identifier.findParent(path => + Boolean( + path.isPattern() && + path.parentPath?.isVariableDeclarator() && + path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() + ) + ) + ); + if (isWithinExportDestructuring) { + let currentPath: NodePath | null = identifier; + while (currentPath) { + if ( + currentPath.parentPath?.isVariableDeclarator() && + currentPath.parentKey === 'id' + ) { + exportedVariableDeclaratorKeys.add( + getExportedVariableDeclaratorKey( + currentPath.parentPath, + topLevel + ) + ); + break; } - return node; + currentPath = currentPath.parentPath; } - throw new Error('Unknown export node type'); - }) - .filter(Boolean) as t.Statement[]; - - return generate(ast, generateOptions).code; + } + } + exportDependencies.set(exportName, { + topLevelStatementIndices, + topLevelNonModuleStatementIndices, + importedIdentifierNames, + exportedVariableDeclaratorKeys, + }); } - ); -}; -const omitChunkedExports = ( - code: string, - exportNames: string[], - generateOptions: Record = {}, - cache: RouteChunkCache | undefined, - cacheKey: string -): string | undefined => { - return getOrSetFromCache( - cache, - `${cacheKey}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify( - generateOptions - )}`, - code, - () => { - const isChunkable = (exportName: string) => - hasChunkableExport(code, exportName, cache, cacheKey); - const isOmitted = (exportName: string) => - exportNames.includes(exportName) && isChunkable(exportName); - const isRetained = (exportName: string) => !isOmitted(exportName); - - const exportDependencies = getExportDependencies(code, cache, cacheKey); - const allExportNames = Array.from(exportDependencies.keys()); - const omittedExportNames = allExportNames.filter(isOmitted); - const retainedExportNames = allExportNames.filter(isRetained); - - const omittedStatements = new Set(); - const omittedExportedVariableDeclarators = - new Set(); - - for (const omittedExportName of omittedExportNames) { - const dependencies = exportDependencies.get(omittedExportName); - invariant( - dependencies, - `Expected dependencies for ${omittedExportName}` - ); - for (const statement of dependencies.topLevelNonModuleStatements) { - omittedStatements.add(statement); + traverse(ast, { + ExportDeclaration(exportPath) { + const { node } = exportPath; + if (t.isExportAllDeclaration(node)) { + return; } - for (const declarator of dependencies.exportedVariableDeclarators) { - omittedExportedVariableDeclarators.add(declarator); + if (t.isExportDefaultDeclaration(node)) { + handleExport('default', exportPath); + return; } - } - - const ast = codeToAst(code, cache, cacheKey); - const omittedStatementsArray = Array.from(omittedStatements); - const omittedExportedVariableDeclaratorsArray = Array.from( - omittedExportedVariableDeclarators - ); - ast.program.body = ast.program.body - .filter(node => - omittedStatementsArray.every( - statement => !t.isNodesEquivalent(node, statement) - ) - ) - .map(node => { - if (!t.isImportDeclaration(node)) { - return node; - } - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { - const importedName = specifier.local.name; - for (const retainedExportName of retainedExportNames) { - const dependencies = exportDependencies.get(retainedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { - return true; - } + const { declaration } = node; + if (t.isVariableDeclaration(declaration)) { + const { declarations } = declaration; + for (let i = 0; i < declarations.length; i++) { + const declarator = declarations[i]; + if (t.isIdentifier(declarator.id)) { + const declaratorPath = exportPath.get( + `declaration.declarations.${i}` + ); + assertNodePathIsVariableDeclarator(declaratorPath); + handleExport(declarator.id.name, exportPath, declaratorPath); + continue; } - for (const omittedExportName of omittedExportNames) { - const dependencies = exportDependencies.get(omittedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { - return false; + if (t.isPattern(declarator.id)) { + const exportedPatternPath = exportPath.get( + `declaration.declarations.${i}.id` + ); + assertNodePathIsPattern(exportedPatternPath); + const identifiers = + getIdentifiersForPatternPath(exportedPatternPath); + for (const identifier of identifiers) { + if (!t.isIdentifier(identifier.node)) { + continue; + } + handleExport(identifier.node.name, exportPath, identifier); } } - return true; - }); - if (node.specifiers.length === 0) { - return null; - } - return node; - }) - .map(node => { - if (!t.isExportDeclaration(node)) { - return node; - } - if (t.isExportAllDeclaration(node)) { - return node; } - if (t.isExportDefaultDeclaration(node)) { - return isOmitted('default') ? null : node; - } - if (t.isVariableDeclaration(node.declaration)) { - node.declaration.declarations = - node.declaration.declarations.filter(declarationNode => - omittedExportedVariableDeclaratorsArray.every( - declarator => - !t.isNodesEquivalent(declarationNode, declarator) - ) + return; + } + if ( + t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration) + ) { + invariant( + declaration.id, + 'Expected exported function or class declaration to have a name when not the default export' + ); + handleExport(declaration.id.name, exportPath); + return; + } + if (t.isExportNamedDeclaration(node)) { + for (const specifier of node.specifiers) { + if (t.isIdentifier(specifier.exported)) { + const name = specifier.exported.name; + const specifierPath = exportPath + .get('specifiers') + .find(path => path.node === specifier); + invariant( + specifierPath, + `Expected to find specifier path for ${name}` ); - if (node.declaration.declarations.length === 0) { - return null; + handleExport(name, exportPath, specifierPath); } - return node; } - if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) - ) { - const declarationId = node.declaration.id; - invariant( - declarationId, - 'Expected exported function or class declaration to have a name when not the default export' - ); - return isOmitted(declarationId.name) ? null : node; - } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { - const exportedName = getExportedName(specifier.exported); - return !isOmitted(exportedName); - }); - if (node.specifiers.length === 0) { - return null; - } - return node; - } - throw new Error('Unknown node type'); - }) - .filter(Boolean) as t.Statement[]; + return; + } + throw new Error('Unknown export node type'); + }, + }); - if (ast.program.body.length === 0) { - return undefined; - } - return generate(ast, generateOptions).code; + if (process.env.NODE_ENV !== 'production') { + Object.freeze(topLevel); } + + return { + ast, + exports: exportDependencies, + topLevel, + chunkableExports: getChunkableExports(exportDependencies), + }; + }); +}; + +const assertAnalysisBodyLengthUnchanged = ( + analysis: RouteChunkAnalysis, + expectedLength: number +) => { + invariant( + analysis.ast.program.body.length === expectedLength, + 'Expected route chunk analysis program body length to remain unchanged' ); }; -export const detectRouteChunks = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string +const createProgramCode = ( + body: t.Statement[], + generateOptions: Record +) => generate(t.file(t.program(body)), generateOptions).code; + +const cloneImportForNames = ( + node: t.ImportDeclaration, + importedIdentifierNames: ReadonlySet +) => { + // Shallow clone is safe here: only the top-level specifiers array is reassigned. + const clonedNode = t.cloneNode(node, false); + clonedNode.specifiers = node.specifiers.filter(specifier => + importedIdentifierNames.has(specifier.local.name) + ); + invariant( + clonedNode.specifiers.length > 0, + 'Expected import statement to have used specifiers' + ); + return clonedNode; +}; + +const cloneVariableExportForKeys = ( + node: t.ExportNamedDeclaration, + statementIndex: number, + declaratorKeys: ReadonlySet +) => { + invariant( + t.isVariableDeclaration(node.declaration), + 'Expected export declaration to contain variable declarations' + ); + // Shallow clones are safe here: only declaration/declarations array references are reassigned. + const clonedNode = t.cloneNode(node, false); + const clonedDeclaration = t.cloneNode(node.declaration, false); + clonedDeclaration.declarations = node.declaration.declarations.filter( + (_declarationNode, declarationIndex) => + declaratorKeys.has( + getExportedVariableDeclaratorKeyForIndex( + statementIndex, + declarationIndex + ) + ) + ); + if (clonedDeclaration.declarations.length === 0) { + return null; + } + clonedNode.declaration = clonedDeclaration; + return clonedNode; +}; + +const detectRouteChunksFromAnalysis = ( + analysis: RouteChunkAnalysis ): RouteChunkInfo => { const hasRouteChunkByExportName = Object.fromEntries( routeChunkExportNames.map(exportName => [ exportName, - hasChunkableExport(code, exportName, cache, cacheKey), + analysis.chunkableExports.has(exportName), ]) ) as Record; - const chunkedExports = Object.entries(hasRouteChunkByExportName) - .filter(([, isChunked]) => isChunked) - .map(([exportName]) => exportName as RouteChunkExportName); - const hasRouteChunks = chunkedExports.length > 0; + const chunkedExports = routeChunkExportNames.filter( + exportName => hasRouteChunkByExportName[exportName] + ); return { - hasRouteChunks, + hasRouteChunks: chunkedExports.length > 0, hasRouteChunkByExportName, chunkedExports, }; }; +const getChunkedExportFromAnalysis = ( + analysis: RouteChunkAnalysis, + exportName: RouteChunkExportName, + generateOptions: Record = {} +): string | undefined => { + if (!analysis.chunkableExports.has(exportName)) { + return undefined; + } + const dependencies = analysis.exports.get(exportName); + invariant(dependencies, 'Expected export to have dependencies'); + + const bodyLength = analysis.topLevel.length; + const body = analysis.topLevel + .map((node, statementIndex) => { + if (!dependencies.topLevelStatementIndices.has(statementIndex)) { + return null; + } + if (t.isImportDeclaration(node)) { + if (dependencies.importedIdentifierNames.size === 0) { + return null; + } + return cloneImportForNames(node, dependencies.importedIdentifierNames); + } + if (!t.isExportDeclaration(node)) { + return t.cloneNode(node, false); + } + if (t.isExportAllDeclaration(node)) { + return null; + } + if (t.isExportDefaultDeclaration(node)) { + return null; + } + const { declaration } = node; + if (t.isVariableDeclaration(declaration)) { + return cloneVariableExportForKeys( + node, + statementIndex, + dependencies.exportedVariableDeclaratorKeys + ); + } + if ( + t.isFunctionDeclaration(node.declaration) || + t.isClassDeclaration(node.declaration) + ) { + return node.declaration.id?.name === exportName + ? t.cloneNode(node, false) + : null; + } + if (t.isExportNamedDeclaration(node)) { + if (node.specifiers.length === 0) { + return null; + } + // Shallow clone is safe here: only the top-level specifiers array is reassigned. + const clonedNode = t.cloneNode(node, false); + clonedNode.specifiers = node.specifiers.filter( + specifier => getExportedName(specifier.exported) === exportName + ); + if (clonedNode.specifiers.length === 0) { + return null; + } + return clonedNode; + } + throw new Error('Unknown export node type'); + }) + .filter(Boolean) as t.Statement[]; + + assertAnalysisBodyLengthUnchanged(analysis, bodyLength); + return createProgramCode(body, generateOptions); +}; + +const omitChunkedExportsFromAnalysis = ( + analysis: RouteChunkAnalysis, + exportNames: string[], + generateOptions: Record = {} +): string | undefined => { + const isOmitted = (exportName: string) => + exportNames.includes(exportName) && + analysis.chunkableExports.has(exportName as RouteChunkExportName); + const isRetained = (exportName: string) => !isOmitted(exportName); + + const allExportNames = Array.from(analysis.exports.keys()); + const omittedExportNames = allExportNames.filter(isOmitted); + const retainedExportNames = allExportNames.filter(isRetained); + + const omittedStatementIndices = new Set(); + const omittedExportedVariableDeclaratorKeys = new Set(); + + for (const omittedExportName of omittedExportNames) { + const dependencies = analysis.exports.get(omittedExportName); + invariant(dependencies, `Expected dependencies for ${omittedExportName}`); + for (const statementIndex of dependencies.topLevelNonModuleStatementIndices) { + omittedStatementIndices.add(statementIndex); + } + for (const declaratorKey of dependencies.exportedVariableDeclaratorKeys) { + omittedExportedVariableDeclaratorKeys.add(declaratorKey); + } + } + + const bodyLength = analysis.topLevel.length; + const body = analysis.topLevel + .map((node, statementIndex) => { + if (omittedStatementIndices.has(statementIndex)) { + return null; + } + if (t.isImportDeclaration(node)) { + if (node.specifiers.length === 0) { + return t.cloneNode(node, false); + } + // Shallow clone is safe here: only the top-level specifiers array is reassigned. + const clonedNode = t.cloneNode(node, false); + clonedNode.specifiers = node.specifiers.filter(specifier => { + const importedName = specifier.local.name; + for (const retainedExportName of retainedExportNames) { + const dependencies = analysis.exports.get(retainedExportName); + if (dependencies?.importedIdentifierNames?.has(importedName)) { + return true; + } + } + for (const omittedExportName of omittedExportNames) { + const dependencies = analysis.exports.get(omittedExportName); + if (dependencies?.importedIdentifierNames?.has(importedName)) { + return false; + } + } + return true; + }); + if (clonedNode.specifiers.length === 0) { + return null; + } + return clonedNode; + } + if (!t.isExportDeclaration(node)) { + return t.cloneNode(node, false); + } + if (t.isExportAllDeclaration(node)) { + return t.cloneNode(node, false); + } + if (t.isExportDefaultDeclaration(node)) { + return isOmitted('default') ? null : t.cloneNode(node, false); + } + if (t.isVariableDeclaration(node.declaration)) { + const retainedDeclaratorKeys = new Set(); + for (let i = 0; i < node.declaration.declarations.length; i++) { + const key = getExportedVariableDeclaratorKeyForIndex( + statementIndex, + i + ); + if (!omittedExportedVariableDeclaratorKeys.has(key)) { + retainedDeclaratorKeys.add(key); + } + } + return cloneVariableExportForKeys( + node, + statementIndex, + retainedDeclaratorKeys + ); + } + if ( + t.isFunctionDeclaration(node.declaration) || + t.isClassDeclaration(node.declaration) + ) { + const declarationId = node.declaration.id; + invariant( + declarationId, + 'Expected exported function or class declaration to have a name when not the default export' + ); + return isOmitted(declarationId.name) ? null : t.cloneNode(node, false); + } + if (t.isExportNamedDeclaration(node)) { + if (node.specifiers.length === 0) { + return t.cloneNode(node, false); + } + // Shallow clone is safe here: only the top-level specifiers array is reassigned. + const clonedNode = t.cloneNode(node, false); + clonedNode.specifiers = node.specifiers.filter(specifier => { + const exportedName = getExportedName(specifier.exported); + return !isOmitted(exportedName); + }); + if (clonedNode.specifiers.length === 0) { + return null; + } + return clonedNode; + } + throw new Error('Unknown node type'); + }) + .filter(Boolean) as t.Statement[]; + + assertAnalysisBodyLengthUnchanged(analysis, bodyLength); + if (body.length === 0) { + return undefined; + } + return createProgramCode(body, generateOptions); +}; + +const getRouteChunkCodeFromAnalysis = ( + analysis: RouteChunkAnalysis, + chunkName: RouteChunkName +) => { + if (chunkName === 'main') { + return omitChunkedExportsFromAnalysis(analysis, routeChunkExportNames, {}); + } + return getChunkedExportFromAnalysis(analysis, chunkName, {}); +}; + +export const detectRouteChunks = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): RouteChunkInfo => + detectRouteChunksFromAnalysis(analyzeRouteModule(code, cache, cacheKey)); + export const getRouteChunkCode: ( code: string, chunkName: RouteChunkName, @@ -791,10 +862,10 @@ export const getRouteChunkCode: ( cache: RouteChunkCache | undefined, cacheKey: string ) => { - if (chunkName === 'main') { - return omitChunkedExports(code, routeChunkExportNames, {}, cache, cacheKey); - } - return getChunkedExport(code, chunkName, {}, cache, cacheKey); + return getRouteChunkCodeFromAnalysis( + analyzeRouteModule(code, cache, cacheKey), + chunkName + ); }; export const getRouteChunkModuleId = ( diff --git a/task/lexer-route-export-triage.md b/task/lexer-route-export-triage.md new file mode 100644 index 0000000..e27efbc --- /dev/null +++ b/task/lexer-route-export-triage.md @@ -0,0 +1,208 @@ +# Lexer-assisted route export analysis triage + +Branch: `perf/bundling-performance` +Commit: `c2452de1393264c2b01ef8aa03908077bce025db` +Task: `t_a0ef9422` + +## Conclusion + +Do not implement a standalone lexer-first route-export discovery change. + +`es-module-lexer` is already in the hot path, but only after `transformToEsm` has produced parseable ESM (`src/export-utils.ts:52-81`, `src/index.ts:1377-1378`, `src/index.ts:1749-1762`). For route modules, the transform is still load-bearing for TS/TSX/JSX, default-export normalization, and route-chunk analysis. A lexer-first experiment that skips the client-entry warmup only shifts the same transform cost into `route:module`; it does not create a real build-time win. + +The smallest safe optimization path is not “lexer first”, but a unified bundler-side route analysis cache that shares `{ transformed code, export names, optional chunk info }` across the existing transform hooks while keeping `route:client-entry` as the cache warmer. + +## Code-path evidence + +Current route analysis is split across two layers: + +1. Shared helper caches in `src/export-utils.ts` + - `transformCache` keyed by `(resourcePath, source)` at `src/export-utils.ts:24-24` + - `exportNamesCache` keyed by transformed `code` at `src/export-utils.ts:25-25` + - `routeModuleAnalysisCache` keyed by `(resourcePath, mtime, size)` for disk reads at `src/export-utils.ts:26-29`, `src/export-utils.ts:130-156` + +2. Bundler hooks in `src/index.ts` + - `route:client-entry` transforms + lexes + route-chunk detects at `src/index.ts:1367-1411` + - `route:split-exports` transforms + route-chunk detects + lexes at `src/index.ts:1476-1549` + - `route:chunk` transforms + chunk-generates at `src/index.ts:1414-1474` + - `route:module` transforms + SPA export validation + default-export rewrite + Babel parse/generate at `src/index.ts:1737-1825` + +The important point is that `route:client-entry` currently warms `transformCache` before `route:module` runs. Keeping that warmup matters because `route:module` still requires transformed code for correctness work that cannot be done from a raw lexer scan. + +## Design comparison + +| Design | What changes | Upside | Why it fails / succeeds | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Current transform-warming path | Leave `route:client-entry` as `transformToEsm(args.code)` + `getExportNames(code)` and let `route:module` reuse the cache | Correct today; required transform work is paid once when `args.code` matches disk/bundler content | Still has duplicate call sites and repeated bookkeeping across hooks, but the expensive transform is already shared through `transformCache` | +| Lexer-first + transform prewarm | Discover exports earlier with `es-module-lexer`, but still fire `transformToEsm` to warm later hooks | Looks cheaper on paper if you count only export extraction | No real net win for route modules: TS/TSX/JSX cannot be lexed directly, so you still need `transformToEsm`; if you skip that warmup the cost just moves into `route:module`; if you keep it, you have nearly the same work plus more coordination | +| Unified route analysis cache | Cache bundler-side analysis once per `(resourcePath, args.code)` and reuse it across `route:client-entry`, `route:split-exports`, `route:module`, and optionally `route:chunk` | Attacks the actual duplication boundary: repeated “transform → export scan → maybe route-chunk detect” preludes | Safest real improvement path. Must preserve hook-specific post-processing and keep route-chunk work lazy/off unless needed | + +## Correctness constraints that any redesign must preserve + +1. TS / TSX / JSX / MTS inputs still require esbuild loader normalization + - `JS_LOADERS` maps `.ts/.tsx/.jsx/.js/.mjs/.mts` to esbuild loaders in `src/constants.ts:3-19`. + - `transformToEsm` depends on that loader selection in `src/export-utils.ts:47-67`. + - Raw `es-module-lexer` on source text is therefore unsafe for common route files. + +2. `route:module` still needs transformed code beyond export discovery + - SPA-mode validation reads export names from transformed code at `src/index.ts:1755-1790`. + - The default export is normalized with a regex rewrite at `src/index.ts:1792-1805` before Babel parses the module. + - Any shared cache must either return pre-rewrite transformed code plus let `route:module` keep this rewrite, or explicitly model a separate post-processed `routeModuleCode` variant. + +3. Re-export behavior is intentionally narrow for route modules + - Route-module paths use `getExportNames(code)` only (`src/export-utils.ts:83-104`, `src/index.ts:1378`, `src/index.ts:1762`). + - The only place that resolves `export * from` recursively is the `.client` stub path via `getExportNamesAndExportAll` at `src/export-utils.ts:106-127` and `src/index.ts:1588-1722`. + - A lexer-first refactor must not accidentally expand or break route-module export semantics around re-exports without an intentional product decision. + +4. Route-chunk mode depends on the same transformed code string and lazy chunk analysis + - `detectRouteChunksIfEnabled` and `getRouteChunkIfEnabled` both key off normalized file path + exact `code` string in `src/route-chunks.ts:835-889`. + - `route:client-entry`, `route:split-exports`, and `manifest.ts` all feed the same transformed code shape into that cache. + - A redesign that makes the code strings diverge will silently defeat chunk-cache reuse. + +5. Manifest/disk-path unification still has one raw-source dependency today + - `manifest.ts` uses `source` for the dev CSS fallback regex at `src/manifest.ts:191-199`. + - If future work merges disk and bundler analysis more aggressively, that fallback either needs to move to transformed `code` or remain available separately. + +## Benchmark evidence from this run + +Artifacts: + +- `.benchmark/results/triage-smoke-current/baseline.json` +- `.benchmark/results/triage-default-current/baseline.json` + +Commands run: + +```sh +node scripts/bench-builds.mjs \ + --profile smoke \ + --iterations 1 \ + --warmup 0 \ + --format both \ + --out .benchmark/results/triage-smoke-current + +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 1 \ + --warmup 0 \ + --clean build \ + --format both \ + --out .benchmark/results/triage-default-current +``` + +Observed results: + +### Smoke (48-route SSR ESM) + +- Wall: `1071.2 ms` +- Max RSS: `307152 kB` +- Web compiler lifecycle: `760.9 ms` +- Node compiler lifecycle: `845.5 ms` +- Web `route:client-entry.totalMs`: `1712.3 ms` +- Web `route:module.totalMs`: `73.6 ms` + +### 256-route non-split vs split (same run, same commit) + +Non-split `synthetic-256-ssr-esm` + +- Wall: `1937.2 ms` +- Max RSS: `501884 kB` +- Web compiler lifecycle: `1250.2 ms` +- Node compiler lifecycle: `1446.1 ms` +- Web `route:client-entry.totalMs`: `36337.2 ms` +- Web `route:module.totalMs`: `240.8 ms` + +Split `synthetic-256-ssr-esm-split` + +- Wall: `2201.0 ms` +- Max RSS: `694036 kB` +- Web compiler lifecycle: `1681.9 ms` +- Node compiler lifecycle: `1872.9 ms` +- Web `route:client-entry.totalMs`: `76313.8 ms` +- Web `route:module.totalMs`: `224.2 ms` +- Web `route:chunk.totalMs`: `84524.4 ms` + +Delta (split - non-split) + +- Wall: `+263.8 ms` (`+13.6%`) +- Max RSS: `+192152 kB` (`+38.3%`) +- Web compiler lifecycle: `+431.7 ms` +- Node compiler lifecycle: `+426.8 ms` +- Web `route:client-entry.totalMs`: `+39976.6 ms` +- Web `route:module.totalMs`: `-16.6 ms` + +Interpretation: + +- The split build’s extra cost is not showing up as a `route:module` surge. +- The big additional work is in `route:chunk` plus heavier `route:client-entry`/split-route activity. +- That makes the earlier “move lexer work out of client-entry” idea especially unconvincing: `route:module` is not the dominant split-build hotspot here, and simply relocating transform cost there is unlikely to improve total wall time. + +Important caveat: `totalMs` overcounts concurrent async spans, so the ground-truth numbers here are wall-clock and compiler lifecycle times, not the raw sums of per-resource totals. + +## Smallest safe implementation path + +If we do follow-up work, it should be this, in order: + +1. Add a bundler-side route-analysis helper/cache + - Touch: `src/export-utils.ts` or a new helper module. + - Shape: cache by `(resourcePath, args.code)` and return a promise for + `{ code, exportNames, chunkInfo? }`. + - Keep chunk info lazy so non-split routes do not pay Babel parse/traverse cost. + +2. Swap the three main hook preludes onto that helper + - Touch: `src/index.ts:1367-1411`, `src/index.ts:1476-1549`, `src/index.ts:1737-1825`. + - `route:client-entry` remains the warm path. + - `route:module` consumes the shared transformed code and keeps its SPA validation + default-export rewrite. + - `route:split-exports` consumes shared export names and shared/lazy chunk info. + +3. Only then consider manifest/prerender dedup + - Touch later: `src/manifest.ts:185-238`, `src/index.ts:758-778`. + - First move the CSS fallback off raw `source` (`src/manifest.ts:191-199`), then thread export names/analysis out of manifest generation so prerender validation stops re-walking routes. + +This is the smallest path that can plausibly reduce real work instead of shuffling it between hooks. + +## Recommendation + +Reject a standalone lexer-first route-export-discovery change as “not worth it”. + +Recommended follow-up instead: + +- Implement a unified bundler-side route analysis cache. +- Measure it with the existing harness. +- Keep the disk/manifest-side dedup as a second phase only after the bundler-side helper proves a wall-clock win. + +Suggested follow-up card title: + +- `Implement unified bundler-side route analysis cache (keep client-entry transform warmup)` + +Suggested benchmark commands for that future A/B: + +```sh +# quick correctness / smoke +node scripts/bench-builds.mjs \ + --profile smoke \ + --iterations 1 \ + --warmup 0 \ + --format both \ + --out .benchmark/results/-smoke + +# canonical 256-route comparison: compare split and non-split rows from the same JSON +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/-baseline +``` + +For final sign-off, the stronger profile from the existing methodology docs is still appropriate: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 8 \ + --warmup 2 \ + --clean build \ + --format both \ + --out .benchmark/results/-final +``` diff --git a/task/route-chunk-correctness-test-spec.md b/task/route-chunk-correctness-test-spec.md new file mode 100644 index 0000000..43b38d2 --- /dev/null +++ b/task/route-chunk-correctness-test-spec.md @@ -0,0 +1,437 @@ +# Route Chunk Correctness — Test Specification + +**Kanban:** `t_1c0421c6` (feeds triage `t_d3ed9b84` → plan `t_f8636ea4`) +**Branch:** `perf/bundling-performance` (PR #39) +**Status:** SPEC ONLY — no test bodies implemented. Each entry below is ready for an +implementer to write against. Behavior values marked **(verified)** were produced by +running the real `src/route-chunks.ts` functions against the listed fixtures on the +current head (`c2452de`); they are the golden values the tests must pin. + +--- + +## 0. What this spec protects + +A future change precomputes all chunk analysis for one route in a single parse/traverse +pass (see sibling tasks `t_0f2688a9`, `t_34486796`). That refactor must not change any +externally observable result. This spec defines the exact tests that lock the current +behavior so the precompute can be proven equivalent. + +**The invariant, stated once:** For every route module, the triple +(detection result `RouteChunkInfo`, generated chunk code per `RouteChunkName`, +consumer-visible output in the rspack transforms and the React Router manifest) must be +byte-for-byte identical before and after the precompute refactor, across all five +dimensions: per-export splits, enforce mode, root route, empty/no-split modules, and +detection↔generation↔consumer consistency. + +--- + +## 1. Architecture recap (so tests target the right seams) + +Source: `src/route-chunks.ts`, `src/index.ts`, `src/manifest.ts`, `src/export-utils.ts`. + +``` + detectRouteChunksIfEnabled(cache, config, id, code) + ───────────────────────────────────────────────── + guards (return noRouteChunks, NO parse): ── detectRouteChunks + • config.splitRouteModules falsy ── hasChunkableExport ×4 + • isRootRouteModuleId(config, id) (getExportDependencies + • !routeChunkExportNames.some(name => code.includes(name)) one heavy traverse) + │ + getRouteChunkIfEnabled(cache, config, id, chunkName, code) + ──────────────────────────────────────────────── + • guard: config.splitRouteModules falsy (NOTE: no root guard — see §7) + • getRouteChunkCode: + 'main' → omitChunkedExports(code, allClientExports) + clientAction… → getChunkedExport(code, name) (undefined if !hasChunkableExport) + + CONSUMERS + index.ts entry creation (L433-449) substring source.includes(name) ← NOT full detect + index.ts ?react-router-route transform detectRouteChunksIfEnabled filters reexports + index.ts ?route-chunk= transform getRouteChunkIfEnabled emits chunk code + index.ts split-exports transform detectRouteChunksIfEnabled rewrites module→reexports + index.ts ?route-chunk= + enforce getExportNames(mainChunk) validateRouteChunks + manifest.ts getReactRouterManifestForDev detectRouteChunksIfEnabled sets *Module fields +``` + +Key asymmetries the tests MUST pin (these are intentional or at least load-bearing): + +- **A1** Entry creation uses a cheap `source.includes(name)` substring check, so a + non-splittable export still gets a bundler entry — but that entry resolves to an + `preventEmptyChunkSnippet` module, and the manifest omits the `*Module` field. (§8-H1) +- **A2** `getRouteChunkIfEnabled` has no root-route guard; only `detectRouteChunksIfEnabled` + does. (§7-E3) +- **A3** The substring guard in `detectRouteChunksIfEnabled` is a pre-filter; the parse + is the source of truth, so a comment mentioning `clientAction` does not create a chunk. (§6-F3) + +--- + +## 2. Verified-behavior reference table (golden values) + +Fixtures below were run through the real functions. `cfg(true)` = `{splitRouteModules:true, +appDirectory:'/app', rootRouteFile:'root.tsx'}`, id `/app/routes/r.tsx`. + +| Fixture | clientAction | clientLoader | clientMiddleware | HydrateFallback | main chunk | note | +| ---------------------------------------------------------------------------------------------- | ------------ | ------------ | ---------------- | --------------- | -------------------------------------- | --------------------------------------------- | +| one client export `export const clientAction = async () => {}` + default | true | false | false | false | omits clientAction | splittable | +| all four, each own helper + default | true | true | true | true | omits all four | splittable | +| `const helper; export default Route(){helper()}; export const clientAction=()=>helper()` | **false** | false | false | false | full module | shares top-level stmt w/ default (§4-B2) | +| `const shared; export const clientAction=()=>shared(); export const clientLoader=()=>shared()` | false | false | false | false | full module | existing test; shares helper | +| `function make(); export const { clientAction } = make()` + default | **true** | false | false | false | omits clientAction | single-bind destructure IS chunkable (§4-B3a) | +| `function make(); export const { clientAction, foo } = make()` + default | **false** | false | false | false | full module | shared declarator w/ foo (§4-B3b) | +| `export const clientAction; export const clientLoader` (no default) | true | true | false | false | **undefined** | empty main (§5-C3) | +| `import {json}; export async function action(){json()}; export default Route` | false | false | false | false | full module incl. import | no client exports (§6-G2) | +| `// clientAction in a comment` + default | false | false | false | false | full module incl. comment | substring false positive (§6-F3) | +| same clientAction code, id `/app/root.tsx` (detect) | false | false | false | false | — | root route (§7-E1) | +| same clientAction code, id `/app/root.tsx` (getRouteChunkIfEnabled 'clientAction') | — | — | — | — | generates `export const clientAction…` | root asymmetry (§7-E3) | + +--- + +## 3. File layout (where each test lives) + +| File | Type | Covers | +| -------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------- | +| `tests/route-chunks.test.ts` (EXPAND existing) | unit, pure fns | §4 detection, §5 generation, §6 disabled/empty, §7 root, §9 cache | +| `tests/route-chunks-cache.test.ts` (NEW) | unit | §9 cache versioning + single-pass equivalence (the core regression guards) | +| `tests/manifest-split-route-modules.test.ts` (EXPAND existing) | integration | §8-H1/H2 manifest consumer + enforce at manifest level | +| `tests/route-chunk-transforms.test.ts` (NEW) | integration via stub Rsbuild | §8-H3/H4 bundler transforms + preventEmptyChunkSnippet | +| `tests/fixtures/route-chunks/` (NEW) | fixtures | shared module snippets for §4–§5 | + +Conventions: rstest (`@rstest/core`), tests are ESM, `await` the async functions, +`setup.ts` mocks `node:fs` and provides `createStubRsbuild` (already wired). Fixtures are +plain `.tsx` strings — detection operates on code strings, not files, so inline template +literals are preferred; use `tests/fixtures/` only for the transform-integration tests that +must read real files. + +--- + +## 4. Detection unit tests → `tests/route-chunks.test.ts` (describe "detect route chunks") + +All call `detectRouteChunksIfEnabled(cache, cfg(true), '/app/routes/r.tsx', code)` with a +fresh `new Map()` cache. Assert the full `RouteChunkInfo` shape +(`hasRouteChunks`, `hasRouteChunkByExportName`, `chunkedExports`). + +**D-Detect-01 — each client export is independently splittable (parametrized ×4)** +Fixture (per export `E` in `[clientAction, clientLoader, clientMiddleware, HydrateFallback]`): + +```ts +export const E = async () => {}; // HydrateFallback uses: export function HydrateFallback(){return null} +export default function Route() { + return null; +} +``` + +Expected: `hasRouteChunkByExportName[E]===true`, the other three `false`, `hasRouteChunks===true`, +`chunkedExports===[E]`. Covers function-decl vs const-arrow declaration forms. + +**D-Detect-02 — all four splittable together** +Fixture: all four exports, each referencing its own local helper (no sharing), + default. +Expected: all four `true`, `hasRouteChunks===true`, `chunkedExports` length 4 (order = +`routeChunkExportNames` order). + +**D-Detect-03 — export depends on an import** +Fixture: `import {json} from 'react-router'; export const clientLoader = async()=>json({});` + default. +Expected: `clientLoader===true` (imports do not block chunkability). + +**D-Detect-04 — two client exports share a top-level helper (not chunkable)** [existing, keep] +Fixture: `const shared=()=>{}; export const clientAction=async()=>shared(); export const clientLoader=async()=>shared();` +Expected: both `false`, `hasRouteChunks===false`. (existing test asserts clientAction/clientLoader false.) + +**D-Detect-05 — client export shares top-level code with the DEFAULT export (not chunkable)** +Fixture: `const helper=()=>{}; export default function Route(){return helper();} export const clientAction=async()=>helper();` +Expected: `clientAction===false`, `hasRouteChunks===false`. **(verified)** Pins that the +default export participates in the shared-statement intersection. + +**D-Detect-06a — single-binding destructuring IS chunkable** +Fixture: `function make(){return{clientAction:async()=>{}}} export const{clientAction}=make();` + default. +Expected: `clientAction===true`, `chunkedExports===['clientAction']`. **(verified)** + +**D-Detect-06b — multi-binding destructuring sharing a declarator is NOT chunkable** +Fixture: `function make(){return{clientAction:async()=>{},foo:1}} export const{clientAction,foo}=make();` + default. +Expected: `clientAction===false` (shares declarator with sibling export `foo`). **(verified)** + +**D-Detect-07 — chunkable export isolated from a non-chunkable sibling** +Fixture: clientAction self-contained (chunkable) + clientLoader sharing a helper with default (not chunkable). +Expected: `clientAction===true`, `clientLoader===false`, `hasRouteChunks===true`, +`chunkedExports===['clientAction']`. Pins partial-split detection. + +**D-Detect-08 — `chunkedExports` ordering follows `routeChunkExportNames`** +Fixture: exports in source order HydrateFallback, clientLoader, clientAction, all splittable. +Expected: `chunkedExports===['clientAction','clientLoader','HydrateFallback']` (declaration +order in source must not leak into the result order). + +--- + +## 5. Generated-code unit tests → `tests/route-chunks.test.ts` (describe "generate route chunk code") + +Call `getRouteChunkIfEnabled(cache, cfg(true), id, chunkName, code)` (or `getRouteChunkCode` +directly). Assert by re-parsing the output with `getExportNames` (from `src/export-utils`) and +checking membership — do NOT assert exact whitespace. + +**G-Gen-01 — main chunk omits all chunkable client exports, keeps default + server exports** +Fixture: `import{json}from'react-router'; export async function action(){return json({})} export const clientAction=async()=>{}; export default function Route(){return null}`. +Expected (`chunkName='main'`): output exports include `default` and `action`, exclude `clientAction`. + +**G-Gen-02 — individual client chunk contains only that export + its deps** +Same fixture, `chunkName='clientAction'`: output exports === `['clientAction']` only. Does +not contain `default`/`action`. + +**G-Gen-03 — client chunk retains only used import specifiers** +Fixture: `import{json,useFetcher}from'react-router'; export const clientLoader=async()=>json({}); export default function Route(){return null}`. +`chunkName='clientLoader'`: output contains `import{json}` but NOT `useFetcher`. + +**G-Gen-04 — main chunk is `undefined` when only client exports exist** +Fixture: `export const clientAction=async()=>{}; export const clientLoader=async()=>{};` (no default). +`chunkName='main'` → result `null`/`undefined`. **(verified)** This is the empty-main edge +that maps to `preventEmptyChunkSnippet` in the bundler. + +**G-Gen-05 — non-chunkable export yields `undefined` chunk** +Fixture from D-Detect-05 (clientAction shares with default). `chunkName='clientAction'` → +`null`/`undefined` (because `!hasChunkableExport`). **(verified)** + +**G-Gen-06 — main chunk for a module with NO chunkable exports returns the full module** +Fixture from §6-G2 (only `action`+default). `chunkName='main'` → full source regenerated, +exports include `default`,`action`; nothing omitted. **(verified)** + +**G-Gen-07 — `getRouteChunkCode` dispatch: 'main'→omit, named→extract** +Direct unit test of `getRouteChunkCode(code,'main',…)` vs `getRouteChunkCode(code,'clientAction',…)` +asserting they route to `omitChunkedExports` / `getChunkedExport` respectively (compare outputs +against calling those paths). Pin the public dispatch contract. + +**G-Gen-08 — module-id helpers round-trip** +`getRouteChunkModuleId('/app/routes/r.tsx','clientAction')` === `'/app/routes/r.tsx?route-chunk=clientAction'`; +`isRouteChunkModuleId(that)===true`; `getRouteChunkNameFromModuleId(that)==='clientAction'`; +`getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=main')==='main'`; +`getRouteChunkNameFromModuleId('/app/routes/r.tsx')===null`; +`getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=bogus')===null`; +`getRouteChunkEntryName('routes/clients','clientAction')==='routes/clients-client-action'`. + +--- + +## 6. Disabled / empty / no-split tests → `tests/route-chunks.test.ts` (describe "mode + early-exit") + +**F-Mode-01 — splitRouteModules falsy returns noRouteChunks without parsing** +`detectRouteChunksIfEnabled(cache, cfg(false), id, clientActionCode)` → all four `false`, +`hasRouteChunks===false`. Also `cfg(undefined)` (splitRouteModules absent). Assert no parse +side-effect is observable (e.g. malformed code does NOT throw when disabled — feed syntactically +invalid code and assert clean noRouteChunks return). + +**F-Mode-02 — substring guard early-exits when no client export name appears** +`detectRouteChunksIfEnabled(cache, cfg(true), id, 'export default function Route(){return null}')` +→ all false. Asserts the fast path. + +**F-Mode-03 — substring false positive does not create a chunk** **(verified)** +Code: `// clientAction mentioned in a comment only` + default. Substring guard passes (parse +runs) but `hasChunkableExport` returns false → all four `false`. Pins that the parse is the +source of truth, not the substring filter. + +**G-Empty-01 — route with default only: detect no-op** +Already covered by F-Mode-02 shape; assert `hasRouteChunks===false`. + +**G-Empty-02 — `getRouteChunkIfEnabled` returns null when disabled** +`cfg(false)` → `getRouteChunkIfEnabled(…,'main',clientActionCode)===null` regardless of content. + +--- + +## 7. Root-route tests → `tests/route-chunks.test.ts` (describe "root route") + +**E-Root-01 — detect returns noRouteChunks for the root route id** [existing, keep] +`detectRouteChunksIfEnabled(cache, cfg(true), '/app/root.tsx', clientActionCode)` → all false. + +**E-Root-02 — root detection is path-normalized (query strings, relative segments)** +Assert `isRootRouteModuleId` equivalence via detect on ids: + +- `/app/root.tsx` ✓ root +- `/app/./root.tsx` ✓ root (normalize) +- `/app/root.tsx?react-router-route` ✓ root (query stripped by `normalizeRelativeFilePath`) +- `/app/routes/root.tsx` ✗ not root +- windows-style or trailing slashes per `pathe.normalize` behavior — document expected. + +**E-Root-03 — `getRouteChunkIfEnabled` has NO root guard (asymmetry pin)** **(verified)** +`getRouteChunkIfEnabled(cache, cfg(true), '/app/root.tsx','clientAction', clientActionCode)` +returns the generated `export const clientAction…` — NOT null. This is the intentional +asymmetry: detection gates root, generation does not. Test pins current behavior so the +precompute refactor preserves it (callers only request root chunks they never created). + +**E-Root-04 — validateRouteChunks is a no-op for root route** +`validateRouteChunks({config:cfg('enforce'), id:'/app/root.tsx', valid:{clientAction:false,…}})` +does NOT throw. Pins the `isRootRouteModuleId` early return in `validateRouteChunks`. + +--- + +## 8. Enforce + consumer-consistency tests + +### 8a. Enforce unit → `tests/route-chunks.test.ts` (describe "enforce mode") + +`validateRouteChunks` throws iff any `valid[name]===false` for a non-root route, regardless +of caller. Enforce vs. plain-`true` gating happens at the call sites (manifest/index). + +**V-Enforce-01 — all valid → no throw** +`validateRouteChunks({config:cfg('enforce'), id:'/app/routes/r.tsx', valid:{clientAction:true,clientLoader:true,clientMiddleware:true,HydrateFallback:true}})` returns silently. + +**V-Enforce-02 — one invalid → throws naming the export** [existing, keep/extend] +valid has clientAction:false only. Assert `throwError(/Error splitting route module/)` AND the +message contains `clientAction` and the singular guidance phrasing ("This export…its own chunk…shares"). + +**V-Enforce-03 — multiple invalid → throws plural message listing all** +valid: clientAction:false, clientLoader:false. Assert message lists both and uses plural +phrasing ("These exports…their own chunks…they share"). Pins the `plural` branch. + +**V-Enforce-04 — enforce skipped for root** (cross-ref E-Root-04) + +### 8b. Manifest consumer → `tests/manifest-split-route-modules.test.ts` (EXPAND) + +Use the existing `createTempApp()` helper (writes `app/root.tsx` + a route file). Build a +`clientStats.assetsByChunkName` map. + +**M-Manifest-01 — clientActionModule set when splittable** [existing, keep] +Route exports self-contained clientAction → `manifest.routes[…].clientActionModule` points to +the `…-client-action.js` asset. Repeat the shape for clientLoaderModule, clientMiddlewareModule, +hydrateFallbackModule (parametrized). + +**M-Manifest-02 — \*Module fields omitted in dev** [existing, keep] +`isBuild:false` → all four `*Module` fields undefined even when exports present. + +**M-Manifest-03 — \*Module field omitted when export is NOT splittable** **(H1 critical)** +Route file where clientAction shares a top-level helper with default (D-Detect-05 fixture). +Build mode. Expected: `hasClientAction===true` (export exists) BUT +`clientActionModule===undefined` (not splittable, so `hasRouteChunkByExportName.clientAction===false`). +Pins the entry/manifest asymmetry: a bundler entry may still be created (substring), but the +manifest must not advertise a module that was not split. + +**M-Manifest-04 — enforce throws at manifest level for unsplittable export** +`splitRouteModules:'enforce'`, build mode, route with clientAction sharing code (D-Detect-05). +Expected: `getReactRouterManifestForDev` rejects / `validateRouteChunks` throws inside it. +Assert the throw propagates (wrap call in `expect(…).rejects.toThrow(/Error splitting route module/)`). + +**M-Manifest-05 — plain `true` (non-enforce) does NOT throw for unsplittable** +Same route as M-Manifest-04 but `splitRouteModules:true`. Expected: manifest resolves without +throwing; `clientActionModule===undefined`, `hasClientAction===true`. Pins that enforce gating +is at the call site, not in detect. + +**M-Manifest-06 — root route: no \*Module fields even with client exports** +Root route file exports clientAction. Build + split. Expected: all `*Module` undefined on the +root entry (detect returned noRouteChunks for root). + +### 8c. Bundler-transform consumer → `tests/route-chunk-transforms.test.ts` (NEW) + +These exercise the three `api.transform` hooks in `src/index.ts`. Use `createStubRsbuild` +(from `setup.ts`) to drive `reactRouter()` setup, then assert on the `transform` spy calls or +on `processAssets` output. **Mark these `it.skip` with a TODO if the stub harness cannot yet +isolate a single transform invocation** — they are the highest-value but hardest tests. + +**T-Transform-01 — split-exports rewrites a chunkable route module to reexport stubs (H3)** +Route with splittable clientAction + default. Assert the generated module code is: + +``` +export { default } from "./r.tsx?route-chunk=main"; +export { clientAction } from "./r.tsx?route-chunk=clientAction"; +``` + +(non-chunked names go to `main`; each `chunkedExports` name gets its own reexport line.) + +**T-Transform-02 — non-chunkable route module is passed through unchanged (H3)** +Route with only `action`+default (no client exports): split-exports transform returns original +code (`hasRouteChunks===false` no-op branch). + +**T-Transform-03 — `?route-chunk=` returns generated chunk or preventEmptyChunkSnippet (G3)** +For a splittable clientAction module id `…?route-chunk=clientAction`: transform returns the +generated chunk code. For a disabled/non-build config: returns +`Math.random()<0&&console.log("…");`. For a non-chunkable export: chunk is null → snippet. + +**T-Transform-04 — enforce validates the generated MAIN chunk (H4)** +Enforce + splittable route: main chunk generated → `getExportNames(main)` excludes client +exports → `validateRouteChunks` passes. Inject a fixture where main would still contain a +client export (regression sim) and assert the transform throws. Pins the generate→validate loop. + +**T-Transform-05 — entry map created per substring, not per detect (H1)** +Build + split, route whose clientAction shares code (non-splittable). Assert +`webRouteEntries` contains a `routes/r-client-action` entry (substring match created it) even +though detection says not-splittable. (Assert via unwrapConfig or a spy on the entries object.) + +--- + +## 9. Cache + single-pass equivalence tests → `tests/route-chunks-cache.test.ts` (NEW) + +These are the **most important regression guards for the precompute refactor.** They prove a +single-pass precomputed analysis produces identical results to today's per-call cache. + +**C-Cache-01 — version invalidation on content change** +cacheKey = `/app/routes/r.tsx`. Call `detectRouteChunksIfEnabled` with code A (clientAction +chunkable), then with code B (clientAction non-chunkable, e.g. shares helper). Same cache +instance, same cacheKey. Assert B's result reflects B, not a stale A. Pins that `version===code` +keys actually invalidate. + +**C-Cache-02 — same code + cacheKey returns cached result (no recompute)** +Spy/stub `parse` (or count via a module-level counter in a throwaway double) and assert that a +second `detectRouteChunksIfEnabled` with identical code does not re-parse. Pins the cache hit path. + +**C-Cache-03 — structuredClone isolation: mutating a returned AST does not corrupt the cache** +This guards `codeToAst`'s `structuredClone`. Call `getExportDependencies` (or any path that +returns derived data), then call again with the same code; assert the second result equals the +first byte-for-byte even if test code mutated the first return's structures. (If the public API +does not expose AST, frame as: two sequential identical calls return deeply-equal results and +the second is served from cache.) + +**C-Cache-04 — single-pass equivalence: detect + all chunks == per-export calls** ★ +The headline test. For a fixture with all four client exports splittable + shared-code +siblings, compute via the CURRENT per-export API: + +- `info = detectRouteChunksIfEnabled(…)` +- `main = getRouteChunkIfEnabled(…,'main',…)` +- for each name: `chunk[name] = getRouteChunkIfEnabled(…, name, …)` + Then (after the refactor) compute via the NEW precompute API (e.g. a hypothetical + `analyzeRouteChunks(code, config, id)` returning `{info, chunks: Record}`) + and assert `info`, `main`, and every `chunk[name]` are identical. Until the new API exists, + write this test against the current API as the **reference oracle** and mark the new-API half + `it.skip('TODO: re-enable when precompute API lands')`. + +**C-Cache-05 — undefined cache (no Map) still computes correct results** +Pass `undefined` as cache to all functions; `getOrSetFromCache` short-circuits to `getValue()`. +Assert results identical to the cached path (C-Cache-04 oracle). Pins the no-cache fallback. + +**C-Cache-06 — cache is shared across index + manifest callers (H2)** ★ +Simulate the real wiring: one `routeChunkCache` Map is passed to both the manifest path +(`getReactRouterManifestForDev(…, {cache}`) and the index transform path. For the same route +module, assert both derive the same `hasRouteChunkByExportName`. This is the consistency +property the precompute must guarantee — a single analysis object feeding both consumers. + +--- + +## 10. Coverage matrix + +| Task-body dimension | Tests | +| ----------------------------- | ------------------------------------------------------- | +| split: clientAction | D-Detect-01, D-02, D-03, D-04, D-05, D-07, G-Gen-01..07 | +| split: clientLoader | (same set, parametrized) | +| split: clientMiddleware | (same set, parametrized) | +| split: HydrateFallback | (same set, parametrized; function-decl form) | +| enforce enabled | V-Enforce-01..04, M-Manifest-04 | +| enforce disabled (plain true) | M-Manifest-05 | +| enforce error behavior | V-Enforce-02, V-Enforce-03, M-Manifest-04 | +| root route | E-Root-01..04, M-Manifest-06 | +| no split exports | G-Gen-06, F-Mode-02, T-Transform-02 | +| empty chunks | G-Gen-04, G-Gen-05, T-Transform-03 | +| detection ↔ generated code | G-Gen-01..07, T-Transform-01, T-Transform-04 | +| consumed by index caller | T-Transform-01..05 | +| consumed by manifest caller | M-Manifest-01..06 | +| precompute equivalence | C-Cache-01..06 (esp. C-Cache-04, C-Cache-06) | + +--- + +## 11. Implementation notes for the implementer + +1. **Order:** write §4–§7 first (pure units, fast, no harness). They validate the golden + table in §2. Then §9 (cache) — the regression backbone. Then §8b (manifest, uses + `createTempApp`). Leave §8c (transforms) for last; if the stub harness can't isolate a + transform, ship them as `it.skip` with the assertion encoded in a comment. +2. **Assertions on generated code:** always re-parse with `getExportNames` and assert on + export membership / import specifier presence — never on `generate()` whitespace. +3. **The substring guard (F-Mode-03) and root asymmetry (E-Root-03) are deliberate load-bearing + behaviors, not bugs.** Tests pin them so the precompute doesn't "fix" them and break callers. +4. **C-Cache-04 is the single most valuable test** — it is the equivalence oracle. Build the + precompute against it. +5. **H1 (M-Manifest-03, T-Transform-05)** documents that bundler entries and manifest fields + can disagree for non-splittable exports. The precompute must preserve this disagreement + exactly (entry created via substring; module field absent via detect). +6. rstest config (`rstest.config.ts`) already includes `tests/**/*.test.ts` and loads + `tests/setup.ts`; new test files are picked up with no config change. diff --git a/task/route-chunk-precompute-plan.md b/task/route-chunk-precompute-plan.md new file mode 100644 index 0000000..0359452 --- /dev/null +++ b/task/route-chunk-precompute-plan.md @@ -0,0 +1,321 @@ +# Implementation Plan: Single-Pass Route Chunk Precompute + +**Kanban:** `t_f8636ea4` (synthesis) → triage root `t_d3ed9b84` +**Branch:** `perf/bundling-performance` (PR #39 — _Add React Router plugin performance benchmarks_) +**Head at authoring:** `c2452de` +**Scope of this plan:** `src/route-chunks.ts` only (no edits to `src/index.ts` or `src/manifest.ts`). + +**Source artifacts this plan synthesizes (read these for full detail, the plan below is self-contained):** + +- `route-chunk-parse-traverse-analysis.md` — current-behavior map (parent `t_0f2688a9`) +- `.benchmark/design/route-chunk-analysis.md` — cache representation design (parent `t_34486796`) +- `task/route-chunk-correctness-test-spec.md` — 50+ named correctness tests (parent `t_1c0421c6`) +- `benchmarks/chunk-precompute-methodology.md` — A/B benchmark commands (parent `t_4d84984e`) + +--- + +## 0. Headline answers (acceptance criteria, up front) + +| Question | Answer | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Can all chunks for one route be computed from one parse/traverse pass? | **Yes.** Parse and traverse are _already_ single-pass today (cached once per `(path, code)`). The avoidable cost is not re-parsing — it is (a) `structuredClone` of the full AST on every `codeToAst` call (~6× per splittable module) and (b) the `t.isNodesEquivalent` membership scans (O(body × deps) per generate). | +| Store generated chunk code, or AST + metadata? | **Store AST + index-based metadata, generate on demand (design "Option B").** Do NOT pre-generate and cache chunk strings: only the `?route-chunk=` transform hook ever reads chunk text (1 of the 4 consumers); the manifest + client-entry + split-exports hooks consume only `hasRouteChunkByExportName` / `chunkedExports`. Eagerly materializing 5 strings per module wastes the single biggest retained object. Generating from a pre-filtered node array is cheap; the expensive part today is the parse + full-AST clone _before_ generate, which Option B removes entirely while preserving byte-for-byte output. | +| Exact tests? | §6 below: 3 existing → ~50 tests per `task/route-chunk-correctness-test-spec.md`; the differential equivalence oracle (`C-Cache-04`) is mandatory before flipping the default. | +| Exact benchmark commands? | §7 below, lifted from `benchmarks/chunk-precompute-methodology.md`. | +| Implementer re-triage needed? | **No.** Steps §4 are ordered, name exact files/functions/line numbers, and each carries its own verification gate. | + +--- + +## 1. Current state (ground truth, verified at `c2452de`) + +All references are `src/route-chunks.ts` unless noted. + +``` +codeToAst (L87-97) → parse() cached at ${ck}::codeToAst; structuredClone RUNS ON EVERY CALL (cache hit or miss) +getExportDependencies (L158-315)→ one traverse() building Map; cached ${ck}::getExportDependencies +hasChunkableExport (L460-516) → set-intersection over ExportDependencies; cached ${ck}::hasChunkableExport::${name} +getChunkedExport (L518-617) → codeToAst(CLONE) + filter body via t.isNodesEquivalent + generate(); cached ${ck}::getChunkedExport::${name}::opts +omitChunkedExports (L619-758) → codeToAst(CLONE) + filter body via t.isNodesEquivalent + generate(); cached ${ck}::omitChunkedExports::${names}::opts +detectRouteChunks (L760-780) → hasChunkableExport ×4 +getRouteChunkCode (L782-797) → dispatch 'main'→omitChunkedExports, named→getChunkedExport +detectRouteChunksIfEnabled (L834-868) → guards (splitRouteModules / root / substring) then detectRouteChunks +getRouteChunkIfEnabled (L870-888) → guards (splitRouteModules only — NO root guard, intentional) then getRouteChunkCode +``` + +Per-module cost for a 4-export splittable route across one build (3 transform hooks + manifest + 5 `?route-chunk=` queries share one `routeChunkCache`): + +- `parse()`: **1×** (cached) — already optimal. +- `traverse()`: **1×** (cached) — already optimal. +- `generate()`: **5×** (main + 4 named) — inherent floor, each chunk is a distinct program. +- `structuredClone()`: **~6×** of the **full AST** (1 in `getExportDependencies` miss + 4 in `getChunkedExport` + 1 in `omitChunkedExports`) — **the avoidable hot spot.** +- `t.isNodesEquivalent` scans: O(body × deps) per generate — **the second avoidable cost.** + +Cache primitive: `getOrSetFromCache(cache, key, version, getValue)` (L69), `version === code` (full source text) at every site. The shared `routeChunkCache: RouteChunkCache = new Map()` is created once per plugin instance at `src/index.ts:403` and passed by reference to manifest (`index.ts:408`) and the three transform hooks (`index.ts:1384/1447/1510`). No config-coupled keying. + +--- + +## 2. Target design (what the implementer builds) + +Collapse the scatter of `getOrSetFromCache` entries (`codeToAst`, `getExportDependencies`, `hasChunkableExport` ×4, `getChunkedExport` ×N, `omitChunkedExports`) into **one analysis object per route module**, computed in one parse + one traverse, cached under one key. + +```ts +// NEW types in src/route-chunks.ts +type ExportDependencyIndex = { + // Indices into ast.program.body — plain serializable data, never node references. + topLevelStatementIndices: ReadonlySet; + topLevelNonModuleStatementIndices: ReadonlySet; + importedIdentifierNames: ReadonlySet; + exportedDeclaratorIndex: number; // -1 if not a var-declarator + exportedDeclaratorParentIndex: number; // for destructuring-export binding lookup +}; + +type RouteChunkAnalysis = { + readonly code: string; // doubles as cache version + readonly ast: t.File; // IMMUTABLE shared AST — consumers never mutate + readonly exports: Map; // keyed by export name + readonly topLevel: readonly t.Statement[]; // alias of ast.program.body (stable: body never reordered) + readonly chunkableExports: ReadonlySet; // materialized once from exports +}; +``` + +**Why indices, not node references:** the current `ExportDependencies` stores `Set` / `Set` and re-identifies them via `t.isNodesEquivalent` (L550/584/670/715). That is both mutation-unsafe (forces the per-call `structuredClone`) and O(n×m) per match. Index-based metadata is plain data, survives across the immutable shared AST with zero aliasing risk, and lets `getRouteChunkCode` select statements by array index in O(1). + +**Constructor:** + +```ts +// NEW in src/route-chunks.ts — replaces codeToAst+getExportDependencies+hasChunkableExport trio +const analyzeRouteModule = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): RouteChunkAnalysis => { + // one getOrSetFromCache under `${cacheKey}::analysis`, version = code. + // On miss: parse(code) once, traverse once to record ExportDependencyIndex map, + // derive chunkableExports (same intersection rule as hasChunkableExport L477-513), + // return the analysis. Reuse getDependentIdentifiersForPath / + // getTopLevelStatementPathForPath helpers unchanged — just record body.indexOf(path.node). +}; +``` + +**Consumers rewritten:** + +- `detectRouteChunks` → reads `analysis.chunkableExports`; no per-export `hasChunkableExport` calls. +- `getChunkedExport` / `omitChunkedExports` → `analyzeRouteModule(...)`, select `analysis.topLevel[i]` by stored indices, build `t.program([...])`, call `t.cloneNode(node, false)` only on the narrowed import/export nodes, `generate()`. **Delete the `t.isNodesEquivalent` scans (L550/584/670/715) entirely** — selection is by index. +- `codeToAst` → **deleted** (no callers after the rewrite). +- `getExportDependencies` body → moves into the `analyzeRouteModule` miss-closure, refactored to record indices; the standalone function is removed. +- `hasChunkableExport` → removed; logic folds into `analyzeRouteModule`'s `chunkableExports` derivation. + +**Public signatures unchanged:** `detectRouteChunks`, `getRouteChunkCode`, `detectRouteChunksIfEnabled`, `getRouteChunkIfEnabled`, `validateRouteChunks` keep their current signatures. `src/index.ts` and `src/manifest.ts` need **zero edits** — they already pass the shared `routeChunkCache`. + +**Root route, substring guard, enforce validation, empty-chunk snippet:** stay exactly where they are (pre-analysis early returns / caller policy). The analysis is a pure function of source code and must not encode any of them — see `.benchmark/design/route-chunk-analysis.md` §9 for the rationale (baking root-route suppression into the cache would couple the key to config and break cross-caller reuse). + +--- + +## 3. Toggle (transient scaffolding, not a permanent flag) + +To measure old vs new on **one commit** (required by the benchmark methodology), gate the new path behind an env var for exactly one measured commit, then delete it. + +```ts +// src/route-chunks.ts +const PRECOMPUTE_ENABLED = process.env.ROUTE_CHUNK_PRECOMPUTE === '1'; +``` + +- `detectRouteChunks` and `getRouteChunkCode` branch on `PRECOMPUTE_ENABLED`: old branch keeps today's codeToAst/structuredClone/isNodesEquivalent path; new branch calls `analyzeRouteModule` + index selection. +- The toggle exists **only** for the A/B benchmark + differential-equivalence commit. The very next commit (after §6 + §7 are green) deletes the old branch and the constant — it is not a shipped feature flag. (If a permanent opt-out is later wanted, promote it to `pluginReactRouter({ future: { v8_routeChunkPrecompute } })`, but that is out of scope here.) + +--- + +## 4. Ordered implementation steps + +Each step is independently verifiable. Do not skip the RED-test step — it is the contract the refactor is proven against. + +### Step 0 — RED: pin current behavior (no src changes) + +**Files:** `tests/route-chunks.test.ts` (expand), `tests/route-chunks-cache.test.ts` (new), `tests/fixtures/route-chunks/` (new). +**What:** Implement §4–§9 of the correctness spec against the **current** API. Concretely: `D-Detect-01..08`, `G-Gen-01..08`, `F-Mode-01..03`, `E-Root-01..04`, `V-Enforce-01..04`, `C-Cache-01..06` (write `C-Cache-04` against the current API as the reference oracle; mark the precompute-API half `it.skip`), and the `M-Manifest-01..06` expansions. Defer `T-Transform-01..05` (§8c) to Step 5 — they need the stub harness. +**Why:** These are the golden values the refactor must preserve byte-for-byte. Writing them first means every later step is gated by a green suite, not by reading prose. +**Verify:** `pnpm exec rstest run` — all new + existing (3) tests green against unchanged `src/`. +**Acceptance:** spec's verified-behavior table (§2) reproduced as passing assertions. + +### Step 1 — Add the analysis layer in parallel (old path still live) + +**File:** `src/route-chunks.ts`. +**What:** Add the `ExportDependencyIndex` + `RouteChunkAnalysis` types and `analyzeRouteModule`. Port the `getExportDependencies` body into the miss-closure, recording `body.indexOf(path.node)` instead of node references. Derive `chunkableExports` using the same intersection + single-declarator rule as `hasChunkableExport` (L477-513). Wire it through `setBoundedCacheEntry`-style insertion so the new single entry respects the existing cap (reuse the helper from `src/export-utils.ts`; the cap constant is `MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048`). Do **not** wire it into any consumer yet — it is dead code exercised only by a unit test. +**Why:** Isolates the representation change from the consumer rewrite. If indices are wrong, the failure is local to this step's unit test, not a cascade through 4 consumers. +**Verify:** add one unit test that calls `analyzeRouteModule` directly (export it test-only or via a thin internal wrapper) and asserts `chunkableExports` matches `hasChunkableExport` for every fixture from Step 0. `pnpm exec rstest run`. +**Acceptance:** analysis output == old detection output for all Step-0 fixtures. + +### Step 2 — Route detection through the analysis (toggle-gated) + +**File:** `src/route-chunks.ts`. +**What:** Branch `detectRouteChunks` on `PRECOMPUTE_ENABLED`. New branch returns `{ hasRouteChunks, hasRouteChunkByExportName, chunkedExports }` derived from `analyzeRouteModule(...).chunkableExports`. Old branch untouched. +**Verify:** `ROUTE_CHUNK_PRECOMPUTE=0 pnpm exec rstest run` (old path, all green) **and** `ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run` (new path, all green). The `C-Cache-04` oracle is the headline equivalence check. +**Acceptance:** both toggle values produce identical `RouteChunkInfo` for every fixture. + +### Step 3 — Chunk generation through the analysis (toggle-gated) + +**File:** `src/route-chunks.ts`. +**What:** Branch `getRouteChunkCode` (and through it `getChunkedExport` / `omitChunkedExports`) on `PRECOMPUTE_ENABLED`. New branch: `analyzeRouteModule(...)`, select `analysis.topLevel[i]` by the stored indices, `t.cloneNode(node, false)` on narrowed import/export nodes only, `t.program([...])`, `generate(program, {})`. **Delete the `t.isNodesEquivalent` scans in the new branch** — selection is by index. `generateOptions` stays `{}` (kept in the cache key for forward-compat, unchanged). Old branch untouched. +**Verify:** both toggle values green; additionally run the **byte-for-byte differential** — for every fixture × every chunk name, `ROUTE_CHUNK_PRECOMPUTE=0` output === `ROUTE_CHUNK_PRECOMPUTE=1` output (string equality). This is `C-Cache-04` extended to generation, and the design's mandatory safeguard (risk #4). +**Acceptance:** zero byte drift across all chunks. Emitted chunk hashes do not change. + +### Step 4 — Dev-mode immutability guard + +**File:** `src/route-chunks.ts`. +**What:** In the `analyzeRouteModule` miss-closure (dev/non-production only), `Object.freeze`-shallow `analysis.ast.program.body` and assert in each new-branch consumer that the array length is unchanged before/after selection. Add a code comment at every `t.cloneNode(node, false)` site stating the shallow-clone invariant (mutation reassigns only a top-level array property — `node.specifiers` / `declaration.declarations`). +**Why:** The whole design rests on `ast.program.body` never being reordered or mutated between analysis and generation. Today's code already treats it as read-only up to the post-clone mutation, so the guard is cheap insurance (design risk #1, #3). +**Verify:** `ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run`; the freeze guard must not fire on any fixture. + +### Step 5 — Transform-integration tests (§8c of the spec) + +**Files:** `tests/route-chunk-transforms.test.ts` (new), reuse `createStubRsbuild` from `tests/setup.ts`. +**What:** Implement `T-Transform-01..05`. If the stub harness cannot isolate a single transform invocation, ship as `it.skip` with the assertion encoded in a comment (per spec §11.1) — do not block the refactor on harness work. +**Verify:** `pnpm exec rstest run` (both toggle values for the non-skipped ones). + +### Step 6 — Cleanup: delete the old path and the toggle + +**File:** `src/route-chunks.ts`. +**What:** Remove the `PRECOMPUTE_ENABLED` constant, the old branches in `detectRouteChunks` / `getRouteChunkCode`, and the now-dead `codeToAst`, `getExportDependencies`, `hasChunkableExport` functions. Convert `C-Cache-04`'s `it.skip` precompute-API half into the live assertion (or delete the skip if the test already asserts via the now-only path). The differential test from Step 3 becomes a no-op (only one path) — keep it as a snapshot/golden regression or delete per `task/route-chunk-correctness-test-spec.md` guidance. +**Prerequisite:** §6 testing sequence green **and** §7 benchmark sequence shows the expected win (§5) with no RSS regression. +**Verify:** `pnpm exec rstest run` + `pnpm build` + `pnpm run format`. + +--- + +## 5. Expected performance wins + +Derived from the current-state map + design; confirm with §7 before locking in. + +| Metric (per splittable route module, 4 exports) | Today | After | Δ | +| ----------------------------------------------- | --------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | +| `parse()` calls | 1 (cached) | 1 | 0 — already optimal | +| `traverse()` calls | 1 (cached) | 1 | 0 — already optimal | +| `generate()` calls | 5 | 5 | 0 — inherent floor | +| `structuredClone(full AST)` calls | ~6 | **0** | −6 full-tree deep clones/module | +| `t.isNodesEquivalent` scans | O(body × deps) × 5 | **0** (index lookup) | removed | +| Cache map entries / module | ~8 | **1** | −87% entries; ~8× better LRU coverage at the 2048 cap | +| Peak transient memory | 6 full-AST clone copies/module | 0 transient clones | sharp drop in GC pressure | +| Steady-state retained | node-ref Sets + 1-5 chunk strings | index maps (≪ node Sets); 0 chunk strings by default | modest drop | + +Headline: **all chunks for one route already come from one parse + one traverse; the win is eliminating ~6 full-AST `structuredClone`s and the `isNodesEquivalent` scans per splittable module.** CPU-time and `route:chunk.totalMs` should drop with no peak-RSS regression beyond the retained `RouteChunkAnalysis` heap cost (quantified separately by the micro-benchmark). + +--- + +## 6. Testing sequence + +Conventions: rstest (`@rstest/core`), ESM, `tests/**/*.test.ts` auto-included via `rstest.config.ts`, `tests/setup.ts` mocks `node:fs` + provides `createStubRsbuild`. Assert generated code by re-parsing with `getExportNames` (from `src/export-utils`) and checking export/import membership — **never** assert `generate()` whitespace. + +```sh +# 0. Full suite, current code (baseline green) — run once before starting +pnpm exec rstest run + +# 1. After each step — both toggle values for Steps 2-5 +ROUTE_CHUNK_PRECOMPUTE=0 pnpm exec rstest run # old path +ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run # new path + +# 2. Type check + format + build (after Step 6) +pnpm run build +pnpm run format +``` + +**Mandatory tests (from `task/route-chunk-correctness-test-spec.md`):** + +- §4 detection: `D-Detect-01..08` (incl. verified single-bind destructure chunkable, multi-bind not, default-export sharing). +- §5 generation: `G-Gen-01..08` (incl. verified empty-main → `undefined`, non-chunkable → `undefined`). +- §6 mode/early-exit: `F-Mode-01..03` (incl. verified substring false-positive does not chunk). +- §7 root: `E-Root-01..04` (incl. **verified root-guard asymmetry** — `getRouteChunkIfEnabled` has NO root guard; pin it). +- §8 enforce + consumers: `V-Enforce-01..04`, `M-Manifest-01..06` (incl. **H1 critical** `M-Manifest-03` — entry created via substring but `*Module` field absent when not splittable), `T-Transform-01..05`. +- §9 cache: `C-Cache-01..06`. **`C-Cache-04` (single-pass equivalence oracle) and `C-Cache-06` (cache shared across index + manifest callers) are the headline regression guards — the refactor is built against them.** + +Today's `tests/route-chunks.test.ts` has 3 tests; the spec takes it to ~50. The implementer writes §4–§7 first (pure units), then §9 (cache backbone), then §8b (manifest via `createTempApp`), then §8c (transforms, `it.skip` if the stub can't isolate). + +--- + +## 7. Benchmark sequence + +Lifted verbatim from `benchmarks/chunk-precompute-methodology.md` — run after Step 5 (toggle live, both paths in one commit) and before Step 6 (cleanup). + +**Pre-flight:** + +```sh +git status --short # confirm tree state (note: src/performance.ts has an unrelated uncommitted sort tweak — commit/leave separately, not part of this plan) +pnpm install +pnpm build +node --version # record (v22.x here) +``` + +**End-to-end (primary comparison, 256 routes):** + +```sh +ROUTE_CHUNK_PRECOMPUTE=0 pnpm bench:baseline \ + --profile default --iterations 8 --warmup 2 --clean build \ + --format both --out .benchmark/results/lazy + +ROUTE_CHUNK_PRECOMPUTE=1 pnpm bench:baseline \ + --profile default --iterations 8 --warmup 2 --clean build \ + --format both --out .benchmark/results/precompute +``` + +Compare the `synthetic-256-ssr-esm-split` row (code path that changes) **and** the `synthetic-256-ssr-esm` row (non-split control — must show no meaningful diff; if it diverges, the toggle is leaking, which is a bug). + +**Scaling sweep (does the win grow with route count?):** + +```sh +for PRECOMPUTE in 0 1; do + ROUTE_CHUNK_PRECOMPUTE=$PRECOMPUTE pnpm bench:full \ + --profile full --filter split \ + --iterations 5 --warmup 1 --clean build \ + --out .benchmark/results/scale-precompute-$PRECOMPUTE +done +``` + +**Micro-benchmark (direct parse/traverse/generate/structuredClone attribution):** +Create `scripts/bench-chunk-analysis.mjs` (imports the analysis fns from `dist/`, runs over generated route modules in-process). Then: + +```sh +node scripts/bench-chunk-analysis.mjs --routes 256 --variant ssr-esm-split \ + --iterations 50 --warmup 5 --mode lazy --out .benchmark/results/micro-lazy.json +node scripts/bench-chunk-analysis.mjs --routes 256 --variant ssr-esm-split \ + --iterations 50 --warmup 5 --mode precompute --out .benchmark/results/micro-precompute.json +``` + +**Metrics to report** (per methodology §5): CPU time (`userMs+sysMs` median), wall median, peak RSS p95, `route:chunk` / `route:split-exports` / `route:client-entry` `totalMs`+`maxMs`, and from the micro: `parse`/`traverse`/`generate`/`structuredClone` call counts per route, per-route mean ms, heap delta. Expected micro signature: precompute shows `parse = routeCount` (1/module) vs lazy's `≤ 5×routeCount`, and `structuredClone ≈ 0`. + +**A win =** CPU time and `route:chunk.totalMs` drop, no peak-RSS regression beyond the retained `RouteChunkAnalysis` heap cost. Fill the comparison table template in methodology §7.3. + +**Hygiene:** benchmark output is gitignored under `.benchmark/`. Clean with `rm -rf .benchmark/` — **not** `git clean -fdX` (also nukes `node_modules/` and `.tracedecay/`). Pin one Node version; run both halves back-to-back with no other load. + +--- + +## 8. Compatibility risks + mitigations + +| # | Risk | Mitigation | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Index stability.** Design rests on `ast.program.body` never being reordered between analysis and generation. | Dev-mode `Object.freeze`-shallow on `body` (Step 4) + length assertions. Low risk — today's code already treats parsed body as read-only up to the post-clone mutation. | +| 2 | **Byte-for-byte output drift.** `generate()` output changing would invalidate downstream chunk hashes / break snapshot tests. | Mandatory differential test (Step 3): old vs new `getRouteChunkCode` output === for every fixture × chunk name, both toggle values. Do not proceed to Step 6 until green. | +| 3 | **`t.cloneNode(node, false)` correctness.** Shallow clone is safe only because mutation reassigns a single top-level array property. A future deep-edit would silently share state. | Code comment at every clone site + the Step 4 freeze guard. | +| 4 | **Root-guard asymmetry (load-bearing).** `detectRouteChunksIfEnabled` suppresses root; `getRouteChunkIfEnabled` does NOT. Callers only ever request root chunks they never created. | `E-Root-03` pins it explicitly. The refactor preserves both guards exactly where they are — the analysis encodes neither. | +| 5 | **Entry/manifest disagreement (H1).** Bundler entries are created via substring (`source.includes(name)`); manifest `*Module` fields via detect. They can disagree for non-splittable exports. | `M-Manifest-03` + `T-Transform-05` pin it. Refactor preserves: entry path unchanged (substring in `index.ts`, not touched), manifest path consumes `chunkableExports`. | +| 6 | **Code-source divergence (pre-existing).** Transform path gets `code` from `args.code`; manifest path from `readFile`. If they ever differ, version strings differ and the manifest re-parses. | Pre-existing; the refactor does not worsen it (still versions by full `code`). Flagged in the behavior map §5; out of scope here. | +| 7 | **Cache eviction pattern change.** ~8 entries/module → 1 entry/module changes LRU eviction. At cap 2048 this is strictly better coverage (~2048 modules vs ~256). | Confirm cap not lowered under the new shape (it isn't — reuses `MAX_EXPORT_UTILS_CACHE_ENTRIES`). | +| 8 | **Free-floating top-level side effects.** Statements not in any chunkable export's dependency closure must land in `main` only. Subtle — index-selection preserves today's `omitChunkedExports` keep-everything-not-omitted behavior. | Test matrix must include a module with a free-floating top-level statement; assert it lands in `main` and nowhere else (spec §9 risk #7). | + +--- + +## 9. Rollback strategy + +1. **Per-commit reversibility.** Steps 0-5 each leave the old path fully functional behind `ROUTE_CHUNK_PRECOMPUTE=0`. A bad step is reverted with a single `git revert` of that step's commit; production is unaffected because the default is the old path until Step 6. +2. **Toggle kill-switch.** If the new path misbehaves after Step 6 (toggle deleted), `git revert` the Step 6 commit restores the toggle, then set `ROUTE_CHUNK_PRECOMPUTE=0` while diagnosing. Because Steps 1-5 are independently revertible, you can also roll back to any intermediate state. +3. **No data/manifest migration.** The change is internal to `src/route-chunks.ts`; public signatures, emitted chunk bytes (proven by the differential test), and the manifest shape are identical. There is nothing to migrate or restore on the consumer side — rollback is purely source-level. +4. **No persisted state.** `routeChunkCache` is in-memory, per plugin instance, never serialized. A rollback takes effect on the next build with no cleanup. + +The safest sequencing: land Steps 0-5 as one PR (or PR-range) on `perf/bundling-performance` with the toggle defaulting to old; run §7; only after the win is confirmed and §6 is green, land Step 6 as a follow-up commit deleting the toggle. + +--- + +## 10. Out of scope (explicit non-goals) + +- **`getExportNames` consolidation.** `src/index.ts` calls `getExportNames` via a separate `mlly`/`es-module-lexer` parser (different from Babel). Merging it into the single Babel traverse is theoretically possible but couples the chunk pipeline to the export-name contract and risks `export *` divergence. Flagged as a future consolidation, not a blocker (design §9 #6). +- **`getDependentIdentifiersForPath` resolver cost.** The scope-walking per export is the real CPU cost inside the single traverse; moving to indices does not speed it up. If profiling later shows it dominates, that is a separate memoization optimization. +- **Permanent feature flag / `future` opt-out.** The toggle is transient scaffolding for measurement, deleted in Step 6. +- **Lazy per-chunk string memo.** A `Map` on top of Option B so each `generate()` runs at most once per build is a cheap follow-on micro-optimization, not part of the core representation (design §3 hybrid note). +- **`src/performance.ts` uncommitted change** (slowest-list sort + hoisted `resolvedEnvironment`) — orthogonal perf tweak on this branch; commit or leave separately, not part of this plan. diff --git a/task/unified-route-module-analysis-cache-triage.md b/task/unified-route-module-analysis-cache-triage.md new file mode 100644 index 0000000..f3d8c48 --- /dev/null +++ b/task/unified-route-module-analysis-cache-triage.md @@ -0,0 +1,598 @@ +# Unified Route Module Analysis Cache Triage + +Task: `t_07287a3f` +Branch: `perf/bundling-performance` @ `c2452de` +Scope: design-only synthesis for a unified per-route analysis cache spanning `src/export-utils.ts`, `src/manifest.ts`, `src/index.ts`, and `src/route-chunks.ts`. + +Inputs synthesized: + +- `route-analysis-duplication-audit.md` +- `.benchmark/design/manifest-route-analysis-triage.md` +- `.benchmark/design/shared-route-analysis-cache-proposal.md` +- `.benchmark/design/test-impact-plan-shared-cache.md` +- `task/route-chunk-precompute-plan.md` +- live code in `src/export-utils.ts`, `src/manifest.ts`, `src/index.ts`, `src/route-chunks.ts` + +--- + +## 0. Headline answer + +The repo already shares low-level transform/export caches in `src/export-utils.ts:24-29` and a per-plugin `routeChunkCache` in `src/index.ts:403-409`, but it still duplicates higher-level route analysis because manifest generation, prerender validation, and three build transforms each reconstruct overlapping facts from the same route module. + +Recommended direction: + +1. Introduce a plugin-instance-scoped `RouteAnalysisCache` beside `routeChunkCache`. +2. Make it the single source of truth for: + - transformed ESM code, + - export-name list, + - manifest booleans, + - dev CSS fallback bit, + - route chunk metadata, + - future pointer to the single-pass `RouteChunkAnalysis` object proposed for `src/route-chunks.ts`. +3. Keep build/dev/root-route/split-mode guards outside the base cache entry where possible so one source analysis can be safely reused across callers. +4. Remove the prerender re-extraction pass in `src/index.ts:758-762` by threading route analysis out of manifest generation. +5. Treat raw-source web entry emission in `src/index.ts:433-450` as a follow-up hardening step unless it can be safely switched to the same cache without changing config timing. + +--- + +## 1. Current consumers: what each caller needs + +### 1.1 Shared low-level helpers + +`src/export-utils.ts` + +- `transformToEsm(code, resourcePath)` at `:52-80` +- `getExportNames(code)` at `:83-104` +- `getRouteModuleAnalysis(resourcePath)` at `:130-157` +- `getRouteModuleExports(resourcePath)` at `:159-163` + +Current caches: + +- `transformCache` keyed by `resourcePath` and validated by exact source string (`src/export-utils.ts:24,56-59`) +- `exportNamesCache` keyed by transformed `code` (`src/export-utils.ts:25,83-104`) +- `routeModuleAnalysisCache` keyed by `resourcePath` and validated by `mtimeMs + size` (`src/export-utils.ts:26-29,133-155`) + +### 1.2 Consumer matrix + +| Consumer | Callsite | Needs raw source? | Needs transformed code? | Needs export names? | Needs route chunk info? | +| ----------------------------------------------- | -------------------------------------- | -------------------------------------------------: | --------------------------------------------------: | -----------------------------------------------------------------: | --------------------------------------------------------------------------: | +| Manifest generation | `src/manifest.ts:163-285` | Yes today, only for dev CSS fallback at `:191-199` | Yes, for `detectRouteChunksIfEnabled` at `:202-210` | Yes, to derive manifest booleans at `:216-279` | Yes in build mode | +| Prerender validation | `src/index.ts:733-816` | No | No | Yes, via `getRouteModuleExports()` at `:758-762` | No | +| Client-entry transform (`route:client-entry`) | `src/index.ts:1368-1411` | No | Yes, `transformToEsm` at `:1377` | Yes, `getExportNames` at `:1378` | Yes, `detectRouteChunksIfEnabled` at `:1383-1389` | +| Route-chunk transform (`route:chunk`) | `src/index.ts:1414-1474` | No | Yes, `transformToEsm` at `:1442-1445` | Yes, but only for generated main-chunk enforcement at `:1454-1465` | Yes, plus generated chunk body via `getRouteChunkIfEnabled` at `:1446-1452` | +| Split-exports transform (`route:split-exports`) | `src/index.ts:1476-1547` | No | Yes, `transformToEsm` at `:1504-1507` | Yes, `getExportNames` at `:1519` | Yes, `detectRouteChunksIfEnabled` at `:1508-1514` | +| Route-module transform (`route:module`) | `src/index.ts:1738-1824` | No | Yes, `transformToEsm` at `:1749` | Yes in SPA mode, `getExportNames` at `:1762` | No | +| Browser manifest emit hook | `src/modify-browser-manifest.ts:39-46` | Indirectly through manifest | Indirectly through manifest | Indirectly through manifest | Indirectly through manifest | + +### 1.3 Current duplication that matters + +1. `getReactRouterManifestForDev()` can run up to three times per build: + - prerender path: `src/index.ts:869-876` + - node virtual server-manifest fallback: `src/index.ts:1352-1359` + - browser emit hook: `src/modify-browser-manifest.ts:39-46` +2. prerender validation immediately re-reads route exports after manifest generation via `getRouteModuleExports()` (`src/index.ts:758-762`). +3. build transforms each replay some combination of `transformToEsm()`, `getExportNames()`, and `detectRouteChunksIfEnabled()` from bundler `args.code` rather than consuming one shared analysis object. +4. manifest dev CSS fallback still depends on raw `source` (`src/manifest.ts:191-199`), which is the only remaining raw-source-only consumer in the route analysis path. + +--- + +## 2. Proposed unified cache shape + +Base principle: cache the source-derived facts once per route file and make build/dev policy a caller concern, not a property of the base analysis entry. + +Recommended module: + +```ts +// src/route-analysis-cache.ts +export type RouteAnalysisCache = { + getRouteAnalysis(args: RouteAnalysisRequest): Promise; + getRouteAnalysisFromCode( + args: RouteCodeAnalysisRequest + ): Promise; + invalidateFile?(filePath: string): void; + clear?(): void; +}; +``` + +Recommended stored shape: + +```ts +type RouteAnalysis = { + key: { + filePath: string; // normalized absolute path, query stripped + routeRelativePath: string; // normalized path relative to appDirectory + }; + version: { + mtimeMs: number; + size: number; + contentHash: string; // hash of raw source + }; + code: string; // transformed ESM + codeHash: string; // hash of transformed code + exports: { + exports: readonly string[]; + hasAction: boolean; + hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; + hasClientMiddleware: boolean; + hasDefaultExport: boolean; + hasErrorBoundary: boolean; + hasHydrateFallback: boolean; + }; + css: { + hasCssImport: boolean; // derived from transformed code, not raw source + }; + chunks: { + hasRouteChunks: boolean; + hasRouteChunkByExportName: Record; + chunkedExports: readonly RouteChunkExportName[]; + }; + // optional future field when the route-chunk single-pass analysis lands: + // routeChunkAnalysis?: InternalRouteChunkAnalysis; +}; +``` + +### Why this shape works + +- It covers every current caller without making them re-run analysis. +- It lets manifest reuse the same export list that prerender validation currently rebuilds. +- It keeps route chunk metadata alongside the same transformed code that generated it. +- It allows the route-chunk internal precompute plan to plug in later without changing external consumers again. + +### Important design choice + +Move the dev CSS fallback regex from raw `source` to transformed `code`. + +Current regex in `src/manifest.ts:194`: + +```ts +/\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/; +``` + +That regex should remain, but be evaluated against `analysis.code`. This removes the only load-bearing raw-source requirement from manifest generation. + +--- + +## 3. Cache keying and versioning + +## 3.1 Primary key + +Use normalized absolute file path with query string stripped: + +```ts +const key = normalize(resolve(filePath)).split('?')[0]; +``` + +## 3.2 Versioning strategy + +Use a two-layer strategy. + +### Disk-read path + +For `getRouteAnalysis({ readFromDisk: true })`: + +- primary lookup key: normalized absolute file path +- warm-hit guard: `mtimeMs + size` +- stale-hit confirmation: `contentHash` after read +- transformed-code equivalence diagnostic: `codeHash` + +Why: `mtimeMs + size` is cheap for warm hits, while `contentHash` protects against edge cases where metadata changes but content does not, or content changes in a way the metadata check alone should not trust. + +### Bundler-code path + +For `getRouteAnalysisFromCode({ readFromDisk: false, sourceCode })`: + +- primary lookup key: normalized absolute file path +- secondary version key: exact source variant / `codeHash` +- do not overwrite the disk-read entry with a bundler-source variant unless hashes match + +Recommended representation: + +```ts +type PerFileRouteAnalysisEntry = { + disk?: CacheEntry; + codeVersions: Map>; +}; +``` + +This is the safe answer to the current F-3 divergence: disk-source and bundler-source analysis for the same file can coexist without clobbering each other. + +## 3.3 Build/dev/split-route safety + +Do not encode `isBuild` or root-route suppression into the base route-analysis key. + +Recommended split: + +- base cache entry: source-derived facts only (`code`, `exports`, CSS bit, pure chunkability metadata) +- caller-side policy: + - build vs dev decides whether chunk metadata is requested/used + - root-route suppression remains in `detectRouteChunksIfEnabled`-style policy + - `splitRouteModules` / `enforce` remain policy inputs, not source-version inputs + +Reason: the same route file should be able to serve manifest, prerender, and transform callers without polluting one caller with another caller’s guard semantics. + +If the implementation chooses to cache guard-applied route chunk results instead of pure chunkability, then the cache subkey must include: + +- `splitRouteModules` mode (`false | true | 'enforce'`) +- normalized `rootRouteFile` +- normalized `appDirectory` +- caller intent (`detect` vs `getChunk`) because `detectRouteChunksIfEnabled` suppresses root routes while `getRouteChunkIfEnabled` does not (`src/route-chunks.ts:857-888`) + +Recommended design: avoid this complexity by caching the pure analysis and applying caller policy after lookup. + +--- + +## 4. Concurrency and failure hazards + +These are the hazards the implementation must explicitly handle. + +### H-1. Divergent disk vs bundler source versions + +Current risk: + +- manifest/prerender read from disk via `getRouteModuleAnalysis()` +- build transforms analyze `args.code` +- same path may produce different transformed inputs + +Hazard: + +- a resourcePath-only cache entry can be silently overwritten by a different source variant +- later callers observe misses or inconsistent chunk metadata without any explicit signal + +Mitigation: + +- keep separate per-file code-version entries +- compare `codeHash`/source identity in development and log or assert on divergence + +### H-2. Rejected Promise poisoning + +`transformToEsm()`, `getExportNames()`, and `getRouteModuleAnalysis()` already use delete-on-rejection logic (`src/export-utils.ts:69-74`, `95-100`, `144-149`). The unified cache must preserve that behavior. + +Hazard: + +- if a rejected in-flight Promise stays cached, every future caller fails forever until process restart + +Mitigation: + +- every Promise-backed cache layer must remove its own entry on rejection +- if a higher-level entry fans out into subentries (`disk`, `codeVersions`), rejection cleanup must remove the failed subentry only + +### H-3. Stat/read race on disk files + +Current `getRouteModuleAnalysis()` does `stat()` before deciding to reuse a cached Promise (`src/export-utils.ts:133-155`). + +Hazard: + +- file changes between `stat()` and `readFile()` +- metadata can drift while the content is already different + +Mitigation: + +- treat `mtimeMs + size` as a cheap warm-hit filter only +- canonicalize on `contentHash` after reading when metadata changed +- store `contentHash` in the entry so equivalent content can reuse transformed/export/chunk data even if metadata changed + +### H-4. Guarded route-chunk results poisoning other callers + +Current asymmetry: + +- `detectRouteChunksIfEnabled()` suppresses root routes at `src/route-chunks.ts:860-861` +- `getRouteChunkIfEnabled()` does not apply the same root-route guard (`src/route-chunks.ts:884-888`) + +Hazard: + +- caching a final caller-shaped result instead of a pure analysis can make one caller's policy leak into another + +Mitigation: + +- cache pure analysis/chunkability only +- apply root/build/split guards outside the shared entry + +### H-5. Shared AST mutation when route-chunk precompute lands + +The route-chunk precompute plan already identifies `structuredClone()` as a correctness guard because chunk consumers mutate `ast.program.body` in place. + +Hazard: + +- if the unified cache later stores a shared `RouteChunkAnalysis.ast`, consumers can accidentally mutate it and poison every later read + +Mitigation: + +- keep the current clone-and-filter behavior until the single-pass route-chunk refactor lands +- when that refactor lands, use immutable/index-based metadata as proposed in `task/route-chunk-precompute-plan.md` +- add dev-only immutability guards/freeze assertions before sharing an AST object broadly + +--- + +## 5. Exact tests that need coverage + +The exact named tests are already spelled out in `.benchmark/design/test-impact-plan-shared-cache.md` and `task/route-chunk-correctness-test-spec.md`. The implementation should treat the lists below as the required coverage set. + +### 5.1 New cache-layer tests + +New file: `tests/route-analysis-cache.test.ts` + +Required cases: + +- `T-CACHE-01` warm-hit reuse +- `T-CACHE-02` mtime/size drift with identical content hash still reuses analysis +- `T-CACHE-03` content change recomputes analysis +- `T-CACHE-04` disk and bundler source variants for the same file do not overwrite each other +- `T-CACHE-05` bounded-cache eviction at the configured cap +- `T-CACHE-06` explicit `invalidateFile()` / `clear()` behavior +- `T-CACHE-07` dev diagnostic when disk and bundler code hashes diverge +- `T-CACHE-08` shared-consumer consistency between manifest and transform-hook callers + +### 5.2 Manifest + prerender tests + +Update/add in: + +- `tests/manifest-split-route-modules.test.ts` +- `tests/manifest-version.test.ts` +- `tests/manifest.test.ts` +- `tests/index.test.ts` +- either export `validateSsrFalsePrerenderExports` for direct testing or add dedicated cases through the plugin harness + +Required named cases: + +- `T-MAN-06` through `T-MAN-13` +- `T-MAN-14` through `T-MAN-16` +- `T-PRE-01` through `T-PRE-05` +- `T-IDX-01` + +These specifically cover: + +- dev CSS fallback parity after moving from raw `source` to transformed `code` +- manifest export-boolean parity +- build-only chunk metadata correctness and no cross-mode leakage +- serialized manifest staying free of internal cache fields +- removal of the `getRouteModuleExports()` re-extraction pass from prerender validation + +### 5.3 Route-chunk passthrough tests + +Update: + +- `tests/route-chunks.test.ts` + +Required shared-cache case: + +- `T-CHUNK-01` cache-derived chunk metadata matches direct `detectRouteChunksIfEnabled()` behavior + +In addition, the sibling route-chunk correctness/precompute work remains required because the unified cache will eventually point at that analysis: + +- `D-Detect-01..08` +- `G-Gen-01..08` +- `F-Mode-01..03` +- `E-Root-01..04` +- `V-Enforce-01..04` +- `M-Manifest-01..06` +- `T-Transform-01..05` +- `C-Cache-01..06` + +Source of truth: `task/route-chunk-correctness-test-spec.md` and `task/route-chunk-precompute-plan.md`. + +### 5.4 serverBundles and SRI compatibility tests + +Update/add: + +- `tests/build-manifest.test.ts` +- new `tests/modify-browser-manifest.test.ts` + +Required named cases: + +- `T-BM-01` +- `T-BM-02` +- `T-SRI-01` through `T-SRI-05` + +These prove: + +- `build-manifest.ts` remains route-tree-only +- `serverBundles({ branch })` is not coupled to route-source analysis +- emitted manifest assets remain serializable/public-only +- SRI is still computed from emitted JS asset bytes only +- manifest chunk URLs still line up with emitted assets + +### 5.5 Existing coverage gaps to close + +These areas are currently effectively untested and should be considered mandatory coverage gaps: + +- `src/modify-browser-manifest.ts` emit/SRI path +- `validateSsrFalsePrerenderExports()` in `src/index.ts:733-816` +- dev CSS fallback in `src/manifest.ts:191-199` +- cache behavior in `src/export-utils.ts` + +--- + +## 6. Benchmark commands and counters + +### 6.1 Primary before/after benchmark commands + +From the existing methodology and scripts: + +Canonical baseline: + +```sh +pnpm bench:baseline +``` + +Equivalent explicit command: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-baseline +``` + +After the cache refactor: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-after-cache-dedup +``` + +Focused split-smoke run: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --filter split \ + --iterations 3 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-split-smoke +``` + +Existing package shortcut for the broader suite: + +```sh +pnpm bench:full +``` + +### 6.2 Verification commands during implementation + +```sh +pnpm exec rstest run +pnpm run build +pnpm run format +``` + +### 6.3 Counters to watch + +Top-level counts should stay stable for the same fixture: + +- `manifest:transform` +- `manifest:stage` +- `route:client-entry` +- `route:chunk` +- `route:split-exports` +- `route:module` + +New lower-level counters worth adding or watching: + +- `manifest:route-stat` +- `manifest:route-read` +- `manifest:route-transform-to-esm` +- `manifest:route-export-extract` +- `manifest:route-analysis` +- `manifest:route-map` +- `manifest:route-chunk-detect` +- `route-chunk:parse` +- `route-chunk:traverse` +- `route-chunk:structured-clone` +- `route-chunk:generate` + +Success criterion: + +- top-level transform counts remain stable +- direct route-analysis work drops +- route-chunk structured-clone overhead drops once the single-pass chunk-analysis follow-up lands + +--- + +## 7. Recommended implementation breakdown + +This should not be one commit. Minimum recommended sequence is three commits, with one optional hardening follow-up. + +### Commit 1 — Introduce the cache as an orchestration layer + +Files: + +- create `src/route-analysis-cache.ts` +- wire creation in `src/index.ts` beside `routeChunkCache` +- keep using existing helpers from `src/export-utils.ts` and `src/route-chunks.ts` +- add `tests/route-analysis-cache.test.ts` +- add the passthrough test in `tests/route-chunks.test.ts` + +Goal: + +- prove the cache can wrap existing behavior without changing outputs + +Merge gate: + +- `T-CACHE-01,03,06,08` +- `T-CHUNK-01` +- `T-MAN-13` + +### Commit 2 — Remove the raw-source-only manifest/prerender duplication + +Files: + +- `src/manifest.ts` +- `src/index.ts` (`validateSsrFalsePrerenderExports` path) +- `tests/manifest-split-route-modules.test.ts` +- `tests/manifest-version.test.ts` +- `tests/manifest.test.ts` +- `tests/index.test.ts` and/or dedicated prerender validation tests + +Goal: + +- move CSS fallback to transformed code +- thread route analysis out of manifest generation +- delete the `getRouteModuleExports()` re-extraction pass from prerender validation + +Merge gate: + +- `T-MAN-06..09` +- `T-PRE-01..05` +- `T-IDX-01` +- `T-MAN-14..16` + +### Commit 3 — Convert transform/emit consumers to the shared cache + +Files: + +- `src/index.ts` transform hooks +- `src/modify-browser-manifest.ts` +- `tests/build-manifest.test.ts` +- new `tests/modify-browser-manifest.test.ts` + +Goal: + +- `route:client-entry`, `route:split-exports`, and `route:module` consume cached analysis +- browser-manifest emission receives the shared cache without changing SRI semantics + +Merge gate: + +- `T-BM-01..02` +- `T-SRI-01..05` +- transform-hook parity tests from the sibling chunk spec remain green + +### Commit 4 — Optional hardening follow-up + +Files: + +- `src/index.ts` web route entry emission around `:433-450` +- possibly manifest staging/reuse paths + +Goal: + +- replace raw `source.includes(exportName)` entry emission with analysis-driven chunk entries +- investigate whether prerender can reuse a staged manifest instead of forcing another generation + +This is optional because it may change config timing or asset-list behavior. Keep it separate from the main cache landing. + +--- + +## 8. Bottom line + +If the goal is a safe unified route-module analysis cache, the best path is: + +1. keep one plugin-instance cache for source-derived route facts, +2. move CSS fallback onto transformed code, +3. thread manifest analysis into prerender validation, +4. let build transforms reuse the same analysis object, +5. preserve separate source versions for disk and bundler inputs, +6. leave entry-emission hardening as a follow-up unless it can be proven behavior-neutral. + +That gives one analysis source of truth without breaking `serverBundles`, SRI, root-route chunk policy, or the future single-pass route-chunk plan. diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts new file mode 100644 index 0000000..50a7c40 --- /dev/null +++ b/tests/export-utils.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from '@rstest/core'; +import { getBundlerRouteAnalysis } from '../src/export-utils'; + +const routeChunkConfig = { + splitRouteModules: true as const, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +describe('getBundlerRouteAnalysis', () => { + it('reuses transformed code, export names, and chunk info for the same source', async () => { + const source = ` + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + const resourcePath = '/app/routes/demo.tsx'; + + const first = await getBundlerRouteAnalysis(source, resourcePath); + const second = await getBundlerRouteAnalysis(source, resourcePath); + + expect(second).toBe(first); + expect(second.code).toBe(first.code); + expect(second.getExportNames()).toBe(first.getExportNames()); + expect(second.getRouteChunkInfo(undefined, routeChunkConfig)).toBe( + first.getRouteChunkInfo(undefined, routeChunkConfig) + ); + + expect(await first.getExportNames()).toEqual([ + 'clientAction', + 'default', + ]); + await expect( + first.getRouteChunkInfo(undefined, routeChunkConfig) + ).resolves.toMatchObject({ + hasRouteChunks: true, + chunkedExports: ['clientAction'], + }); + }); + + it('replaces the cached analysis when the source changes for the same resource', async () => { + const resourcePath = '/app/routes/demo.tsx'; + + const initial = await getBundlerRouteAnalysis( + `export const clientAction = async () => {};`, + resourcePath + ); + const updated = await getBundlerRouteAnalysis( + `export const clientLoader = async () => {};`, + resourcePath + ); + + expect(updated).not.toBe(initial); + await expect(updated.getExportNames()).resolves.toEqual(['clientLoader']); + }); +}); diff --git a/tests/manifest-split-route-modules.test.ts b/tests/manifest-split-route-modules.test.ts index 8c0579e..1e2107b 100644 --- a/tests/manifest-split-route-modules.test.ts +++ b/tests/manifest-split-route-modules.test.ts @@ -3,9 +3,36 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { getReactRouterManifestForDev } from '../src/manifest'; -import { getRouteChunkEntryName } from '../src/route-chunks'; +import { + getRouteChunkEntryName, + routeChunkExportNames, + type RouteChunkExportName, +} from '../src/route-chunks'; -const createTempApp = () => { +const clientExportFixtures: Record = { + clientAction: `export async function clientAction() { return {}; }`, + clientLoader: `export async function clientLoader() { return {}; }`, + clientMiddleware: `export async function clientMiddleware() { return null; }`, + HydrateFallback: `export function HydrateFallback() { return null; }`, +}; + +type ManifestModuleField = + | 'clientActionModule' + | 'clientLoaderModule' + | 'clientMiddlewareModule' + | 'hydrateFallbackModule'; + +const moduleFieldByExportName: Record< + RouteChunkExportName, + ManifestModuleField +> = { + clientAction: 'clientActionModule', + clientLoader: 'clientLoaderModule', + clientMiddleware: 'clientMiddlewareModule', + HydrateFallback: 'hydrateFallbackModule', +}; + +const createTempApp = (routeCode?: string, rootCode?: string) => { const root = mkdtempSync(join(tmpdir(), 'rr-manifest-')); const appDir = join(root, 'app'); const routesDir = join(appDir, 'routes'); @@ -13,102 +40,156 @@ const createTempApp = () => { writeFileSync( join(appDir, 'root.tsx'), - `export default function Root() { return null; }` + rootCode ?? `export default function Root() { return null; }` ); writeFileSync( join(routesDir, 'clients.tsx'), - `export async function clientAction() { return {}; } - export async function clientLoader() { return {}; } - export default function Clients() { return null; }` + routeCode ?? + `export async function clientAction() { return {}; } + export async function clientLoader() { return {}; } + export default function Clients() { return null; }` ); - return { root, appDir, routesDir }; + return { root, appDir }; +}; + +const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/clients': { + id: 'routes/clients', + parentId: 'root', + file: 'routes/clients.tsx', + path: 'clients', + }, +}; + +const createClientStats = (routeId = 'routes/clients') => { + const assetsByChunkName: Record = { + 'entry.client': ['static/js/entry.client.js'], + [routeId]: [`static/js/${routeId}.js`], + }; + for (const exportName of routeChunkExportNames) { + assetsByChunkName[getRouteChunkEntryName(routeId, exportName)] = [ + `static/js/${getRouteChunkEntryName(routeId, exportName)}.js`, + ]; + } + return { assetsByChunkName }; }; +const getManifest = async ( + appDir: string, + splitRouteModules: boolean | 'enforce', + isBuild = true +) => + getReactRouterManifestForDev(routes, {}, createClientStats(), appDir, '/', { + splitRouteModules, + rootRouteFile: 'root.tsx', + isBuild, + cache: new Map(), + }); + describe('manifest split route modules', () => { - it('includes clientActionModule when split route modules are enabled for build', async () => { + it.each(routeChunkExportNames)( + 'includes %sModule when the export is splittable in build mode', + async (exportName: RouteChunkExportName) => { + const { root, appDir } = createTempApp(` + ${clientExportFixtures[exportName]} + export default function Clients() { return null; } + `); + try { + const manifest = await getManifest(appDir, true); + const field = moduleFieldByExportName[exportName]; + + expect(manifest.routes['routes/clients'][field]).toBe( + `/static/js/${getRouteChunkEntryName('routes/clients', exportName)}.js` + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + ); + + it('omits split route module fields in dev mode', async () => { const { root, appDir } = createTempApp(); try { - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - 'routes/clients': { - id: 'routes/clients', - parentId: 'root', - file: 'routes/clients.tsx', - path: 'clients', - }, - }; - - const clientActionEntry = getRouteChunkEntryName( - 'routes/clients', - 'clientAction' - ); + const manifest = await getManifest(appDir, true, false); - const clientStats: { assetsByChunkName: Record } = { - assetsByChunkName: { - 'routes/clients': ['static/js/routes/clients.js'], - [clientActionEntry]: ['static/js/routes/clients-client-action.js'], - }, - }; - - const manifest = await getReactRouterManifestForDev( - routes, - {}, - clientStats, - appDir, - '/', - { - splitRouteModules: true, - rootRouteFile: 'root.tsx', - isBuild: true, - } - ); + expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); + expect(manifest.routes['routes/clients'].clientLoaderModule).toBeUndefined(); + expect( + manifest.routes['routes/clients'].clientMiddlewareModule + ).toBeUndefined(); + expect( + manifest.routes['routes/clients'].hydrateFallbackModule + ).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('omits a module field for a client export that is present but not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); + try { + const manifest = await getManifest(appDir, true); expect(manifest.routes['routes/clients'].hasClientAction).toBe(true); - expect(manifest.routes['routes/clients'].clientActionModule).toBe( - '/static/js/routes/clients-client-action.js' - ); + expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } }); - it('omits split route module fields in dev mode', async () => { - const { root, appDir } = createTempApp(); + it('throws in enforce mode when a present client export is not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); try { - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - 'routes/clients': { - id: 'routes/clients', - parentId: 'root', - file: 'routes/clients.tsx', - path: 'clients', - }, - }; - - const clientStats: { assetsByChunkName: Record } = { - assetsByChunkName: { - 'routes/clients': ['static/js/routes/clients.js'], - }, - }; - - const manifest = await getReactRouterManifestForDev( - routes, - {}, - clientStats, - appDir, - '/', - { - splitRouteModules: true, - rootRouteFile: 'root.tsx', - isBuild: false, - } + await expect(getManifest(appDir, 'enforce')).rejects.toThrowError( + /Error splitting route module[\s\S]*clientAction/ ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('does not throw outside enforce mode when a present client export is not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); + try { + const manifest = await getManifest(appDir, true); + + expect(manifest.routes['routes/clients'].hasClientAction).toBe(true); expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); - expect(manifest.routes['routes/clients'].clientLoaderModule).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } }); -}); + + it('does not add route chunk module fields for the root route', async () => { + const { root, appDir } = createTempApp( + `export default function Clients() { return null; }`, + `export async function clientAction() { return {}; } + export default function Root() { return null; }` + ); + try { + const manifest = await getManifest(appDir, true); + + expect(manifest.routes.root.hasClientAction).toBe(true); + expect(manifest.routes.root.clientActionModule).toBeUndefined(); + expect(manifest.routes.root.clientLoaderModule).toBeUndefined(); + expect(manifest.routes.root.clientMiddlewareModule).toBeUndefined(); + expect(manifest.routes.root.hydrateFallbackModule).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index a17204e..cb67291 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,5 +1,45 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { configRoutesToRouteManifest } from '../src/manifest'; +import { + configRoutesToRouteManifest, + getReactRouterManifestForDev, + getRouteManifestModuleExports, +} from '../src/manifest'; + +const createTempApp = (routeCode: string) => { + const root = mkdtempSync(join(tmpdir(), 'rr-manifest-')); + const appDir = join(root, 'app'); + const routesDir = join(appDir, 'routes'); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + join(appDir, 'root.tsx'), + `export default function Root() { return null; }` + ); + writeFileSync(join(routesDir, 'page.tsx'), routeCode); + + return { root, appDir }; +}; + +const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, +}; + +const clientStats = { + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + root: ['static/js/root.js'], + 'routes/page': ['static/js/routes/page.js'], + }, +}; describe('manifest', () => { describe('configRoutesToRouteManifest', () => { @@ -172,4 +212,73 @@ describe('manifest', () => { expect(item).toHaveProperty('hasClientMiddleware', false); expect(item).toHaveProperty('hasDefaultExport', false); }); + + it('keeps route export names available without serializing internal analysis fields', async () => { + const { root, appDir } = createTempApp(` + export function headers() { return {}; } + export async function action() { return null; } + export async function loader() { return null; } + export default function Page() { return null; } + `); + try { + const manifest = await getReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: true, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ); + + expect(manifest.routes['routes/page']).toMatchObject({ + hasAction: true, + hasLoader: true, + }); + expect(getRouteManifestModuleExports(manifest)['routes/page']).toEqual( + expect.arrayContaining(['headers', 'action', 'loader', 'default']) + ); + expect(Object.keys(manifest).sort()).toEqual([ + 'entry', + 'hmr', + 'routes', + 'sri', + 'url', + 'version', + ]); + expect(JSON.stringify(manifest)).not.toContain('headers'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('preserves dev css fallback when route analysis uses transformed code', async () => { + const { root, appDir } = createTempApp(` + import './page.css'; + export default function Page() { return

Page

; } + `); + try { + const manifest = await getReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: false, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ); + + expect(manifest.routes['routes/page'].css).toEqual([ + '/static/css/routes/page.css', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/performance.test.ts b/tests/performance.test.ts index 5700e4d..218dbdd 100644 --- a/tests/performance.test.ts +++ b/tests/performance.test.ts @@ -46,6 +46,59 @@ describe('React Router performance profiler', () => { expect(secondReport.operations['manifest:stage']).toBeUndefined(); }); + it('reports interval-union wall time without changing summed timing fields', async () => { + const logs: string[] = []; + const originalNow = performance.now; + let now = 0; + let resolveFirst: (value: string) => void = () => {}; + let resolveSecond: (value: string) => void = () => {}; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => now; + + const first = profiler.record('web', 'route:module', 'app/routes/a.tsx', () => { + return new Promise(resolve => { + resolveFirst = resolve; + }); + }); + + now = 10; + const second = profiler.record('web', 'route:module', 'app/routes/b.tsx', () => { + return new Promise(resolve => { + resolveSecond = resolve; + }); + }); + + now = 25; + resolveSecond('second'); + await second; + + now = 40; + resolveFirst('first'); + await first; + + profiler.flush('web'); + + const report = JSON.parse(logs[0].replace(/^.*?\{/, '{')); + expect(report.operations['route:module']).toMatchObject({ + count: 2, + totalMs: 55, + wallMs: 40, + maxMs: 40, + }); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 40, resource: 'app/routes/a.tsx' }, + { durationMs: 15, resource: 'app/routes/b.tsx' }, + ]); + } finally { + performance.now = originalNow; + } + }); + it('does not evaluate timers or log output when disabled', async () => { const logs: string[] = []; const originalNow = performance.now; diff --git a/tests/route-chunks-cache.test.ts b/tests/route-chunks-cache.test.ts new file mode 100644 index 0000000..7737c6f --- /dev/null +++ b/tests/route-chunks-cache.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from '@rstest/core'; +import { + detectRouteChunksIfEnabled, + getRouteChunkIfEnabled, + routeChunkNames, + type RouteChunkConfig, + type RouteChunkInfo, + type RouteChunkName, +} from '../src/route-chunks'; + +const config: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const routeId = '/app/routes/demo.tsx'; + +const chunkableCode = ` + const actionHelper = () => null; + const loaderHelper = () => null; + const middlewareHelper = () => null; + const fallbackHelper = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => loaderHelper(); + export const clientMiddleware = async () => middlewareHelper(); + export function HydrateFallback() { return fallbackHelper(); } + export async function action() { return null; } + export default function Route() { return null; } +`; + +const nonChunkableCode = ` + const shared = () => null; + export default function Route() { return shared(); } + export const clientAction = async () => shared(); +`; + +const collectRouteChunkOracle = async ( + cache: Map | undefined, + code = chunkableCode +) => { + const info = await detectRouteChunksIfEnabled(cache, config, routeId, code); + const chunks = Object.fromEntries( + await Promise.all( + routeChunkNames.map(async chunkName => [ + chunkName, + await getRouteChunkIfEnabled(cache, config, routeId, chunkName, code), + ]) + ) + ) as Record; + + return { info, chunks }; +}; + +const expectAllRouteChunks = (info: RouteChunkInfo) => { + expect(info.hasRouteChunks).toBe(true); + expect(info.chunkedExports).toEqual([ + 'clientAction', + 'clientLoader', + 'clientMiddleware', + 'HydrateFallback', + ]); + expect(info.hasRouteChunkByExportName).toEqual({ + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }); +}; + +describe('route chunk cache', () => { + it('invalidates cached detection when the same route id receives changed code', async () => { + const cache = new Map(); + + const first = await detectRouteChunksIfEnabled( + cache, + config, + routeId, + chunkableCode + ); + const second = await detectRouteChunksIfEnabled( + cache, + config, + routeId, + nonChunkableCode + ); + + expectAllRouteChunks(first); + expect(second.hasRouteChunks).toBe(false); + expect(second.hasRouteChunkByExportName.clientAction).toBe(false); + }); + + it('returns identical route chunk info and generated chunks across repeated cached calls', async () => { + const cache = new Map(); + + const first = await collectRouteChunkOracle(cache); + const second = await collectRouteChunkOracle(cache); + + expect(second).toEqual(first); + expectAllRouteChunks(first.info); + expect(first.chunks.main).not.toContain('clientAction'); + expect(first.chunks.clientAction).toContain('clientAction'); + expect(first.chunks.clientLoader).toContain('clientLoader'); + expect(first.chunks.clientMiddleware).toContain('clientMiddleware'); + expect(first.chunks.HydrateFallback).toContain('HydrateFallback'); + }); + + it('computes the same route chunk oracle with and without an explicit cache', async () => { + const cached = await collectRouteChunkOracle(new Map()); + const uncached = await collectRouteChunkOracle(undefined); + + expect(uncached).toEqual(cached); + }); + + it('stores only the shared route chunk analysis entry', async () => { + const cache = new Map(); + + await collectRouteChunkOracle(cache); + + expect(Array.from(cache.keys())).toEqual(['routes/demo.tsx::analysis']); + }); +}); diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index f6a7799..13197ed 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -1,87 +1,516 @@ import { describe, expect, it } from '@rstest/core'; +import { getExportNames } from '../src/export-utils'; import { detectRouteChunksIfEnabled, + getRouteChunkCode, + getRouteChunkEntryName, + getRouteChunkIfEnabled, + getRouteChunkModuleId, + getRouteChunkNameFromModuleId, + isRouteChunkModuleId, + routeChunkExportNames, + type RouteChunkConfig, + type RouteChunkExportName, + type RouteChunkInfo, validateRouteChunks, } from '../src/route-chunks'; -const config = { - splitRouteModules: true as const, +const config: RouteChunkConfig = { + splitRouteModules: true, appDirectory: '/app', rootRouteFile: 'root.tsx', }; -const enforceConfig = { - splitRouteModules: 'enforce' as const, - appDirectory: '/app', - rootRouteFile: 'root.tsx', +const disabledConfig: RouteChunkConfig = { + ...config, + splitRouteModules: false, +}; + +const enforceConfig: RouteChunkConfig = { + ...config, + splitRouteModules: 'enforce', +}; + +const routeId = '/app/routes/demo.tsx'; +const rootRouteId = '/app/root.tsx'; + +const emptyChunkInfo: RouteChunkInfo = { + chunkedExports: [], + hasRouteChunks: false, + hasRouteChunkByExportName: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }, +}; + +const clientExportFixtures: Record = { + clientAction: `export const clientAction = async () => {};`, + clientLoader: `export const clientLoader = async () => {};`, + clientMiddleware: `export const clientMiddleware = async () => {};`, + HydrateFallback: `export function HydrateFallback() { return null; }`, +}; + +const codeWithClientAction = ` + export const clientAction = async () => {}; + export default function Route() { return null; } +`; + +const codeWithClientActionSharedWithDefault = ` + const helper = () => null; + export default function Route() { return helper(); } + export const clientAction = async () => helper(); +`; + +const codeWithActionAndDefault = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export default function Route() { return null; } +`; + +const detect = (code: string, id = routeId) => + detectRouteChunksIfEnabled(new Map(), config, id, code); + +const expectOnlyChunkedExport = ( + result: RouteChunkInfo, + exportName: RouteChunkExportName +) => { + expect(result.hasRouteChunks).toBe(true); + expect(result.chunkedExports).toEqual([exportName]); + for (const name of routeChunkExportNames) { + expect(result.hasRouteChunkByExportName[name]).toBe(name === exportName); + } +}; + +const expectNoRouteChunks = (result: RouteChunkInfo) => { + expect(result).toEqual(emptyChunkInfo); +}; + +const expectExports = async ( + code: string | null, + expectedExports: string[], + unexpectedExports: string[] = [] +) => { + expect(code).not.toBeNull(); + const exports = await getExportNames(code ?? ''); + for (const exportName of expectedExports) { + expect(exports).toContain(exportName); + } + for (const exportName of unexpectedExports) { + expect(exports).not.toContain(exportName); + } }; describe('route chunks', () => { - it('detects chunkable client exports', async () => { - const code = ` - export const clientAction = async () => {}; - export const clientLoader = async () => {}; - export const clientMiddleware = async () => {}; - export function HydrateFallback() { return null; } - export default function Route() { return null; } - `; - - const result = await detectRouteChunksIfEnabled( - undefined, - config, - '/app/routes/demo.tsx', - code + describe('detect route chunks', () => { + it.each(routeChunkExportNames)( + 'detects a splittable %s export independently', + async exportName => { + const code = ` + ${clientExportFixtures[exportName]} + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, exportName); + } ); - expect(result.hasRouteChunks).toBe(true); - expect(result.hasRouteChunkByExportName.clientAction).toBe(true); - expect(result.hasRouteChunkByExportName.clientLoader).toBe(true); - expect(result.hasRouteChunkByExportName.clientMiddleware).toBe(true); - expect(result.hasRouteChunkByExportName.HydrateFallback).toBe(true); + it('detects all four client exports as independently splittable', async () => { + const code = ` + const actionHelper = () => null; + const loaderHelper = () => null; + const middlewareHelper = () => null; + const fallbackHelper = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => loaderHelper(); + export const clientMiddleware = async () => middlewareHelper(); + export function HydrateFallback() { return fallbackHelper(); } + export default function Route() { return null; } + `; + + const result = await detect(code); + + expect(result.hasRouteChunks).toBe(true); + expect(result.hasRouteChunkByExportName).toEqual({ + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }); + expect(result.chunkedExports).toEqual(routeChunkExportNames); + }); + + it('allows client exports to depend on imports', async () => { + const code = ` + import { json } from 'react-router'; + export const clientLoader = async () => json({}); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientLoader'); + }); + + it('does not split two client exports that share a top-level helper', async () => { + const code = ` + const shared = () => {}; + export const clientAction = async () => shared(); + export const clientLoader = async () => shared(); + `; + + const result = await detect(code); + + expectNoRouteChunks(result); + }); + + it('does not split a client export that shares top-level code with the default export', async () => { + const result = await detect(codeWithClientActionSharedWithDefault); + + expectNoRouteChunks(result); + }); + + it('splits a single-binding destructured client export', async () => { + const code = ` + function make() { return { clientAction: async () => {} }; } + export const { clientAction } = make(); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientAction'); + }); + + it('does not split a multi-binding destructured client export sharing a declarator', async () => { + const code = ` + function make() { return { clientAction: async () => {}, foo: 1 }; } + export const { clientAction, foo } = make(); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectNoRouteChunks(result); + }); + + it('splits an isolated client export while leaving a non-splittable sibling unsplit', async () => { + const code = ` + const actionHelper = () => null; + const shared = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => shared(); + export default function Route() { return shared(); } + `; + + const result = await detect(code); + + expect(result.hasRouteChunks).toBe(true); + expect(result.chunkedExports).toEqual(['clientAction']); + expect(result.hasRouteChunkByExportName.clientAction).toBe(true); + expect(result.hasRouteChunkByExportName.clientLoader).toBe(false); + }); + + it('orders chunkedExports by routeChunkExportNames, not source order', async () => { + const code = ` + export function HydrateFallback() { return null; } + export const clientLoader = async () => {}; + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const result = await detect(code); + + expect(result.chunkedExports).toEqual([ + 'clientAction', + 'clientLoader', + 'HydrateFallback', + ]); + }); }); - it('skips splitting for the root route', async () => { - const code = `export const clientAction = async () => {};`; + describe('generate route chunk code', () => { + it('omits chunkable client exports from the main chunk while retaining default and server exports', async () => { + const code = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export const clientAction = async () => {}; + export default function Route() { return null; } + `; - const result = await detectRouteChunksIfEnabled( - undefined, - config, - '/app/root.tsx', - code - ); + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + code + ); + + await expectExports(chunk, ['default', 'action'], ['clientAction']); + }); + + it('generates an individual client chunk with only that client export', async () => { + const code = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientAction', + code + ); + + await expectExports(chunk, ['clientAction'], ['default', 'action']); + }); + + it('keeps only import specifiers used by an individual client chunk', async () => { + const code = ` + import { json, useFetcher } from 'react-router'; + export const clientLoader = async () => json({}); + export default function Route() { return useFetcher(); } + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientLoader', + code + ); + + expect(chunk).toMatch(/import\s*\{\s*json\s*\}\s*from/); + expect(chunk).not.toContain('useFetcher'); + await expectExports(chunk, ['clientLoader'], ['default']); + }); + + it('returns null for the main chunk when only client exports exist', async () => { + const code = ` + export const clientAction = async () => {}; + export const clientLoader = async () => {}; + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + code + ); + + expect(chunk).toBeNull(); + }); + + it('returns null for a non-chunkable individual client export', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientAction', + codeWithClientActionSharedWithDefault + ); + + expect(chunk).toBeNull(); + }); + + it('returns the full main chunk when a module has no chunkable exports', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + codeWithActionAndDefault + ); + + await expectExports(chunk, ['default', 'action'], ['clientAction']); + }); - expect(result.hasRouteChunks).toBe(false); - expect(result.hasRouteChunkByExportName.clientAction).toBe(false); + it('dispatches main and named chunk generation through getRouteChunkCode', async () => { + const cache = new Map(); + const mainChunk = getRouteChunkCode( + codeWithClientAction, + 'main', + cache, + 'routes/demo.tsx' + ); + const clientActionChunk = getRouteChunkCode( + codeWithClientAction, + 'clientAction', + cache, + 'routes/demo.tsx' + ); + + await expectExports(mainChunk ?? null, ['default'], ['clientAction']); + await expectExports(clientActionChunk ?? null, ['clientAction'], ['default']); + }); + + it('round-trips route chunk module ids and entry names', () => { + const moduleId = getRouteChunkModuleId( + '/app/routes/r.tsx', + 'clientAction' + ); + + expect(moduleId).toBe('/app/routes/r.tsx?route-chunk=clientAction'); + expect(isRouteChunkModuleId(moduleId)).toBe(true); + expect(getRouteChunkNameFromModuleId(moduleId)).toBe('clientAction'); + expect(getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=main')).toBe( + 'main' + ); + expect(getRouteChunkNameFromModuleId('/app/routes/r.tsx')).toBeNull(); + expect( + getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=bogus') + ).toBeNull(); + expect(getRouteChunkEntryName('routes/clients', 'clientAction')).toBe( + 'routes/clients-client-action' + ); + }); }); - it('throws when enforce is enabled and chunks cannot be split', async () => { - const code = ` - const shared = () => {}; - export const clientAction = async () => shared(); - export const clientLoader = async () => shared(); - `; - - const result = await detectRouteChunksIfEnabled( - undefined, - enforceConfig, - '/app/routes/shared.tsx', - code + describe('mode + early-exit', () => { + it('returns no route chunks without parsing when splitRouteModules is disabled or absent', async () => { + const invalidCode = `export const clientAction = ;`; + const absentConfig: RouteChunkConfig = { + ...config, + splitRouteModules: undefined, + }; + + await expect( + detectRouteChunksIfEnabled(new Map(), disabledConfig, routeId, invalidCode) + ).resolves.toEqual(emptyChunkInfo); + await expect( + detectRouteChunksIfEnabled(new Map(), absentConfig, routeId, invalidCode) + ).resolves.toEqual(emptyChunkInfo); + }); + + it('early-exits when no client export name substring appears', async () => { + const result = await detect(codeWithActionAndDefault); + + expectNoRouteChunks(result); + }); + + it('does not create a chunk from a client export name mentioned only in a comment', async () => { + const code = ` + // clientAction is mentioned here, but no such export exists. + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectNoRouteChunks(result); + }); + + it('returns null when route chunk generation is disabled', async () => { + await expect( + getRouteChunkIfEnabled( + new Map(), + disabledConfig, + routeId, + 'main', + codeWithClientAction + ) + ).resolves.toBeNull(); + }); + }); + + describe('root route', () => { + it.each([ + ['/app/root.tsx', true], + ['/app/./root.tsx', true], + ['/app/root.tsx?react-router-route', true], + ['/app/routes/root.tsx', false], + ])( + 'detects root route identity for %s', + async (id, isRootRoute) => { + const result = await detect(codeWithClientAction, id); + + expect(result.hasRouteChunks).toBe(!isRootRoute); + expect(result.hasRouteChunkByExportName.clientAction).toBe(!isRootRoute); + } ); - expect(result.hasRouteChunkByExportName.clientAction).toBe(false); - expect(result.hasRouteChunkByExportName.clientLoader).toBe(false); - - expect(() => - validateRouteChunks({ - config: enforceConfig, - id: '/app/routes/shared.tsx', - valid: { - clientAction: false, - clientLoader: false, - clientMiddleware: true, - HydrateFallback: true, - }, - }) - ).toThrowError(/Error splitting route module/); + it('generates a named chunk for the root route because generation has no root guard', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + rootRouteId, + 'clientAction', + codeWithClientAction + ); + + await expectExports(chunk, ['clientAction'], ['default']); + }); + + it('does not enforce route chunk validity for the root route', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: rootRouteId, + valid: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }, + }) + ).not.toThrow(); + }); + }); + + describe('enforce mode', () => { + it('allows all valid route chunks', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).not.toThrow(); + }); + + it('throws a singular guidance message for one invalid route chunk', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: false, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).toThrowError( + /Error splitting route module:[\s\S]*clientAction[\s\S]*This export[\s\S]*its own chunk[\s\S]*it shares/ + ); + }); + + it('throws a plural guidance message listing every invalid route chunk', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: false, + clientLoader: false, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).toThrowError( + /Error splitting route module:[\s\S]*clientAction[\s\S]*clientLoader[\s\S]*These exports[\s\S]*their own chunks[\s\S]*they share/ + ); + }); }); }); From 55dfd271ff76c62f710d8f5f9a20ae807bb8deea Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:51:12 +0200 Subject: [PATCH 07/30] Cache combined route export analysis --- src/export-utils.ts | 79 +++++++++++++++++++++++---------------------- src/index.ts | 9 +++--- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/export-utils.ts b/src/export-utils.ts index a18efd7..378333f 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -33,6 +33,7 @@ type RouteModuleAnalysis = { source: string; code: string; exports: string[]; + exportAllModules: string[]; }; type RouteModuleAnalysisCacheEntry = { @@ -42,7 +43,10 @@ type RouteModuleAnalysisCacheEntry = { }; const transformCache = new Map(); -const exportNamesCache = new Map>(); +const exportInfoCache = new Map< + string, + Promise<{ exportNames: string[]; exportAllModules: string[] }> +>(); const bundlerRouteAnalysisCache = new Map< string, BundlerRouteAnalysisCacheEntry @@ -108,26 +112,7 @@ export const transformToEsm = async ( }; export const getExportNames = async (code: string): Promise => { - const cached = exportNamesCache.get(code); - if (cached) { - return cached; - } - - const exports = (async () => { - await init; - const [, exportSpecifiers] = await parseExports(code); - return Array.from( - new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) - ); - })().catch(error => { - if (exportNamesCache.get(code) === exports) { - exportNamesCache.delete(code); - } - throw error; - }); - - setBoundedCacheEntry(exportNamesCache, code, exports); - return exports; + return (await getExportNamesAndExportAll(code)).exportNames; }; export const getBundlerRouteAnalysis = async ( @@ -193,25 +178,40 @@ export const getBundlerRouteAnalysis = async ( export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - await init; - const [imports, exportSpecifiers] = await parseExports(code); - const exportNames = new Set(); - for (const specifier of exportSpecifiers) { - if (specifier.n) { - exportNames.add(specifier.n); - } + const cached = exportInfoCache.get(code); + if (cached) { + return cached; } - const exportAllModules: string[] = []; - for (const entry of imports) { - if (!entry.n) { - continue; + + const exportInfo = (async () => { + await init; + const [imports, exportSpecifiers] = await parseExports(code); + const exportNames = new Set(); + for (const specifier of exportSpecifiers) { + if (specifier.n) { + exportNames.add(specifier.n); + } } - const statement = code.slice(entry.ss, entry.se); - if (/^\s*export\s*\*\s*from\s*['"]/.test(statement)) { - exportAllModules.push(entry.n); + const exportAllModules: string[] = []; + for (const entry of imports) { + if (!entry.n) { + continue; + } + const statement = code.slice(entry.ss, entry.se); + if (/^\s*export\s*\*\s*from\s*['"]/.test(statement)) { + exportAllModules.push(entry.n); + } } - } - return { exportNames: Array.from(exportNames), exportAllModules }; + return { exportNames: Array.from(exportNames), exportAllModules }; + })().catch(error => { + if (exportInfoCache.get(code) === exportInfo) { + exportInfoCache.delete(code); + } + throw error; + }); + + setBoundedCacheEntry(exportInfoCache, code, exportInfo); + return exportInfo; }; export const getRouteModuleAnalysis = async ( @@ -226,8 +226,9 @@ export const getRouteModuleAnalysis = async ( const analysis = (async () => { const source = await readFile(resourcePath, 'utf8'); const code = await transformToEsm(source, resourcePath); - const exports = await getExportNames(code); - return { source, code, exports }; + const { exportNames, exportAllModules } = + await getExportNamesAndExportAll(code); + return { source, code, exports: exportNames, exportAllModules }; })().catch(error => { if (routeModuleAnalysisCache.get(resourcePath)?.analysis === analysis) { routeModuleAnalysisCache.delete(resourcePath); diff --git a/src/index.ts b/src/index.ts index ff0012c..0e594c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; @@ -53,6 +53,7 @@ import { getBundlerRouteAnalysis, getExportNames, getExportNamesAndExportAll, + getRouteModuleAnalysis, transformToEsm, } from './export-utils.js'; import { @@ -1669,12 +1670,10 @@ export const pluginReactRouter = ( return; } visitedModules.add(modulePath); - const source = await readFile(modulePath, 'utf8'); - const moduleCode = await transformToEsm(source, modulePath); const { - exportNames: moduleExportNames, + exports: moduleExportNames, exportAllModules: moduleExportAll, - } = await getExportNamesAndExportAll(moduleCode); + } = await getRouteModuleAnalysis(modulePath); for (const name of moduleExportNames) { if (name !== 'default') { exportNames.add(name); From 60f80282cd9105ab5d69b1d996415c05ece11d1d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:10:24 +0200 Subject: [PATCH 08/30] perf: narrow route transform environments --- src/constants.ts | 8 ++++++++ src/index.ts | 18 +++++++++--------- tests/features.test.ts | 28 ++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 12c1732..6ad1d63 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,6 +28,10 @@ export const SERVER_ONLY_ROUTE_EXPORTS = [ 'headers', ] as const; +export const SERVER_ONLY_ROUTE_EXPORTS_SET: ReadonlySet = new Set( + SERVER_ONLY_ROUTE_EXPORTS +); + // Client route exports are split into non-component exports and component exports. // This mirrors upstream React Router Vite plugin intent and is used for export filtering. export const CLIENT_NON_COMPONENT_EXPORTS = [ @@ -52,6 +56,10 @@ export const CLIENT_ROUTE_EXPORTS: readonly ( | (typeof CLIENT_COMPONENT_EXPORTS)[number] )[] = [...CLIENT_NON_COMPONENT_EXPORTS, ...CLIENT_COMPONENT_EXPORTS]; +export const CLIENT_ROUTE_EXPORTS_SET: ReadonlySet = new Set( + CLIENT_ROUTE_EXPORTS +); + export const NAMED_COMPONENT_EXPORTS = [ 'HydrateFallback', 'ErrorBoundary', diff --git a/src/index.ts b/src/index.ts index 0e594c5..cccb412 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,11 @@ import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; import { generate, parse } from './babel.js'; import { BUILD_CLIENT_ROUTE_QUERY_STRING, - CLIENT_ROUTE_EXPORTS, + CLIENT_ROUTE_EXPORTS_SET, JS_EXTENSIONS, PLUGIN_NAME, SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { @@ -1392,11 +1393,8 @@ export const pluginReactRouter = ( return false; } return ( - (CLIENT_ROUTE_EXPORTS as readonly string[]).includes(exp) || - (isServer && - (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes( - exp - )) + CLIENT_ROUTE_EXPORTS_SET.has(exp) || + (isServer && SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp)) ); }); const target = `${args.resourcePath}?react-router-route`; @@ -1412,6 +1410,7 @@ export const pluginReactRouter = ( api.transform( { resourceQuery: /route-chunk=/, + environments: ['web'], }, async args => performanceProfiler.record( @@ -1474,6 +1473,7 @@ export const pluginReactRouter = ( api.transform( { test: /\.[cm]?[jt]sx?$/, + environments: ['web'], }, async args => performanceProfiler.record( @@ -1548,6 +1548,7 @@ export const pluginReactRouter = ( api.transform( { test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/, + environments: ['web'], }, async args => performanceProfiler.record( @@ -1570,6 +1571,7 @@ export const pluginReactRouter = ( api.transform( { test: /[\\/]\.client[\\/]|\.client(\.[cm]?[jt]sx?)?$/, + environments: ['node'], }, async args => performanceProfiler.record( @@ -1768,9 +1770,7 @@ export const pluginReactRouter = ( const invalidServerOnly = resolvedExportNames.filter(exp => { if (isRootRoute && exp === 'loader') return false; - return ( - SERVER_ONLY_ROUTE_EXPORTS as readonly string[] - ).includes(exp); + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); }); if (invalidServerOnly.length > 0) { diff --git a/tests/features.test.ts b/tests/features.test.ts index ad1e3bf..e213b41 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -129,11 +129,35 @@ describe('pluginReactRouter', () => { ).toBe(true); expect( - calls.some((call: any) => call.test?.toString().includes('\\.server')) + calls.some( + (call: any) => + call.resourceQuery?.toString().includes('route-chunk=') && + call.environments?.includes('web') + ) + ).toBe(true); + + expect( + calls.some( + (call: any) => + call.test?.toString().includes('\\.[cm]?') && + call.environments?.includes('web') + ) ).toBe(true); expect( - calls.some((call: any) => call.test?.toString().includes('\\.client')) + calls.some( + (call: any) => + call.test?.toString().includes('\\.server') && + call.environments?.includes('web') + ) + ).toBe(true); + + expect( + calls.some( + (call: any) => + call.test?.toString().includes('\\.client') && + call.environments?.includes('node') + ) ).toBe(true); }); }); From ea32619acb8fea718bb899c2f61fdc6c93b9c732 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:10:54 +0800 Subject: [PATCH 09/30] Replace Babel and esbuild with Yuku --- config/rslib.config.ts | 12 +- package.json | 19 +- pnpm-lock.yaml | 398 ++++++++++++++++++-- scripts/benchmark-yuku.mjs | 307 ++++++++++++++++ src/babel.ts | 69 +++- src/export-utils.ts | 194 ++++++++-- src/index.ts | 4 +- src/plugin-utils.ts | 722 ++++++++++++++++++++++++------------- src/route-chunks.ts | 681 +++++++++++++--------------------- 9 files changed, 1604 insertions(+), 802 deletions(-) create mode 100644 scripts/benchmark-yuku.mjs diff --git a/config/rslib.config.ts b/config/rslib.config.ts index b005996..44553e2 100644 --- a/config/rslib.config.ts +++ b/config/rslib.config.ts @@ -27,8 +27,8 @@ export const pluginCleanTscCache: RsbuildPlugin = { setup(api) { api.onBeforeBuild(() => { const tsbuildinfo = path.join( - api.context.rootPath, - 'tsconfig.tsbuildinfo', + api.context.rootPath, + 'tsconfig.tsbuildinfo' ); if (fs.existsSync(tsbuildinfo)) { fs.rmSync(tsbuildinfo); @@ -42,8 +42,8 @@ export const esmConfig: LibConfig = { syntax: 'es2021', shims: { esm: { - __dirname: true - } + __dirname: true, + }, }, dts: { build: true, @@ -51,10 +51,6 @@ export const esmConfig: LibConfig = { plugins: [pluginCleanTscCache], output: { minify: nodeMinifyConfig, - externals: { - '@babel/traverse': 'commonjs @babel/traverse', - '@babel/generator': 'commonjs @babel/generator', - } }, }; diff --git a/package.json b/package.json index 5e504c8..61e72a3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "test": "rstest run", "test:watch": "rstest watch", "test:coverage": "rstest run --coverage", + "bench:yuku": "node scripts/benchmark-yuku.mjs --compare-head", "test:core": "rstest run -c ./rstest.config.ts", "test:core:watch": "rstest watch -c ./rstest.config.ts", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", @@ -64,16 +65,9 @@ "release:local": "pnpm build && changeset version && changeset publish && git add . && git commit -m \"chore: version packages\" && git push && git push --tags" }, "dependencies": { - "@babel/core": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", "@react-router/node": "^7.13.0", "@remix-run/node-fetch-server": "^0.13.0", "@rspack/plugin-react-refresh": "^2.0.2", - "babel-dead-code-elimination": "^1.0.12", - "esbuild": "^0.27.2", "execa": "^9.6.1", "fs-extra": "11.3.3", "isbot": "5.1.34", @@ -81,7 +75,10 @@ "jsesc": "^3.1.0", "pathe": "^2.0.3", "react-refresh": "^0.18.0", - "rspack-plugin-virtual-module": "^1.0.1" + "rspack-plugin-virtual-module": "^1.0.1", + "yuku-analyzer": "0.5.38", + "yuku-codegen": "0.5.38", + "yuku-parser": "0.5.38" }, "devDependencies": { "@changesets/cli": "^2.29.8", @@ -90,18 +87,14 @@ "@rsbuild/core": "2.0.15", "@rslib/core": "^0.22.1", "@rspack/core": "2.0.8", - "@swc/helpers": "^0.5.23", "@rstest/core": "^0.8.1", "@rstest/coverage-istanbul": "^0.2.0", - "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", + "@swc/helpers": "^0.5.23", "@types/fs-extra": "11.0.4", "@types/jsesc": "^3.0.3", "@types/node": "^25.0.10", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "es-module-lexer": "1.7.0", "kill-port": "^2.0.1", "playwright": "^1.58.0", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 596ac75..716ed84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,21 +11,6 @@ importers: .: dependencies: - '@babel/core': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/generator': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/parser': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/traverse': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/types': - specifier: ^7.28.6 - version: 7.28.6 '@react-router/node': specifier: ^7.13.0 version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -34,13 +19,7 @@ importers: version: 0.13.0 '@rspack/plugin-react-refresh': specifier: ^2.0.2 - version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0) - babel-dead-code-elimination: - specifier: ^1.0.12 - version: 1.0.12 - esbuild: - specifier: ^0.27.2 - version: 0.27.2 + version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0) execa: specifier: ^9.6.1 version: 9.6.1 @@ -65,6 +44,15 @@ importers: rspack-plugin-virtual-module: specifier: ^1.0.1 version: 1.0.1 + yuku-analyzer: + specifier: 0.5.38 + version: 0.5.38 + yuku-codegen: + specifier: 0.5.38 + version: 0.5.38 + yuku-parser: + specifier: 0.5.38 + version: 0.5.38 devDependencies: '@changesets/cli': specifier: ^2.29.8 @@ -80,7 +68,7 @@ importers: version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.0.8 version: 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -93,15 +81,6 @@ importers: '@swc/helpers': specifier: ^0.5.23 version: 0.5.23 - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 - '@types/babel__generator': - specifier: ^7.27.0 - version: 7.27.0 - '@types/babel__traverse': - specifier: ^7.28.0 - version: 7.28.0 '@types/fs-extra': specifier: 11.0.4 version: 11.0.4 @@ -117,9 +96,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) - es-module-lexer: - specifier: 1.7.0 - version: 1.7.0 kill-port: specifier: ^2.0.1 version: 2.0.1 @@ -5141,6 +5117,174 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yuku-analyzer/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-ReQ6gxvR+fpPzaWXgovO/NPwUNL2MnllZlX3mEtUl3F7lC7ccLMzTJiYNSp8XBqqJtWW5N+jVQk744V5Y1IAgw==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-aG4J3j+rQ7UDR+BfY2ku3iurq2wCFJC3edbZCTztbkHkB6L7ScdwpVlJdIczDsxI6+U2J+nwB50VtW3asZlQow==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-3syI009LnQBNC+RdpEZZAP7P+rVe6Q2FQY3ZHlX5ejRb+eNjWqHoZbOSlqaON7WAOf1JQe5iLlEkMKZVBK5OiQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-DjHt+fDqYnptWXLGCbIL4QW2pDTGzmzi45qMz063h+2PoY7xhUmGCkqTm8Teh3IZmnm2J2ZIz9HJNHm+VOujmg==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-4FJgBfmZuUWHzx/OTiBjk6ZmniacT9CawHeOaQBwEgBYmvjBtaOOUtvBqD5lrJKR3VTyLAsW/DF0zHmJvkAPBA==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-YMfGT7QSFe9QXwbwmGasuXN62coW6u+TC1jb/NroGaOHsDB79cKeRz+rG+HDppTmNHlYRJfzkg2QucmvpGWTHg==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-ujtqERprsVGIIQihk6wtsECSdE9XWe5Eij4/2aRbf/v0MilNECIqccC8cFElFNlJxOkZBNAL8UbF2IeCXU5+Tw==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-hDDWq+CDLxuntgGoitQAaCw/ueDXNhGV0yIaEbZDEv5AvwGPgh09gBF5+yx/5wBdNNRACeYeFyukeJuIFAndZQ==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-k8koEM7OacjdoAHDg0T0ZMtHoBAcRqyE8zIvYhjQnZfInyJ0t+WT4oeq/l//YMbcsF4wOg+QvuS+3OutyRcLTA==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-oLlk9JWH+E0qK+wc/jW2PN0ZdwNdGa0cMPN24hMmhiK6yPSFZNJUG6138XyynYq6iV//TkSzsYbweAjwtzmEAw==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-DthuKpARL1lciV1XQod2r68YgFqZ+JM+oeZ+/umNJ166+HuaO2UwKpQ5h5IO8tC0MPvlLDO+J+er3aNMMrsVTQ==} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-2nOLgv6h5pDda+Ykqg2N+tcm+lYEdSIoStVxpUV2IlbNWSg7/q2iCtEl0qWo42o9H6Oivk6c6BftDJdZNsMIbg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-P1gUksBlW+q7MmOoLKwEkBcJ7sxlO8e4C00dWvuTFrrygTJZJfalN8WZ0DOrjkV06DLy4FIBn7FXdDy+yPbeaw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-jsF0g3FYkzeuGijBfTWuPVQo6xMjCWPrQihNjNJdrhObPeMFRW/jCcPsjre20aZBLF5gbzRH9BFaEiGJtXvo0Q==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-h5jGyr8fQ+zwmf3VzwyS+ltHfzm7iYXzkAy5TWOTT1x5GHGH5tVTBljPAmc/o+T19uj42aQgL5z3pzcMJ9g+MQ==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-7w5OxDdSBantw40nFOa/5lejp3IPoAgAg3u3uTW95ipL9/cCHSyq8cKtPxSGr+8LHVx0fCO4a2tEB8SFLy8PXQ==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-ZW4WJm4ygtzMv0VWwVvDa2TbkOAywggb6FGD+ZSYUNafGflMhlDvOsRTnNzlEsFqw3uU3RLEU795Ql0o2xrTxA==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-jlZRCKfElmGMFNUweknPdGiqukSi9N4XiS10jJ/ROFg8ND6fk9NOI2r+pr6qN0U2GAkfu2mgT7RFSHHtgtLCIg==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-RxrCajqnaeJqNzs9RM8Jze/RSk2PQFDRBEkxUovpJPNhiDj5Q4JbBz5ruZBmi7bgwHliFohhqzdReyovRLsvEg==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-QzUiOPUICxzM46Au7f2T0rUE7b+gxuDyOETL1Iz8o0JKYLjt04m6dqpo9Ln2tF4jN8RW7flolzDN7gdVQXGQsw==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-NknjHAtzzJKawpMzmJ/XVi/BNk6rGs00GWCBclQUk8XNN0fJ/1urZ8iCibBZwlUjd6Z77GOYsovNU4a/Rh8nBQ==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-OFRc/vNo/3nsX0ARyxpwZPsVzbDA71YyCMhKYqnyeD5OZek0O88PAjtYCg8YrmIuNGLWYE0fvMpsZe51AjePTg==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-Y6hexHekLYsOyPXJwYmLUhbwawYrHx4YfFNB72vyej/CkMtG0RLHpzJKTqAwn6JTR2zdvLx6sV8gx47dAmjWNg==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-/Y/GOsBUwLgcHdxzDZ6JoO4iH2NK94wDileNz8h1hPyUEAYPUo6x3+4JXMT7MHJRyoPuHIrQ/p2JZmPdDtjguQ==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-OLUvZAx1g+nDg0cPk/QEkOdc6d49DLCkEhsGjqyd3uit69CngK9Fs8154pOZc/3Y2QAy4jQAs8HnnediyIc5Bw==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-LcyGYaBuBm1VYKH0qURqKRcMkW0/PaZdfFdTyHaStLNJzYrHzJLBE/wI5dm2q6NEc56NMnFMSrRzw/BUXv4V0g==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-d6fd0z96mmq85rm1w8+AUURQoW/R7JxNXx61oWusVAC+JdJmK6KUty5r1hTXiLGRAzdkZXeG3ZlnqlzjvC0wnQ==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-fz9emPmTQsupJR0H5s/oMHf9JrIMo6qaNXVR9ljY9PFICW0+FD7TMdAwoIN7pK/vZ3AgOKWMZcDjjpoA6qaEZQ==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-jvFgjPgoUo9kOebQD4mZUyQ2xMrsuOcTuzJ2rWLApqUTpnrOoVwWyL1MKNw3CdkZUh0h/nMoIPQmQXObmSRxNw==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-DFmydzH7fHMRlFC82dVbIcPugN8eq83B/t2Zjy3HRLnQWMQXhFvZNiv9NNinT3ccjzGeFKo/V3+N9/tGc98sGQ==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-PTwAGbC5I5Fj6VI38HtI4C3BDrNgpXZdtcK9Um3i/2Tv2R1AintQhIDMyl5ir6NI7AweWl83sHAkO9xAG7cEEw==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-3gxfBDo1G70Y1q2Ec8lAYQ2+BV3bA9i74lovmIVRmv6C55aiXfBzrJHwSvsBU/Js1r0MtzT13vdxUdx83BPsuw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-CDZz3v7M6+PyZQJjAJFkZURbDmZtaJeOTgrvDudAk5FzMemcfuTJBKZ0hYp3OI/u0va0kDsre3eCRIwg5eMVbA==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.37': + resolution: {integrity: sha512-yaGadzsSgTqKXUFef9iUBP7tFXdkN+DWcZqU+MvixYajB3luC8HHCDfJZk/Dy/Hb8haAwJ3z0G9g7bjAG4nGJg==} + '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} @@ -9468,6 +9612,15 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-analyzer@0.5.38: + resolution: {integrity: sha512-uxVIPMomdry2zW2qvPbo44Rj72ucom2atQp6Cf5CCcW2wuLxwdHe9eLEo6qELZutsLNC0zZT+cYswyJ75G9q0g==} + + yuku-codegen@0.5.38: + resolution: {integrity: sha512-oaWapF6EiMec8UndXkxVrHiYrDhKEywNTKLoYHLBkbIOxopKd9jBKpLOiYu89NNszuuglGkpQ1z+iuGWYytLPQ==} + + yuku-parser@0.5.38: + resolution: {integrity: sha512-u2+4Vv948JFl+AiXWcKNoagrmZDL1jSvwBuRDoZq4pMTO/ZYJZp3lI2PuIXcLW1eL9eGxvBNvB+X5NgWgoFb0A==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -10380,7 +10533,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -12100,6 +12253,17 @@ snapshots: - '@rspack/core' - webpack + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - '@typescript/native-preview' + - core-js + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -12226,7 +12390,7 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: @@ -13229,6 +13393,107 @@ snapshots: '@xtuc/long@4.2.2': {} + '@yuku-analyzer/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.38': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.38': + optional: true + + '@yuku-toolchain/types@0.5.37': {} + '@zeit/schemas@2.36.0': {} accepts@1.3.8: @@ -15270,7 +15535,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -16469,6 +16734,13 @@ snapshots: transitivePeerDependencies: - supports-color + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + optionalDependencies: + typescript: 5.9.3 + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 @@ -16776,7 +17048,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -17795,4 +18067,52 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + yuku-analyzer@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-analyzer/binding-darwin-arm64': 0.5.38 + '@yuku-analyzer/binding-darwin-x64': 0.5.38 + '@yuku-analyzer/binding-freebsd-x64': 0.5.38 + '@yuku-analyzer/binding-linux-arm-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-arm-musl': 0.5.38 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-arm64-musl': 0.5.38 + '@yuku-analyzer/binding-linux-x64-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-x64-musl': 0.5.38 + '@yuku-analyzer/binding-win32-arm64': 0.5.38 + '@yuku-analyzer/binding-win32-x64': 0.5.38 + + yuku-codegen@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.38 + '@yuku-codegen/binding-darwin-x64': 0.5.38 + '@yuku-codegen/binding-freebsd-x64': 0.5.38 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.38 + '@yuku-codegen/binding-linux-arm-musl': 0.5.38 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.38 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.38 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.38 + '@yuku-codegen/binding-linux-x64-musl': 0.5.38 + '@yuku-codegen/binding-win32-arm64': 0.5.38 + '@yuku-codegen/binding-win32-x64': 0.5.38 + + yuku-parser@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.38 + '@yuku-parser/binding-darwin-x64': 0.5.38 + '@yuku-parser/binding-freebsd-x64': 0.5.38 + '@yuku-parser/binding-linux-arm-gnu': 0.5.38 + '@yuku-parser/binding-linux-arm-musl': 0.5.38 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.38 + '@yuku-parser/binding-linux-arm64-musl': 0.5.38 + '@yuku-parser/binding-linux-x64-gnu': 0.5.38 + '@yuku-parser/binding-linux-x64-musl': 0.5.38 + '@yuku-parser/binding-win32-arm64': 0.5.38 + '@yuku-parser/binding-win32-x64': 0.5.38 + zod@3.25.76: {} diff --git a/scripts/benchmark-yuku.mjs b/scripts/benchmark-yuku.mjs new file mode 100644 index 0000000..eb3d21d --- /dev/null +++ b/scripts/benchmark-yuku.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, symlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createJiti } from 'jiti'; + +const iterations = Number(process.env.BENCH_ITERATIONS ?? 250); +const sampleCount = Number(process.env.BENCH_SAMPLES ?? 24); + +const exec = (cmd, args, options = {}) => { + const result = spawnSync(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + [`Command failed: ${cmd} ${args.join(' ')}`, result.stdout, result.stderr] + .filter(Boolean) + .join('\n') + ); + } + return result.stdout; +}; + +const createOldCheckout = async repoRoot => { + const dir = await mkdtemp(path.join(tmpdir(), 'rr-yuku-before-')); + const archive = path.join(dir, 'head.tar'); + exec('git', ['archive', 'HEAD', '-o', archive], { cwd: repoRoot }); + const checkout = path.join(dir, 'repo'); + exec('mkdir', ['-p', checkout]); + exec('tar', ['-xf', archive, '-C', checkout]); + await linkNodeModules(repoRoot, checkout); + return checkout; +}; + +const linkNodeModules = async (repoRoot, checkout) => { + const sourceNodeModules = path.join(repoRoot, 'node_modules'); + const targetNodeModules = path.join(checkout, 'node_modules'); + await mkdir(targetNodeModules, { recursive: true }); + + for (const entry of await readdir(sourceNodeModules, { + withFileTypes: true, + })) { + if (entry.name === '.pnpm') { + continue; + } + const source = path.join(sourceNodeModules, entry.name); + const target = path.join(targetNodeModules, entry.name); + if (entry.name.startsWith('@') && entry.isDirectory()) { + await mkdir(target, { recursive: true }); + for (const scoped of await readdir(source)) { + const scopedTarget = path.join(target, scoped); + if (!existsSync(scopedTarget)) { + await symlink(path.join(source, scoped), scopedTarget); + } + } + continue; + } + if (!existsSync(target)) { + await symlink(source, target); + } + } + + const oldOnlyPackages = [ + '@babel/core', + '@babel/generator', + '@babel/parser', + '@babel/traverse', + '@babel/types', + 'babel-dead-code-elimination', + 'es-module-lexer', + 'esbuild', + ]; + for (const packageName of oldOnlyPackages) { + await linkPnpmPackage(sourceNodeModules, targetNodeModules, packageName); + } +}; + +const linkPnpmPackage = async ( + sourceNodeModules, + targetNodeModules, + packageName +) => { + const source = findPnpmPackage(sourceNodeModules, packageName); + if (!source) { + throw new Error(`Could not find ${packageName} in node_modules/.pnpm`); + } + const segments = packageName.split('/'); + const target = + segments.length === 1 + ? path.join(targetNodeModules, packageName) + : path.join(targetNodeModules, segments[0], segments[1]); + await mkdir(path.dirname(target), { recursive: true }); + if (!existsSync(target)) { + await symlink(source, target); + } +}; + +const findPnpmPackage = (sourceNodeModules, packageName) => { + const pnpmDir = path.join(sourceNodeModules, '.pnpm'); + const encodedName = packageName.replace('/', '+'); + const entries = spawnSync( + 'find', + [pnpmDir, '-maxdepth', '1', '-type', 'd', '-name', `${encodedName}@*`], + { + encoding: 'utf8', + } + ); + const dir = entries.stdout.split('\n').filter(Boolean).sort().at(-1); + if (!dir) { + return null; + } + return path.join(dir, 'node_modules', packageName); +}; + +const loadModules = async repoRoot => { + const jiti = createJiti(pathToFileURL(path.join(repoRoot, 'bench.mjs')).href); + return { + exportUtils: await jiti.import(path.join(repoRoot, 'src/export-utils.ts')), + compiler: await jiti.import(path.join(repoRoot, 'src/babel.ts')), + pluginUtils: await jiti.import(path.join(repoRoot, 'src/plugin-utils.ts')), + routeChunks: await jiti.import(path.join(repoRoot, 'src/route-chunks.ts')), + }; +}; + +const createSamples = () => + Array.from({ length: sampleCount }, (_, index) => { + const shared = + index % 3 === 0 + ? `const shared${index} = (value: number) => value + ${index};` + : ''; + return { + path: `/app/routes/bench-${index}.tsx`, + code: ` + import { helper${index} } from "./helpers"; + import { serverOnly${index} } from "./data.server"; + ${shared} + + type LoaderData${index} = { value: number }; + + export const loader = async () => { + return serverOnly${index}(); + }; + + export const action = async () => { + return serverOnly${index}(); + }; + + export const clientLoader = async () => { + const value = helper${index}(${index}); + return ${shared ? `shared${index}(value)` : 'value'}; + }; + + export const clientAction = async () => { + return helper${index}(${index + 1}); + }; + + export function HydrateFallback() { + return
Loading
; + } + + export function ErrorBoundary() { + return
Error
; + } + + export default function Route(props: LoaderData${index}) { + return
{props.value}
; + } + `, + }; + }); + +const hrtimeMs = start => Number(process.hrtime.bigint() - start) / 1e6; + +const measure = async fn => { + const start = process.hrtime.bigint(); + await fn(); + return hrtimeMs(start); +}; + +const runForRepo = async (label, repoRoot) => { + const { exportUtils, compiler, pluginUtils, routeChunks } = + await loadModules(repoRoot); + const samples = createSamples(); + + for (let i = 0; i < 20; i++) { + const sample = samples[i % samples.length]; + const code = await exportUtils.transformToEsm(sample.code, sample.path); + await exportUtils.getExportNames(code); + } + + const transformed = new Map(); + const transformMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = await exportUtils.transformToEsm(sample.code, sample.path); + transformed.set(sample.path, code); + } + }); + + const exportScanMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + await exportUtils.getExportNames(transformed.get(sample.path)); + } + }); + + const routeTransformMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = transformed.get(sample.path); + const ast = compiler.parse(code, { sourceType: 'module' }); + pluginUtils.removeExports(ast, [ + 'loader', + 'action', + 'middleware', + 'headers', + ]); + pluginUtils.transformRoute(ast); + pluginUtils.removeUnusedImports(ast); + compiler.generate(ast, { sourceMaps: true, filename: sample.path }); + } + }); + + const routeChunkMs = await measure(async () => { + const cache = new Map(); + const config = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', + }; + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = transformed.get(sample.path); + await routeChunks.detectRouteChunksIfEnabled( + cache, + config, + sample.path, + code + ); + await routeChunks.getRouteChunkIfEnabled( + cache, + config, + sample.path, + 'main', + code + ); + await routeChunks.getRouteChunkIfEnabled( + cache, + config, + sample.path, + 'clientLoader', + code + ); + } + }); + + return { + label, + transformMs, + exportScanMs, + routeTransformMs, + routeChunkMs, + totalMs: transformMs + exportScanMs + routeTransformMs + routeChunkMs, + }; +}; + +const format = value => value.toFixed(2).padStart(10); + +const printComparison = (before, after) => { + const rows = [ + ['transform', before.transformMs, after.transformMs], + ['export scan', before.exportScanMs, after.exportScanMs], + ['route transform', before.routeTransformMs, after.routeTransformMs], + ['route chunks', before.routeChunkMs, after.routeChunkMs], + ['total', before.totalMs, after.totalMs], + ]; + console.log( + `Benchmark: ${iterations} iterations across ${sampleCount} TSX route samples` + ); + console.log(`Node: ${process.version}`); + console.log(''); + console.log('metric before ms after ms speedup'); + for (const [name, oldMs, newMs] of rows) { + const speedup = oldMs / newMs; + console.log( + `${name.padEnd(18)}${format(oldMs)}${format(newMs)}${`${speedup.toFixed(2)}x`.padStart(10)}` + ); + } +}; + +const repoRoot = process.cwd(); +const compareHead = process.argv.includes('--compare-head'); + +if (compareHead) { + const oldRepo = await createOldCheckout(repoRoot); + const before = await runForRepo('before', oldRepo); + const after = await runForRepo('after', repoRoot); + printComparison(before, after); +} else { + const result = await runForRepo('current', repoRoot); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/src/babel.ts b/src/babel.ts index 4c52d9c..b3cdfb7 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -1,18 +1,55 @@ -import type { types as Babel } from '@babel/core'; -import generatorPkg from '@babel/generator'; -import { type ParseResult, parse } from '@babel/parser'; -/* eslint-disable @typescript-eslint/consistent-type-imports */ -import type { NodePath } from '@babel/traverse'; -import traversePkg from '@babel/traverse'; -import * as t from '@babel/types'; +import { + parse as yukuParse, + walk, + type ParseOptions, + type ParseResult, +} from 'yuku-parser'; +import { strip } from 'yuku-codegen'; -// Babel packages are CommonJS. Depending on the bundler/runtime interop mode, -// their "default" may either be the exported function or a module namespace. -// We normalize to always get the callable function. -const traverse: typeof import('@babel/traverse').default = - (traversePkg as any).default ?? (traversePkg as any); -const generate: typeof import('@babel/generator').default = - (generatorPkg as any).default ?? (generatorPkg as any); +export type Babel = any; +export type NodePath = T; -export { traverse, generate, parse, t }; -export type { Babel, NodePath, ParseResult }; +export const parse = ( + code: string, + options: ParseOptions = {} +): ParseResult => { + const result = yukuParse(code, { + sourceType: options.sourceType ?? 'module', + lang: options.lang ?? 'tsx', + preserveParens: false, + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result; +}; + +export const traverse: typeof walk = walk; + +export const generate = ( + ast: ParseResult | { type: 'Program' }, + options: { + sourceMaps?: boolean; + filename?: string; + sourceFileName?: string; + } = {} +): { code: string; map: any } => { + const result = 'program' in ast ? ast : { program: ast, lineStarts: [] }; + const generated = strip(result.program as any, { + comments: 'some', + sourceMaps: options.sourceMaps + ? { + lineStarts: result.lineStarts, + file: options.filename, + sourceFileName: options.sourceFileName, + } + : undefined, + }); + return { code: generated.code, map: generated.map as any }; +}; + +export const t = {}; +export type { ParseResult }; diff --git a/src/export-utils.ts b/src/export-utils.ts index d5f67d4..f7b0743 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,58 +1,174 @@ import { readFile } from 'node:fs/promises'; -import { extname } from 'pathe'; -import * as esbuild from 'esbuild'; -import { init, parse as parseExports } from 'es-module-lexer'; -import { JS_LOADERS } from './constants.js'; +import { langFromPath, parse } from 'yuku-parser'; +import { strip } from 'yuku-codegen'; -const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { - const ext = extname(resourcePath) as keyof typeof JS_LOADERS; - return JS_LOADERS[ext] ?? 'js'; +type AnyNode = Record; + +const parseProgram = (code: string, resourcePath?: string) => { + const result = parse(code, { + sourceType: 'module', + lang: resourcePath ? langFromPath(resourcePath) : 'tsx', + preserveParens: false, + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result.program as AnyNode; +}; + +const getIdentifierNamesFromPattern = ( + pattern: AnyNode | null | undefined, + names: string[] = [] +): string[] => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.push(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getIdentifierNamesFromPattern(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getIdentifierNamesFromPattern(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getIdentifierNamesFromPattern(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getIdentifierNamesFromPattern(property.argument, names); + } else { + getIdentifierNamesFromPattern(property.value, names); + } + } + } + return names; +}; + +const getExportedName = (node: AnyNode): string | null => { + if (!node) { + return null; + } + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' || node.type === 'StringLiteral') { + return String(node.value); + } + return null; +}; + +const isTypeOnlyExport = (node: AnyNode): boolean => + node.exportKind === 'type' || node.type === 'TSExportAssignment'; + +const collectExportNames = (program: AnyNode): string[] => { + const exportNames = new Set(); + for (const statement of program.body ?? []) { + if (statement.type === 'ExportAllDeclaration') { + const exported = getExportedName(statement.exported); + if (exported) { + exportNames.add(exported); + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + exportNames.add('default'); + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + if (isTypeOnlyExport(statement)) { + continue; + } + + const declaration = statement.declaration; + if (declaration) { + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + for (const name of getIdentifierNamesFromPattern(declarator.id)) { + exportNames.add(name); + } + } + } else if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration') && + declaration.id?.name + ) { + exportNames.add(declaration.id.name); + } + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.exportKind === 'type') { + continue; + } + const exported = getExportedName(specifier.exported); + if (exported) { + exportNames.add(exported); + } + } + } + return Array.from(exportNames); +}; + +const collectExportAllModules = (program: AnyNode): string[] => { + const modules: string[] = []; + for (const statement of program.body ?? []) { + if (statement.type !== 'ExportAllDeclaration') { + continue; + } + if (statement.exported) { + continue; + } + const source = statement.source?.value; + if (typeof source === 'string') { + modules.push(source); + } + } + return modules; }; export const transformToEsm = async ( code: string, resourcePath: string ): Promise => { - return ( - await esbuild.transform(code, { - jsx: 'automatic', - format: 'esm', - platform: 'neutral', - loader: getEsbuildLoader(resourcePath), - }) - ).code; + const result = parse(code, { + sourceType: 'module', + lang: langFromPath(resourcePath), + preserveParens: false, + }); + const transformed = strip(result.program, { comments: 'some' }); + if (transformed.errors.length > 0) { + throw new Error(transformed.errors.map(error => error.message).join('\n')); + } + return transformed.code; }; export const getExportNames = async (code: string): Promise => { - await init; - const [, exportSpecifiers] = await parseExports(code); - return Array.from( - new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) - ); + return collectExportNames(parseProgram(code)); }; export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - await init; - const [imports, exportSpecifiers] = await parseExports(code); - const exportNames = new Set(); - for (const specifier of exportSpecifiers) { - if (specifier.n) { - exportNames.add(specifier.n); - } - } - const exportAllModules: string[] = []; - for (const entry of imports) { - if (!entry.n) { - continue; - } - const statement = code.slice(entry.ss, entry.se); - if (/^\s*export\s*\*\s*from\s*['"]/.test(statement)) { - exportAllModules.push(entry.n); - } - } - return { exportNames: Array.from(exportNames), exportAllModules }; + const program = parseProgram(code); + return { + exportNames: collectExportNames(program), + exportAllModules: collectExportAllModules(program), + }; }; export const getRouteModuleExports = async ( diff --git a/src/index.ts b/src/index.ts index 651343a..67ae7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1688,8 +1688,8 @@ export const pluginReactRouter = ( // In SPA mode, server-only route exports are invalid (except root `loader`), // and `HydrateFallback` is only allowed on the root route. // - // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan - // the ESBuild-transformed JS output. + // Scan the Yuku-stripped output so TypeScript-only exports do not + // participate in route export validation. if (args.environment.name === 'web' && !ssr && isSpaMode) { const exportNames = await getExportNames(code); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 519c18a..836a733 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,24 +1,23 @@ -import { - deadCodeElimination, - findReferencedIdentifiers, -} from 'babel-dead-code-elimination'; import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; -import type { Babel, NodePath, ParseResult } from './babel.js'; -import { t, traverse } from './babel.js'; +import { walk, type ParseResult } from 'yuku-parser'; import { NAMED_COMPONENT_EXPORTS, JS_EXTENSIONS } from './constants.js'; +type AnyNode = Record; + +const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; + export function validateDestructuredExports( - id: Babel.ArrayPattern | Babel.ObjectPattern, + id: AnyNode, exportsToRemove: string[] ): void { if (id.type === 'ArrayPattern') { - for (const element of id.elements) { + for (const element of id.elements ?? []) { if (!element) { continue; } - // [ foo ] if ( element.type === 'Identifier' && exportsToRemove.includes(element.name) @@ -26,7 +25,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.name); } - // [ ...foo ] if ( element.type === 'RestElement' && element.argument.type === 'Identifier' && @@ -35,8 +33,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.argument.name); } - // [ [...] ] - // [ {...} ] if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { validateDestructuredExports(element, exportsToRemove); } @@ -44,16 +40,12 @@ export function validateDestructuredExports( } if (id.type === 'ObjectPattern') { - for (const property of id.properties) { + for (const property of id.properties ?? []) { if (!property) { continue; } - if ( - property.type === 'ObjectProperty' && - property.key.type === 'Identifier' - ) { - // { foo } + if (property.type === 'Property') { if ( property.value.type === 'Identifier' && exportsToRemove.includes(property.value.name) @@ -61,8 +53,6 @@ export function validateDestructuredExports( throw invalidDestructureError(property.value.name); } - // { foo: [...] } - // { foo: {...} } if ( property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' @@ -71,7 +61,6 @@ export function validateDestructuredExports( } } - // { ...foo } if ( property.type === 'RestElement' && property.argument.type === 'Identifier' && @@ -87,14 +76,12 @@ export function invalidDestructureError(name: string): Error { return new Error(`Cannot remove destructured export "${name}"`); } -export function toFunctionExpression(decl: Babel.FunctionDeclaration): any { - return t.functionExpression( - decl.id, - decl.params, - decl.body, - decl.generator, - decl.async - ); +export function toFunctionExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'FunctionExpression', + declare: undefined, + }; } export function combineURLs(baseURL: string, relativeURL: string): string { @@ -173,278 +160,503 @@ export function generateWithProps() { `; } +const removeFromArray = (array: T[], value: T): void => { + const index = array.indexOf(value); + if (index >= 0) { + array.splice(index, 1); + } +}; + +const getPatternIdentifierNames = ( + pattern: AnyNode | null | undefined, + names = new Set() +): Set => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.add(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getPatternIdentifierNames(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getPatternIdentifierNames(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getPatternIdentifierNames(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getPatternIdentifierNames(property.argument, names); + } else { + getPatternIdentifierNames(property.value, names); + } + } + } + return names; +}; + +const getDeclaredNames = (node: AnyNode): Set => { + const names = new Set(); + if (node.type === 'VariableDeclaration') { + for (const declarator of node.declarations ?? []) { + getPatternIdentifierNames(declarator.id, names); + } + } else if ( + (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') && + node.id?.name + ) { + names.add(node.id.name); + } else if (node.type === 'ImportDeclaration') { + for (const specifier of node.specifiers ?? []) { + if (specifier.local?.name) { + names.add(specifier.local.name); + } + } + } + return names; +}; + +const isIdentifierDeclaration = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ClassDeclaration' || + parent.type === 'ClassExpression') && + parent.id === node + ) { + return true; + } + if (parent.type === 'VariableDeclarator') { + return getPatternIdentifierNames(parent.id).has(node.name); + } + if ( + (parent.type === 'ImportSpecifier' || + parent.type === 'ImportDefaultSpecifier' || + parent.type === 'ImportNamespaceSpecifier') && + parent.local === node + ) { + return true; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ArrowFunctionExpression') && + (parent.params ?? []).some((param: AnyNode) => + getPatternIdentifierNames(param).has(node.name) + ) + ) { + return true; + } + return false; +}; + +const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if (isIdentifierDeclaration(node, parent)) { + return true; + } + if ( + parent.type === 'MemberExpression' && + parent.property === node && + !parent.computed + ) { + return true; + } + if ( + parent.type === 'Property' && + parent.key === node && + !parent.computed && + !parent.shorthand + ) { + return true; + } + if ( + parent.type === 'MethodDefinition' && + parent.key === node && + !parent.computed + ) { + return true; + } + if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement') { + return true; + } + return false; +}; + +const collectReferencedNames = (program: AnyNode): Set => { + const referenced = new Set(); + walk(program as any, { + Identifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!isNonReferenceIdentifier(node, parent)) { + referenced.add(node.name); + } + }, + ExportSpecifier(node: AnyNode) { + if (node.local?.name && node.exportKind !== 'type') { + referenced.add(node.local.name); + } + }, + }); + return referenced; +}; + +const getExportedName = (specifier: AnyNode): string | null => { + const exported = specifier.exported; + if (!exported) { + return null; + } + if (exported.type === 'Identifier') { + return exported.name; + } + if (exported.type === 'Literal') { + return String(exported.value); + } + return null; +}; + +const collectExportedLocalNames = (program: AnyNode): Set => { + const names = new Set(); + for (const statement of program.body ?? []) { + if (statement.type === 'ExportDefaultDeclaration') { + if (statement.declaration?.id?.name) { + names.add(statement.declaration.id.name); + } + continue; + } + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + if (statement.declaration) { + for (const name of getDeclaredNames(statement.declaration)) { + names.add(name); + } + } + for (const specifier of statement.specifiers ?? []) { + if (specifier.local?.name && specifier.exportKind !== 'type') { + names.add(specifier.local.name); + } + } + } + return names; +}; + +const removeUnusedTopLevelDeclarations = (program: AnyNode): void => { + let changed = true; + while (changed) { + changed = false; + const referenced = collectReferencedNames(program); + const exported = collectExportedLocalNames(program); + for (const statement of [...program.body]) { + if (statement.type !== 'VariableDeclaration') { + if ( + (statement.type === 'FunctionDeclaration' || + statement.type === 'ClassDeclaration') && + statement.id?.name && + !referenced.has(statement.id.name) && + !exported.has(statement.id.name) + ) { + removeFromArray(program.body, statement); + changed = true; + } + continue; + } + statement.declarations = statement.declarations.filter( + (declarator: AnyNode) => { + const names = getPatternIdentifierNames(declarator.id); + return Array.from(names).some( + name => referenced.has(name) || exported.has(name) + ); + } + ); + if (statement.declarations.length === 0) { + removeFromArray(program.body, statement); + changed = true; + } + } + } +}; + export const removeExports = ( - ast: ParseResult, + ast: ParseResult | AnyNode, exportsToRemove: string[] ): void => { - const previouslyReferencedIdentifiers = findReferencedIdentifiers(ast); + const program = getProgram(ast); let exportsFiltered = false; - const markedForRemoval = new Set>(); - // Keep track of identifiers referenced by removed exports, - // e.g. export { localName as exportName }, export default function localName const removedExportLocalNames = new Set(); - traverse(ast, { - ExportDeclaration(path: NodePath) { - // export { foo }; - // export { bar } from "./module"; - if (path.node.type === 'ExportNamedDeclaration') { - if (path.node.specifiers.length) { - //@ts-ignore - path.node.specifiers = path.node.specifiers.filter( - ( - specifier: - | Babel.ExportSpecifier - | Babel.ExportDefaultSpecifier - | Babel.ExportNamespaceSpecifier - ) => { - // Filter out individual specifiers - if ( - specifier.type === 'ExportSpecifier' && - specifier.exported.type === 'Identifier' - ) { - if (exportsToRemove.includes(specifier.exported.name)) { - exportsFiltered = true; - // Track the local identifier if it's different from the exported name - if ( - specifier.local && - specifier.local.type === 'Identifier' && - specifier.local.name !== specifier.exported.name - ) { - removedExportLocalNames.add(specifier.local.name); - } - return false; - } - } + for (const statement of [...program.body]) { + if (statement.type === 'ExportNamedDeclaration') { + if (statement.specifiers?.length) { + statement.specifiers = statement.specifiers.filter( + (specifier: AnyNode) => { + if (specifier.type !== 'ExportSpecifier') { return true; } - ); - // Remove the entire export statement if all specifiers were removed - if (path.node.specifiers.length === 0) { - markedForRemoval.add(path); + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemove.includes(exportedName)) { + exportsFiltered = true; + if (specifier.local?.name) { + removedExportLocalNames.add(specifier.local.name); + } + return false; + } + return true; } + ); + if (statement.specifiers.length === 0 && !statement.declaration) { + removeFromArray(program.body, statement); } + } - // export const foo = ...; - // export const [ foo ] = ...; - if (path.node.declaration?.type === 'VariableDeclaration') { - const declaration = path.node.declaration; - declaration.declarations = declaration.declarations.filter( - (declaration: Babel.VariableDeclarator) => { - // export const foo = ...; - // export const foo = ..., bar = ...; - if ( - declaration.id.type === 'Identifier' && - exportsToRemove.includes(declaration.id.name) - ) { - // Filter out individual variables + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + declaration.declarations = declaration.declarations.filter( + (declarator: AnyNode) => { + if (declarator.id.type === 'Identifier') { + if (exportsToRemove.includes(declarator.id.name)) { exportsFiltered = true; + removedExportLocalNames.add(declarator.id.name); return false; } - - // export const [ foo ] = ...; - // export const { foo } = ...; - if ( - declaration.id.type === 'ArrayPattern' || - declaration.id.type === 'ObjectPattern' - ) { - // NOTE: These exports cannot be safely removed, so instead we - // validate them to ensure that any exports that are intended to - // be removed are not present - validateDestructuredExports(declaration.id, exportsToRemove); - } - return true; } - ); - // Remove the entire export statement if all variables were removed - if (declaration.declarations.length === 0) { - markedForRemoval.add(path); - } - } - // export function foo() {} - if (path.node.declaration?.type === 'FunctionDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); - } - } - - // export class Foo() {} - if (path.node.declaration?.type === 'ClassDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); + validateDestructuredExports(declarator.id, exportsToRemove); + return true; } + ); + if (declaration.declarations.length === 0) { + removeFromArray(program.body, statement); } } - // export default ...; if ( - path.node.type === 'ExportDefaultDeclaration' && - exportsToRemove.includes('default') + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemove.includes(declaration.id.name) ) { - markedForRemoval.add(path); - // Track the identifier being exported as default - if (path.node.declaration) { - if (path.node.declaration.type === 'Identifier') { - removedExportLocalNames.add(path.node.declaration.name); - } else if ( - (path.node.declaration.type === 'FunctionDeclaration' || - path.node.declaration.type === 'ClassDeclaration') && - path.node.declaration.id - ) { - removedExportLocalNames.add(path.node.declaration.id.name); - } - } - } - }, - }); - - // Remove top-level property assignments to removed exports. Handles - // `clientLoader.hydrate = true`, `Component.displayName = "..."`, etc. - traverse(ast, { - ExpressionStatement(path: NodePath) { - // Only handle top-level statements - if (!path.parentPath.isProgram()) { - return; - } - - const expr = path.node.expression; - if (expr.type !== 'AssignmentExpression') { - return; + removedExportLocalNames.add(declaration.id.name); + removeFromArray(program.body, statement); } + } - const left = expr.left; - if ( - left.type === 'MemberExpression' && - left.object.type === 'Identifier' && - (exportsToRemove.includes(left.object.name) || - removedExportLocalNames.has(left.object.name)) - ) { - markedForRemoval.add(path as any); + if ( + statement.type === 'ExportDefaultDeclaration' && + exportsToRemove.includes('default') + ) { + const declaration = statement.declaration; + if (declaration?.type === 'Identifier') { + removedExportLocalNames.add(declaration.name); + } else if (declaration?.id?.name) { + removedExportLocalNames.add(declaration.id.name); } - }, - }); + removeFromArray(program.body, statement); + } + } - if (markedForRemoval.size > 0 || exportsFiltered) { - for (const path of markedForRemoval) { - path.remove(); + for (const statement of [...program.body]) { + const expression = + statement.type === 'ExpressionStatement' ? statement.expression : null; + const left = + expression?.type === 'AssignmentExpression' ? expression.left : null; + if ( + left?.type === 'MemberExpression' && + left.object?.type === 'Identifier' && + (exportsToRemove.includes(left.object.name) || + removedExportLocalNames.has(left.object.name)) + ) { + removeFromArray(program.body, statement); } + } - // Run dead code elimination on any newly unreferenced identifiers - deadCodeElimination(ast, previouslyReferencedIdentifiers); + if (exportsFiltered || removedExportLocalNames.size > 0) { + removeUnusedTopLevelDeclarations(program); } }; -export const removeUnusedImports = (ast: ParseResult): void => { - let scopeCrawled = false; - traverse(ast, { - Program(path: NodePath) { - if (!scopeCrawled) { - path.scope.crawl(); - scopeCrawled = true; - } - }, - ImportDeclaration(path: NodePath) { - if (path.node.specifiers.length === 0) { - return; - } - - const specifierPaths = path.get('specifiers') as NodePath< - | Babel.ImportSpecifier - | Babel.ImportDefaultSpecifier - | Babel.ImportNamespaceSpecifier - >[]; - - for (const specifierPath of specifierPaths) { - const local = specifierPath.node.local; - const binding = local ? path.scope.getBinding(local.name) : null; - if (!binding || !binding.referenced) { - specifierPath.remove(); +export const removeUnusedImports = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const referenced = collectReferencedNames(program); + for (const statement of [...program.body]) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + if ((statement.specifiers ?? []).length === 0) { + continue; + } + statement.specifiers = (statement.specifiers ?? []).filter( + (specifier: AnyNode) => { + if (specifier.importKind === 'type') { + return false; } + return !specifier.local?.name || referenced.has(specifier.local.name); } + ); + if (statement.specifiers.length === 0) { + removeFromArray(program.body, statement); + } + } +}; - if (path.node.specifiers.length === 0) { - path.remove(); - } +const identifier = (name: string): AnyNode => ({ + type: 'Identifier', + start: 0, + end: 0, + name, + decorators: [], + optional: false, + typeAnnotation: null, +}); + +const literal = (value: string): AnyNode => ({ + type: 'Literal', + start: 0, + end: 0, + value, + raw: JSON.stringify(value), +}); + +const callExpression = (callee: AnyNode, args: AnyNode[]): AnyNode => ({ + type: 'CallExpression', + start: 0, + end: 0, + callee, + arguments: args, + optional: false, +}); + +const importDeclaration = ( + specifiers: Array<{ local: string; imported: string }>, + source: string +): AnyNode => ({ + type: 'ImportDeclaration', + start: 0, + end: 0, + specifiers: specifiers.map(specifier => ({ + type: 'ImportSpecifier', + start: 0, + end: 0, + imported: identifier(specifier.imported), + local: identifier(specifier.local), + importKind: 'value', + })), + source: literal(source), + attributes: [], + phase: null, + importKind: 'value', +}); + +const variableDeclaration = (name: string, init: AnyNode): AnyNode => ({ + type: 'VariableDeclaration', + start: 0, + end: 0, + kind: 'const', + declare: false, + declarations: [ + { + type: 'VariableDeclarator', + start: 0, + end: 0, + id: identifier(name), + init, + definite: false, + }, + ], +}); + +const collectUsedNames = (program: AnyNode): Set => { + const names = new Set(); + walk(program as any, { + Identifier(node: AnyNode) { + names.add(node.name); }, }); + return names; }; -export const transformRoute = (ast: ParseResult): void => { - const hocs: Array<[string, Babel.Identifier]> = []; - function getHocUid(path: NodePath, hocName: string) { - const uid = path.scope.generateUidIdentifier(hocName); +export const transformRoute = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const usedNames = collectUsedNames(program); + const hocs: Array<[string, string]> = []; + + function getHocUid(hocName: string) { + let uid = `_${hocName}`; + let index = 2; + while (usedNames.has(uid)) { + uid = `_${hocName}${index++}`; + } + usedNames.add(uid); hocs.push([hocName, uid]); - return uid; - } - - traverse(ast, { - ExportDeclaration(path: NodePath) { - if (path.isExportDefaultDeclaration()) { - const declaration = path.get('declaration'); - // prettier-ignore - const expr = - declaration.isExpression() ? declaration.node : - declaration.isFunctionDeclaration() ? toFunctionExpression(declaration.node) : - undefined - if (expr) { - const uid = getHocUid(path, 'withComponentProps'); - declaration.replaceWith(t.callExpression(uid, [expr]) as any); - } - return; - } + return identifier(uid); + } - if (path.isExportNamedDeclaration()) { - const decl = path.get('declaration'); - - if (decl.isVariableDeclaration()) { - // biome-ignore lint/complexity/noForEach: - decl.get('declarations').forEach((varDeclarator: NodePath) => { - const id = varDeclarator.get('id') as any; - const init = varDeclarator.get('init') as any; - const expr = init.node as any; - if (!expr) return; - if (!id.isIdentifier()) return; - const { name } = id.node; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - init.replaceWith(t.callExpression(uid, [expr])); - }); - return; - } + for (const statement of program.body ?? []) { + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = statement.declaration; + const expr = + declaration?.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration; + if (expr && expr.type !== 'ClassDeclaration') { + const uid = getHocUid('withComponentProps'); + statement.declaration = callExpression(uid, [expr]); + } + continue; + } - if (decl.isFunctionDeclaration()) { - const { id } = decl.node; - if (!id) return; - const { name } = id; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - decl.replaceWith( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(name), - t.callExpression(uid, [toFunctionExpression(decl.node)]) - ), - ]) as any - ); + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + if ( + declarator.id?.type !== 'Identifier' || + !declarator.init || + !isNamedComponentExport(declarator.id.name) + ) { + continue; } + const uid = getHocUid(`with${declarator.id.name}Props`); + declarator.init = callExpression(uid, [declarator.init]); } - }, - }); + continue; + } + + if ( + declaration?.type === 'FunctionDeclaration' && + declaration.id?.name && + isNamedComponentExport(declaration.id.name) + ) { + const name = declaration.id.name; + const uid = getHocUid(`with${name}Props`); + statement.declaration = variableDeclaration( + name, + callExpression(uid, [toFunctionExpression(declaration)]) + ); + } + } if (hocs.length > 0) { - ast.program.body.unshift( - t.importDeclaration( - hocs.map(([name, identifier]) => - t.importSpecifier(identifier, t.identifier(name)) - ), - t.stringLiteral('virtual/react-router/with-props') - ) as any + program.body.unshift( + importDeclaration( + hocs.map(([name, local]) => ({ imported: name, local })), + 'virtual/react-router/with-props' + ) ); } }; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 2339729..32024f1 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -1,7 +1,14 @@ -import type { NodePath } from './babel.js'; -import { generate, parse, t, traverse } from './babel.js'; +import { + Analyzer, + type Module, + type Symbol as YukuSymbol, +} from 'yuku-analyzer'; +import { strip } from 'yuku-codegen'; +import { walk } from 'yuku-parser'; import { normalize, relative, resolve } from 'pathe'; +type AnyNode = Record; + export type RouteChunkExportName = | 'clientAction' | 'clientLoader' @@ -85,75 +92,114 @@ const getOrSetFromCache = ( return value; }; -const codeToAst = ( +type AnalyzedModule = { + module: Module; + program: AnyNode; +}; + +const analyzeCode = ( code: string, cache: RouteChunkCache | undefined, cacheKey: string -) => { - return structuredClone( - getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => - parse(code, { sourceType: 'module' }) - ) - ); +): AnalyzedModule => { + return getOrSetFromCache(cache, `${cacheKey}::analyzeCode`, code, () => { + const analyzer = new Analyzer(); + const module = analyzer.addFile(cacheKey, code, { + lang: 'tsx', + sourceType: 'module', + preserveParens: false, + }); + const errors = module.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return { module, program: module.ast as AnyNode }; + }); }; -const assertNodePath: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path), - `Expected a Path, but got ${Array.isArray(path) ? 'an array' : path}` - ); +const cloneProgram = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): AnyNode => structuredClone(analyzeCode(code, cache, cacheKey).program); + +type ExportDependencies = { + topLevelStatements: Set; + topLevelNonModuleStatements: Set; + importedIdentifierNames: Set; + exportedVariableDeclarators: Set; }; -const isNodePathWithNode = (path: unknown): path is NodePath => { - if (!path || typeof path !== 'object' || Array.isArray(path)) { - return false; - } - if (!('node' in path)) { - return false; +const getTopLevelStatementForNode = ( + module: Module, + node: AnyNode +): AnyNode => { + let current: AnyNode = node; + let parent = module.parentOf(current as never) as AnyNode | null; + while (parent && parent.type !== 'Program') { + current = parent; + parent = module.parentOf(current as never) as AnyNode | null; } - return Boolean((path as { node?: unknown }).node); + invariant(parent?.type === 'Program', 'Expected node to be within Program'); + return current; }; -const assertNodePathIsStatement: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isStatement(path.node), - `Expected a Statement path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const addTopLevelStatement = ( + module: Module, + dependencies: ExportDependencies, + node: AnyNode +) => { + const statement = getTopLevelStatementForNode(module, node); + dependencies.topLevelStatements.add(statement); + if ( + statement.type !== 'ImportDeclaration' && + !statement.type.startsWith('Export') + ) { + dependencies.topLevelNonModuleStatements.add(statement); + } }; -const assertNodePathIsVariableDeclarator: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isVariableDeclarator(path.node), - `Expected a VariableDeclarator path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getVariableDeclaratorForNode = ( + module: Module, + node: AnyNode +): AnyNode | null => { + let current: AnyNode | null = node; + while (current) { + if (current.type === 'VariableDeclarator') { + return current; + } + current = module.parentOf(current as never) as AnyNode | null; + } + return null; }; -const assertNodePathIsPattern: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isPattern(path.node), - `Expected a Pattern path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getExportedName = (exported: AnyNode): string => { + if (exported.type === 'Identifier') { + return exported.name; + } + return String(exported.value); }; -type ExportDependencies = { - topLevelStatements: Set; - topLevelNonModuleStatements: Set; - importedIdentifierNames: Set; - exportedVariableDeclarators: Set; +const sameNode = (left: AnyNode, right: AnyNode): boolean => + left.type === right.type && + left.start === right.start && + left.end === right.end; + +const setsIntersect = (set1: Set, set2: Set) => { + let smallerSet = set1; + let largerSet = set2; + if (set1.size > set2.size) { + smallerSet = set2; + largerSet = set1; + } + for (const element of smallerSet) { + if (largerSet.has(element)) { + return true; + } + } + return false; }; const getExportDependencies = ( @@ -166,296 +212,96 @@ const getExportDependencies = ( `${cacheKey}::getExportDependencies`, code, () => { + const { module } = analyzeCode(code, cache, cacheKey); const exportDependencies = new Map(); - const ast = codeToAst(code, cache, cacheKey); - function handleExport( + const handleExport = ( exportName: string, - exportPath: NodePath, - identifiersPath: NodePath = exportPath - ) { - const identifiers = getDependentIdentifiersForPath(identifiersPath); - const topLevelStatements = new Set([ - exportPath.node as t.Statement, - ...getTopLevelStatementsForPaths(identifiers), - ]); - const topLevelNonModuleStatements = new Set( - Array.from(topLevelStatements).filter( - statement => - !t.isImportDeclaration(statement) && - !t.isExportDeclaration(statement) - ) - ); - const importedIdentifierNames = new Set(); - for (const identifier of identifiers) { - if ( - t.isIdentifier(identifier.node) && - identifier.parentPath?.parentPath?.isImportDeclaration() - ) { - importedIdentifierNames.add(identifier.node.name); - } - } - const exportedVariableDeclarators = new Set(); - for (const identifier of identifiers) { - if (identifier.parentPath?.isVariableDeclarator()) { - const parentPath = identifier.parentPath; - if (parentPath.parentPath?.parentPath?.isExportNamedDeclaration()) { - exportedVariableDeclarators.add( - parentPath.node as t.VariableDeclarator - ); - continue; - } + exportNode: AnyNode, + localSymbol: YukuSymbol | null + ) => { + const dependencies: ExportDependencies = { + topLevelStatements: new Set(), + topLevelNonModuleStatements: new Set(), + importedIdentifierNames: new Set(), + exportedVariableDeclarators: new Set(), + }; + const visitedSymbols = new Set(); + const scannedStatements = new Set(); + + const scanStatement = (statement: AnyNode) => { + if (scannedStatements.has(statement)) { + return; } - const isWithinExportDestructuring = Boolean( - identifier.findParent(path => - Boolean( - path.isPattern() && - path.parentPath?.isVariableDeclarator() && - path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() - ) - ) - ); - if (isWithinExportDestructuring) { - let currentPath: NodePath | null = identifier; - while (currentPath) { - if ( - currentPath.parentPath?.isVariableDeclarator() && - currentPath.parentKey === 'id' - ) { - exportedVariableDeclarators.add( - currentPath.parentPath.node as t.VariableDeclarator - ); - break; + scannedStatements.add(statement); + walk(statement as any, { + Identifier(node: AnyNode) { + const reference = module.referenceOf(node as never); + if (reference?.symbol) { + visitSymbol(reference.symbol); } - currentPath = currentPath.parentPath; - } - } - } - exportDependencies.set(exportName, { - topLevelStatements, - topLevelNonModuleStatements, - importedIdentifierNames, - exportedVariableDeclarators, - }); - } + }, + }); + }; - traverse(ast, { - ExportDeclaration(exportPath) { - const { node } = exportPath; - if (t.isExportAllDeclaration(node)) { - return; - } - if (t.isExportDefaultDeclaration(node)) { - handleExport('default', exportPath); + const visitSymbol = (symbol: YukuSymbol) => { + if (visitedSymbols.has(symbol)) { return; } - const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - const { declarations } = declaration; - for (let i = 0; i < declarations.length; i++) { - const declarator = declarations[i]; - if (t.isIdentifier(declarator.id)) { - const declaratorPath = exportPath.get( - `declaration.declarations.${i}` - ); - assertNodePathIsVariableDeclarator(declaratorPath); - handleExport(declarator.id.name, exportPath, declaratorPath); - continue; - } - if (t.isPattern(declarator.id)) { - const exportedPatternPath = exportPath.get( - `declaration.declarations.${i}.id` - ); - assertNodePathIsPattern(exportedPatternPath); - const identifiers = - getIdentifiersForPatternPath(exportedPatternPath); - for (const identifier of identifiers) { - if (!t.isIdentifier(identifier.node)) { - continue; - } - handleExport(identifier.node.name, exportPath, identifier); - } - } + visitedSymbols.add(symbol); + + for (const declaration of symbol.declarations as AnyNode[]) { + const statement = getTopLevelStatementForNode(module, declaration); + addTopLevelStatement(module, dependencies, declaration); + if (statement.type === 'ImportDeclaration') { + dependencies.importedIdentifierNames.add(symbol.name); } - return; - } - if ( - t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration) - ) { - invariant( - declaration.id, - 'Expected exported function or class declaration to have a name when not the default export' + const declarator = getVariableDeclaratorForNode( + module, + declaration ); - handleExport(declaration.id.name, exportPath); - return; - } - if (t.isExportNamedDeclaration(node)) { - for (const specifier of node.specifiers) { - if (t.isIdentifier(specifier.exported)) { - const name = specifier.exported.name; - const specifierPath = exportPath - .get('specifiers') - .find(path => path.node === specifier); - invariant( - specifierPath, - `Expected to find specifier path for ${name}` - ); - handleExport(name, exportPath, specifierPath); - } + if ( + declarator && + getTopLevelStatementForNode(module, declarator).type === + 'ExportNamedDeclaration' + ) { + dependencies.exportedVariableDeclarators.add(declarator); } - return; + scanStatement(statement); } - throw new Error('Unknown export node type'); - }, - }); - return exportDependencies; - } - ); -}; + for (const reference of symbol.references as any[]) { + const statement = getTopLevelStatementForNode( + module, + reference.node + ); + addTopLevelStatement(module, dependencies, reference.node); + scanStatement(statement); + } + }; -const getDependentIdentifiersForPath = ( - path: NodePath, - state?: { visited: Set; identifiers: Set } -): Set => { - const { visited, identifiers } = state ?? { - visited: new Set(), - identifiers: new Set(), - }; - if (visited.has(path)) { - return identifiers; - } - visited.add(path); - path.traverse({ - Identifier(pathInner) { - if (identifiers.has(pathInner)) { - return; - } - identifiers.add(pathInner); - const binding = pathInner.scope.getBinding(pathInner.node.name); - if (!binding) { - return; - } - getDependentIdentifiersForPath(binding.path, { visited, identifiers }); - for (const reference of binding.referencePaths) { - if (reference.isExportNamedDeclaration()) { - continue; - } - getDependentIdentifiersForPath(reference, { visited, identifiers }); - } - for (const constantViolation of binding.constantViolations) { - getDependentIdentifiersForPath(constantViolation, { - visited, - identifiers, - }); - } - }, - }); - const topLevelStatement = getTopLevelStatementPathForPath(path); - const withinImportStatement = topLevelStatement.isImportDeclaration(); - const withinExportStatement = topLevelStatement.isExportDeclaration(); - if (!withinImportStatement && !withinExportStatement) { - getDependentIdentifiersForPath(topLevelStatement, { visited, identifiers }); - } - if ( - withinExportStatement && - path.isIdentifier() && - (t.isPattern(path.parentPath.node) || - t.isPattern(path.parentPath.parentPath?.node)) - ) { - const variableDeclarator = path.findParent(p => p.isVariableDeclarator()); - if (variableDeclarator) { - assertNodePath(variableDeclarator); - getDependentIdentifiersForPath(variableDeclarator, { - visited, - identifiers, - }); - } - } - return identifiers; -}; + addTopLevelStatement(module, dependencies, exportNode); -const getTopLevelStatementPathForPath = (path: NodePath) => { - const ancestry = path.getAncestry(); - const topLevelStatement = ancestry[ancestry.length - 2]; - assertNodePathIsStatement(topLevelStatement); - return topLevelStatement; -}; + if (localSymbol) { + visitSymbol(localSymbol); + } else { + const statement = getTopLevelStatementForNode(module, exportNode); + scanStatement(statement); + } -const getTopLevelStatementsForPaths = (paths: Set) => { - const topLevelStatements = new Set(); - for (const path of paths) { - const topLevelStatement = getTopLevelStatementPathForPath(path); - topLevelStatements.add(topLevelStatement.node as t.Statement); - } - return topLevelStatements; -}; + exportDependencies.set(exportName, dependencies); + }; -const getIdentifiersForPatternPath = ( - patternPath: NodePath, - identifiers: Set = new Set() -) => { - function walk(currentPath: NodePath) { - if (currentPath.isIdentifier()) { - identifiers.add(currentPath); - return; - } - if (currentPath.isObjectPattern()) { - const { properties } = currentPath.node; - for (let i = 0; i < properties.length; i++) { - const property = properties[i]; - if (t.isObjectProperty(property)) { - const valuePath = currentPath.get(`properties.${i}.value`); - if (isNodePathWithNode(valuePath)) { - walk(valuePath); - } - } else if (t.isRestElement(property)) { - const argumentPath = currentPath.get(`properties.${i}.argument`); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } - } - } else if (currentPath.isArrayPattern()) { - const { elements } = currentPath.node; - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - if (element) { - const elementPath = currentPath.get(`elements.${i}`); - if (isNodePathWithNode(elementPath)) { - walk(elementPath); - } + for (const exp of module.exports as any[]) { + if (exp.typeOnly || exp.isStar || exp.isExportEquals) { + continue; } + handleExport(exp.name, exp.node as AnyNode, exp.local ?? null); } - } else if (currentPath.isRestElement()) { - const argumentPath = currentPath.get('argument'); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } - } - walk(patternPath); - return identifiers; -}; - -const getExportedName = (exported: t.Identifier | t.StringLiteral) => { - return t.isIdentifier(exported) ? exported.name : exported.value; -}; -const setsIntersect = (set1: Set, set2: Set) => { - let smallerSet = set1; - let largerSet = set2; - if (set1.size > set2.size) { - smallerSet = set2; - largerSet = set1; - } - for (const element of smallerSet) { - if (largerSet.has(element)) { - return true; + return exportDependencies; } - } - return false; + ); }; const hasChunkableExport = ( @@ -516,18 +362,39 @@ const hasChunkableExport = ( ); }; +const generateCode = (program: AnyNode): string | undefined => { + if (program.body.length === 0) { + return undefined; + } + const result = strip(program as any, { comments: 'some' }); + if (result.errors.length > 0) { + throw new Error(result.errors.map(error => error.message).join('\n')); + } + return result.code; +}; + +const filterImportSpecifiers = ( + node: AnyNode, + shouldKeep: (importedName: string) => boolean +) => { + if (node.specifiers.length === 0) { + return node; + } + node.specifiers = node.specifiers.filter((specifier: AnyNode) => + shouldKeep(specifier.local.name) + ); + return node.specifiers.length > 0 ? node : null; +}; + const getChunkedExport = ( code: string, exportName: string, - generateOptions: Record = {}, cache: RouteChunkCache | undefined, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::getChunkedExport::${exportName}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::getChunkedExport::${exportName}`, code, () => { if (!hasChunkableExport(code, exportName, cache, cacheKey)) { @@ -544,75 +411,60 @@ const getChunkedExport = ( dependencies.exportedVariableDeclarators ); - const ast = codeToAst(code, cache, cacheKey); - ast.program.body = ast.program.body - .filter(node => - topLevelStatementsArray.some(statement => - t.isNodesEquivalent(node, statement) - ) + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + topLevelStatementsArray.some(statement => sameNode(node, statement)) ) - .map(node => { - if (!t.isImportDeclaration(node)) { + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } if (dependencies.importedIdentifierNames.size === 0) { return null; } - node.specifiers = node.specifiers.filter(specifier => - dependencies.importedIdentifierNames.has(specifier.local.name) - ); - invariant( - node.specifiers.length > 0, - 'Expected import statement to have used specifiers' + return filterImportSpecifiers(node, importedName => + dependencies.importedIdentifierNames.has(importedName) ); - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return null; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return exportName === 'default' ? node : null; } const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { + if (declaration?.type === 'VariableDeclaration') { declaration.declarations = declaration.declarations.filter( - declarationNode => + (declarationNode: AnyNode) => exportedVariableDeclaratorsArray.some(declarator => - t.isNodesEquivalent(declarationNode, declarator) + sameNode(declarationNode, declarator) ) ); - if (declaration.declarations.length === 0) { - return null; - } - return node; + return declaration.declarations.length > 0 ? node : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration' ) { - return node.declaration.id?.name === exportName ? node : null; + return declaration.id?.name === exportName ? node : null; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return null; - } + if (node.type === 'ExportNamedDeclaration') { node.specifiers = node.specifiers.filter( - specifier => getExportedName(specifier.exported) === exportName + (specifier: AnyNode) => + getExportedName(specifier.exported) === exportName ); - if (node.specifiers.length === 0) { - return null; - } - return node; + return node.specifiers.length > 0 ? node : null; } throw new Error('Unknown export node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - return generate(ast, generateOptions).code; + return generateCode(program); } ); }; @@ -620,15 +472,12 @@ const getChunkedExport = ( const omitChunkedExports = ( code: string, exportNames: string[], - generateOptions: Record = {}, cache: RouteChunkCache | undefined, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::omitChunkedExports::${exportNames.join(',')}`, code, () => { const isChunkable = (exportName: string) => @@ -642,9 +491,8 @@ const omitChunkedExports = ( const omittedExportNames = allExportNames.filter(isOmitted); const retainedExportNames = allExportNames.filter(isRetained); - const omittedStatements = new Set(); - const omittedExportedVariableDeclarators = - new Set(); + const omittedStatements = new Set(); + const omittedExportedVariableDeclarators = new Set(); for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); @@ -660,100 +508,73 @@ const omitChunkedExports = ( } } - const ast = codeToAst(code, cache, cacheKey); const omittedStatementsArray = Array.from(omittedStatements); const omittedExportedVariableDeclaratorsArray = Array.from( omittedExportedVariableDeclarators ); - ast.program.body = ast.program.body - .filter(node => - omittedStatementsArray.every( - statement => !t.isNodesEquivalent(node, statement) - ) + + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + omittedStatementsArray.every(statement => !sameNode(node, statement)) ) - .map(node => { - if (!t.isImportDeclaration(node)) { + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { - const importedName = specifier.local.name; + return filterImportSpecifiers(node, importedName => { for (const retainedExportName of retainedExportNames) { const dependencies = exportDependencies.get(retainedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { + if (dependencies?.importedIdentifierNames.has(importedName)) { return true; } } for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { + if (dependencies?.importedIdentifierNames.has(importedName)) { return false; } } return true; }); - if (node.specifiers.length === 0) { - return null; - } - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return node; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return isOmitted('default') ? null : node; } - if (t.isVariableDeclaration(node.declaration)) { + if (node.declaration?.type === 'VariableDeclaration') { node.declaration.declarations = - node.declaration.declarations.filter(declarationNode => + node.declaration.declarations.filter((declarationNode: AnyNode) => omittedExportedVariableDeclaratorsArray.every( - declarator => - !t.isNodesEquivalent(declarationNode, declarator) + declarator => !sameNode(declarationNode, declarator) ) ); - if (node.declaration.declarations.length === 0) { - return null; - } - return node; + return node.declaration.declarations.length > 0 ? node : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + node.declaration?.type === 'FunctionDeclaration' || + node.declaration?.type === 'ClassDeclaration' ) { - const declarationId = node.declaration.id; - invariant( - declarationId, - 'Expected exported function or class declaration to have a name when not the default export' - ); - return isOmitted(declarationId.name) ? null : node; + return isOmitted(node.declaration.id.name) ? null : node; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { + if (node.type === 'ExportNamedDeclaration') { + node.specifiers = node.specifiers.filter((specifier: AnyNode) => { const exportedName = getExportedName(specifier.exported); return !isOmitted(exportedName); }); - if (node.specifiers.length === 0) { - return null; - } - return node; + return node.specifiers.length > 0 || node.declaration ? node : null; } throw new Error('Unknown node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - if (ast.program.body.length === 0) { - return undefined; - } - return generate(ast, generateOptions).code; + return generateCode(program); } ); }; @@ -792,9 +613,9 @@ export const getRouteChunkCode: ( cacheKey: string ) => { if (chunkName === 'main') { - return omitChunkedExports(code, routeChunkExportNames, {}, cache, cacheKey); + return omitChunkedExports(code, routeChunkExportNames, cache, cacheKey); } - return getChunkedExport(code, chunkName, {}, cache, cacheKey); + return getChunkedExport(code, chunkName, cache, cacheKey); }; export const getRouteChunkModuleId = ( From aa33f69ae627a7eb4b077a106436dcacce95211b Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:29:15 +0800 Subject: [PATCH 10/30] Preserve parens in Yuku transforms --- src/babel.ts | 2 +- src/export-utils.ts | 4 ++-- src/route-chunks.ts | 2 +- tests/export-utils.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 tests/export-utils.test.ts diff --git a/src/babel.ts b/src/babel.ts index b3cdfb7..c8559a6 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -16,7 +16,7 @@ export const parse = ( const result = yukuParse(code, { sourceType: options.sourceType ?? 'module', lang: options.lang ?? 'tsx', - preserveParens: false, + preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/src/export-utils.ts b/src/export-utils.ts index f7b0743..2fa300d 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -8,7 +8,7 @@ const parseProgram = (code: string, resourcePath?: string) => { const result = parse(code, { sourceType: 'module', lang: resourcePath ? langFromPath(resourcePath) : 'tsx', - preserveParens: false, + preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' @@ -148,7 +148,7 @@ export const transformToEsm = async ( const result = parse(code, { sourceType: 'module', lang: langFromPath(resourcePath), - preserveParens: false, + preserveParens: true, }); const transformed = strip(result.program, { comments: 'some' }); if (transformed.errors.length > 0) { diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 32024f1..1b76c59 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -107,7 +107,7 @@ const analyzeCode = ( const module = analyzer.addFile(cacheKey, code, { lang: 'tsx', sourceType: 'module', - preserveParens: false, + preserveParens: true, }); const errors = module.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts new file mode 100644 index 0000000..59ef389 --- /dev/null +++ b/tests/export-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from '@rstest/core'; +import { parse } from '../src/babel'; +import { transformToEsm } from '../src/export-utils'; + +describe('export-utils', () => { + describe('transformToEsm', () => { + it('preserves arrow function object return parentheses', async () => { + const code = ` + const items = [{ pathname: '/', data: 'Home' }]; + export const labels = items.map((item) => ({ + to: item.pathname, + label: item.data, + })); + `; + + const transformed = await transformToEsm(code, 'route.tsx'); + + expect(transformed).toContain('=> ({'); + expect(() => parse(transformed, { sourceType: 'module' })).not.toThrow(); + }); + }); +}); From 9d2e7a6f8179c8a3ac4ad0cabefc406014fe85b6 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:43:58 +0800 Subject: [PATCH 11/30] Keep JSX component references during DCE --- src/plugin-utils.ts | 19 +++++++++++++++++++ tests/remove-exports.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 836a733..4c9ab61 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -295,6 +295,8 @@ const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { return false; }; +const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); + const collectReferencedNames = (program: AnyNode): Set => { const referenced = new Set(); walk(program as any, { @@ -304,6 +306,23 @@ const collectReferencedNames = (program: AnyNode): Set => { referenced.add(node.name); } }, + JSXIdentifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!parent || !isUppercaseName(node.name)) { + return; + } + if ( + (parent.type === 'JSXOpeningElement' || + parent.type === 'JSXClosingElement') && + parent.name === node + ) { + referenced.add(node.name); + return; + } + if (parent.type === 'JSXMemberExpression' && parent.object === node) { + referenced.add(node.name); + } + }, ExportSpecifier(node: AnyNode) { if (node.local?.name && node.exportKind !== 'type') { referenced.add(node.local.name); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index e907ca1..44f94d7 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@rstest/core'; -import { parse, traverse } from '../src/babel'; +import { generate, parse, traverse } from '../src/babel'; import { removeExports, removeUnusedImports } from '../src/plugin-utils'; function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { @@ -73,4 +73,28 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); + + it('keeps top-level declarations referenced from JSX after removing exports', () => { + const code = ` + export function loader() { + return null; + } + + function ProgressBar() { + return null; + } + + export default function Route() { + return ; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + + expect(result).toContain('function ProgressBar'); + expect(result).toContain(' Date: Wed, 17 Jun 2026 21:41:04 +0200 Subject: [PATCH 12/30] perf: reduce route artifact build overhead --- package.json | 5 +- pnpm-lock.yaml | 10 - scripts/bench-client-entry-analysis.mjs | 417 ++++++++++++++++++++++ scripts/benchmark/fixture.mjs | 210 ++++++++++- scripts/compare-client-entry-analysis.mjs | 159 +++++++++ src/constants.ts | 4 + src/export-utils.ts | 103 +++--- src/index.ts | 281 +++++---------- src/manifest.ts | 9 +- src/performance.ts | 4 +- src/plugin-utils.ts | 12 +- src/route-artifacts.ts | 157 ++++++++ src/route-chunks.ts | 37 +- src/types.ts | 13 - src/virtual-modules.ts | 30 ++ tests/benchmark-fixture.test.ts | 75 ++++ tests/features.test.ts | 49 ++- tests/route-artifacts.test.ts | 181 ++++++++++ tests/setup.ts | 2 +- 19 files changed, 1470 insertions(+), 288 deletions(-) create mode 100644 scripts/bench-client-entry-analysis.mjs create mode 100644 scripts/compare-client-entry-analysis.mjs create mode 100644 src/route-artifacts.ts create mode 100644 src/virtual-modules.ts create mode 100644 tests/route-artifacts.test.ts diff --git a/package.json b/package.json index 881ceda..ff92028 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "scripts": { "build": "rslib build", "bench": "node scripts/bench-builds.mjs", + "bench:micro": "node scripts/bench-client-entry-analysis.mjs", "bench:compare": "node scripts/compare-benchmarks.mjs", + "bench:compare:micro": "node scripts/compare-client-entry-analysis.mjs", "bench:smoke": "node scripts/bench-builds.mjs --profile smoke --iterations 1 --warmup 0 --format both --out .benchmark/results/smoke", "bench:baseline": "node scripts/bench-builds.mjs --profile default --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/baseline", "bench:full": "node scripts/bench-builds.mjs --profile full --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/full", @@ -85,8 +87,7 @@ "jiti": "^2.6.1", "jsesc": "^3.1.0", "pathe": "^2.0.3", - "react-refresh": "^0.18.0", - "rspack-plugin-virtual-module": "^1.0.1" + "react-refresh": "^0.18.0" }, "devDependencies": { "@changesets/cli": "^2.29.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 596ac75..d20df48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: react-refresh: specifier: ^0.18.0 version: 0.18.0 - rspack-plugin-virtual-module: - specifier: ^1.0.1 - version: 1.0.1 devDependencies: '@changesets/cli': specifier: ^2.29.8 @@ -8305,9 +8302,6 @@ packages: resolution: {integrity: sha512-DCUkRKUBR1lSpHKRcxNvHaYwGrUVf9MsoE1u6gd0CF37I8vwwtWc4b+FA9OwYZ4QA/shslzAYorD3MMfd+Rs/Q==} engines: {node: ^20.19.0 || >=22.12.0} - rspack-plugin-virtual-module@1.0.1: - resolution: {integrity: sha512-NQJ3fXa1v0WayvfHMWbyqLUA3JIqgCkhIcIOnZscuisinxorQyIAo+bqcU5pCusMKSyPqVIWO3caQyl0s9VDAg==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -16478,10 +16472,6 @@ snapshots: rslog@2.1.3: {} - rspack-plugin-virtual-module@1.0.1: - dependencies: - fs-extra: 11.3.3 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 diff --git a/scripts/bench-client-entry-analysis.mjs b/scripts/bench-client-entry-analysis.mjs new file mode 100644 index 0000000..ea32505 --- /dev/null +++ b/scripts/bench-client-entry-analysis.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { parseArgs as parseCliArgs } from 'node:util'; +import { createJiti } from 'jiti'; +import { generateSyntheticFixture, routeFile } from './benchmark/fixture.mjs'; + +const rootDir = process.cwd(); +const schemaVersion = 1; + +const parseArgs = argv => { + const { values } = parseCliArgs({ + args: argv, + allowPositionals: false, + strict: true, + options: { + routes: { type: 'string', default: '256' }, + variant: { type: 'string', default: 'ssr-esm-split' }, + fixture: { type: 'string', default: 'default' }, + iterations: { type: 'string', default: '50' }, + warmup: { type: 'string', default: '5' }, + out: { + type: 'string', + default: path.join( + '.benchmark', + 'results', + 'micro-client-entry-analysis.json' + ), + }, + 'fixture-root': { type: 'string' }, + 'reuse-fixture': { type: 'boolean', default: false }, + environment: { type: 'string', default: 'both' }, + cache: { type: 'string', default: 'cold' }, + format: { type: 'string', default: 'both' }, + }, + }); + + const args = { + routes: Number(values.routes), + variant: values.variant, + fixture: values.fixture, + iterations: Number(values.iterations), + warmup: Number(values.warmup), + out: values.out, + fixtureRoot: values['fixture-root'], + reuseFixture: values['reuse-fixture'], + environment: values.environment, + cache: values.cache, + format: values.format, + }; + + if (!Number.isInteger(args.routes) || args.routes < 1) { + throw new Error('--routes must be a positive integer.'); + } + if (!Number.isInteger(args.iterations) || args.iterations < 1) { + throw new Error('--iterations must be a positive integer.'); + } + if (!Number.isInteger(args.warmup) || args.warmup < 0) { + throw new Error('--warmup must be a non-negative integer.'); + } + if (!['client', 'server', 'both'].includes(args.environment)) { + throw new Error('--environment must be client, server, or both.'); + } + if (!['cold', 'warm'].includes(args.cache)) { + throw new Error('--cache must be cold or warm.'); + } + if (!['json', 'md', 'markdown', 'both'].includes(args.format)) { + throw new Error('--format must be json, md, markdown, or both.'); + } + + return args; +}; + +const summarizeMetric = values => { + const sorted = values + .filter(value => typeof value === 'number' && Number.isFinite(value)) + .sort((a, b) => a - b); + if (sorted.length === 0) { + return { min: null, mean: null, p95: null, stdev: null, max: null }; + } + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + const variance = + sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; + const p95Index = Math.min( + sorted.length - 1, + Math.ceil(sorted.length * 0.95) - 1 + ); + return { + min: sorted[0], + mean, + p95: sorted[p95Index], + stdev: Math.sqrt(variance), + max: sorted[sorted.length - 1], + }; +}; + +const timeAsync = async callback => { + const start = performance.now(); + const value = await callback(); + return { value, ms: performance.now() - start }; +}; + +const timeSync = callback => { + const start = performance.now(); + const value = callback(); + return { value, ms: performance.now() - start }; +}; + +const environmentNames = mode => { + if (mode === 'both') { + return ['client', 'server']; + } + return [mode]; +}; + +const shouldSplitRouteModules = variant => variant.includes('split'); + +const loadPluginInternals = async () => { + const jiti = createJiti(import.meta.url, { + interopDefault: true, + }); + const [exportUtils, routeArtifacts] = await Promise.all([ + jiti.import(path.join(rootDir, 'src/export-utils.ts')), + jiti.import(path.join(rootDir, 'src/route-artifacts.ts')), + ]); + return { + getBundlerRouteAnalysis: exportUtils.getBundlerRouteAnalysis, + buildRouteClientEntryCode: routeArtifacts.buildRouteClientEntryCode, + }; +}; + +const readRouteSources = async (fixtureRoot, routeCount) => + Promise.all( + Array.from({ length: routeCount }, async (_, routeIndex) => { + const index = routeIndex + 1; + const resourcePath = path.join(fixtureRoot, 'app', routeFile(index)); + return { + index, + resourcePath, + source: await readFile(resourcePath, 'utf8'), + }; + }) + ); + +const runRoute = async ({ + route, + iteration, + environment, + cacheMode, + splitRouteModules, + routeChunkCache, + routeChunkConfig, + internals, +}) => { + const isServer = environment === 'server'; + const benchmarkSource = + cacheMode === 'cold' + ? `${route.source}\nconst __clientEntryAnalysisBenchmarkSalt_${iteration}_${environment}_${route.index} = ${iteration + route.index};\n` + : route.source; + const benchmarkResourcePath = + cacheMode === 'cold' + ? path.join( + path.dirname(route.resourcePath), + `.micro-${iteration}-${environment}-${path.basename(route.resourcePath)}` + ) + : route.resourcePath; + + const transformExport = await timeAsync(async () => { + const analysis = await internals.getBundlerRouteAnalysis( + benchmarkSource, + benchmarkResourcePath + ); + const exportNames = await analysis.getExportNames(); + return { analysis, exportNames }; + }); + + const routeChunk = await timeAsync(async () => { + if (isServer || !splitRouteModules) { + return { chunkedExports: [] }; + } + return transformExport.value.analysis.getRouteChunkInfo( + routeChunkCache, + routeChunkConfig + ); + }); + + const filterCodegen = timeSync(() => { + return internals.buildRouteClientEntryCode({ + exportNames: transformExport.value.exportNames, + chunkedExports: routeChunk.value.chunkedExports, + isServer, + resourcePath: route.resourcePath, + }); + }); + + const totalMs = transformExport.ms + routeChunk.ms + filterCodegen.ms; + return { + route: route.index, + environment, + timings: { + transformExportMs: transformExport.ms, + routeChunkInfoMs: routeChunk.ms, + filterCodegenMs: filterCodegen.ms, + totalMs, + }, + operations: { + exportNames: transformExport.value.exportNames.length, + reexports: filterCodegen.value.reexports.length, + chunkedExports: routeChunk.value.chunkedExports.length, + codegenBytes: Buffer.byteLength(filterCodegen.value.code), + }, + }; +}; + +const renderMarkdown = result => { + const lines = [ + '# Route Client-entry Analysis Microbenchmark', + '', + `- Schema version: ${result.schemaVersion}`, + `- Date: ${result.date}`, + `- Node: ${result.node}`, + `- Platform: ${result.platform}`, + `- Routes: ${result.routeCount}`, + `- Variant: ${result.variant}`, + `- Fixture: ${result.fixture}`, + `- Split route modules: ${result.splitRouteModules}`, + `- Cache mode: ${result.cacheMode}`, + `- Environments: ${result.environments.join(', ')}`, + `- Iterations: ${result.iterations}`, + `- Warmup: ${result.warmup}`, + '', + '## Phase timings per route', + '', + '| Phase | Mean | p95 | Stdev |', + '|---|---:|---:|---:|', + ]; + + for (const [phase, stats] of Object.entries(result.summary.phases)) { + lines.push( + `| ${phase} | ${stats.mean?.toFixed(3) ?? '-'}ms | ${stats.p95?.toFixed(3) ?? '-'}ms | ${stats.stdev?.toFixed(3) ?? '-'}ms |` + ); + } + + lines.push( + '', + '## Operation counts', + '', + '| Operation | Count |', + '|---|---:|' + ); + for (const [operation, count] of Object.entries(result.operationCounts)) { + lines.push(`| ${operation} | ${count} |`); + } + + lines.push(''); + return `${lines.join('\n')}\n`; +}; + +const writeOutputs = async (result, args) => { + const outPath = path.resolve(rootDir, args.out); + const format = args.format === 'markdown' ? 'md' : args.format; + const writeJson = format === 'json' || format === 'both'; + const writeMd = format === 'md' || format === 'both'; + const jsonPath = outPath.endsWith('.json') ? outPath : `${outPath}.json`; + const mdPath = outPath.endsWith('.json') + ? outPath.replace(/\.json$/, '.md') + : `${outPath}.md`; + + await mkdir(path.dirname(outPath), { recursive: true }); + if (writeJson) { + await writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`); + } + if (writeMd) { + await writeFile(mdPath, renderMarkdown(result)); + } + return { + jsonPath: writeJson ? jsonPath : null, + mdPath: writeMd ? mdPath : null, + }; +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + const fixtureRoot = path.resolve( + rootDir, + args.fixtureRoot ?? + path.join( + '.benchmark', + 'fixtures', + `micro-client-entry-${args.routes}-${args.variant}-${args.fixture}` + ) + ); + + if (!args.reuseFixture) { + await generateSyntheticFixture({ + root: fixtureRoot, + routeCount: args.routes, + variant: args.variant, + fixture: args.fixture, + }); + } + + const internals = await loadPluginInternals(); + const routes = await readRouteSources(fixtureRoot, args.routes); + const environments = environmentNames(args.environment); + const splitRouteModules = shouldSplitRouteModules(args.variant); + const routeChunkConfig = { + splitRouteModules, + appDirectory: path.join(fixtureRoot, 'app'), + rootRouteFile: 'root.tsx', + }; + const routeChunkCache = args.cache === 'warm' ? new Map() : undefined; + + const measuredIterations = []; + const phaseSamples = { + transformExportMs: [], + routeChunkInfoMs: [], + filterCodegenMs: [], + totalMs: [], + }; + const operationCounts = { + routeExecutions: 0, + exportNames: 0, + reexports: 0, + chunkedExports: 0, + codegenBytes: 0, + }; + + const totalRuns = args.warmup + args.iterations; + for (let iteration = 0; iteration < totalRuns; iteration += 1) { + const measured = iteration >= args.warmup; + const heapBefore = process.memoryUsage().heapUsed; + const startedAt = performance.now(); + const routeResults = []; + + for (const environment of environments) { + for (const route of routes) { + const result = await runRoute({ + route, + iteration, + environment, + cacheMode: args.cache, + splitRouteModules, + routeChunkCache: args.cache === 'cold' ? new Map() : routeChunkCache, + routeChunkConfig, + internals, + }); + routeResults.push(result); + } + } + + const heapAfter = process.memoryUsage().heapUsed; + if (measured) { + for (const result of routeResults) { + for (const [phase, value] of Object.entries(result.timings)) { + phaseSamples[phase].push(value); + } + operationCounts.routeExecutions += 1; + operationCounts.exportNames += result.operations.exportNames; + operationCounts.reexports += result.operations.reexports; + operationCounts.chunkedExports += result.operations.chunkedExports; + operationCounts.codegenBytes += result.operations.codegenBytes; + } + measuredIterations.push({ + iteration: measuredIterations.length + 1, + wallMs: performance.now() - startedAt, + heapDeltaBytes: heapAfter - heapBefore, + routeExecutions: routeResults.length, + }); + } + } + + const result = { + schema: 'rsbuild-plugin-react-router/client-entry-analysis-benchmark', + schemaVersion, + date: new Date().toISOString(), + node: process.version, + platform: `${os.platform()} ${os.release()} ${os.arch()}`, + routeCount: args.routes, + variant: args.variant, + fixture: args.fixture, + splitRouteModules, + environments, + cacheMode: args.cache, + iterations: args.iterations, + warmup: args.warmup, + fixtureRoot, + summary: { + phases: Object.fromEntries( + Object.entries(phaseSamples).map(([phase, samples]) => [ + phase, + summarizeMetric(samples), + ]) + ), + iterationWallMs: summarizeMetric( + measuredIterations.map(run => run.wallMs) + ), + heapDeltaBytes: summarizeMetric( + measuredIterations.map(run => run.heapDeltaBytes) + ), + }, + operationCounts, + runs: measuredIterations, + }; + + const outputs = await writeOutputs(result, args); + console.log( + `Wrote client-entry analysis benchmark${outputs.jsonPath ? ` JSON to ${path.relative(rootDir, outputs.jsonPath)}` : ''}${outputs.mdPath ? ` and markdown to ${path.relative(rootDir, outputs.mdPath)}` : ''}.` + ); +}; + +main().catch(error => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index ec6d614..e37f9a9 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -10,21 +10,40 @@ const routeExportProfiles = [ 'client-server-imports', ]; -const padRoute = number => String(number).padStart(4, '0'); +const stressFixtureNames = new Set([ + 'default', + 'export-heavy', + 'reexports', + 'import-fanout', + 'chunk-saturated', +]); -const routeFile = index => `routes/route-${padRoute(index)}.tsx`; +export const benchmarkFixtureNames = [...stressFixtureNames]; -const routeId = index => `route-${padRoute(index)}`; +export const padRoute = number => String(number).padStart(4, '0'); + +export const routeFile = index => `routes/route-${padRoute(index)}.tsx`; + +export const routeId = index => `route-${padRoute(index)}`; const routeComponentName = index => `Route${padRoute(index)}`; -const createRouteModule = (index, profile, { isSpa }) => { +const createSharedRouteExports = (index, { includeHeaders = false } = {}) => { const name = routeComponentName(index); - const shared = [ + return [ `export const handle = { label: '${routeId(index)}' };`, `export function meta() { return [{ title: '${routeId(index)}' }]; }`, `export default function ${name}() { return null; }`, + ...(includeHeaders + ? [ + `export function headers() { return { 'x-route': '${routeId(index)}' }; }`, + ] + : []), ]; +}; + +const createDefaultRouteModule = (index, profile, { isSpa }) => { + const shared = createSharedRouteExports(index); if (profile === 'ssr-data') { if (isSpa) { @@ -76,6 +95,103 @@ const createRouteModule = (index, profile, { isSpa }) => { return shared.join('\n'); }; +const createExportHeavyRouteModule = (index, { isSpa }) => { + const extraExports = Array.from({ length: 32 }, (_, extraIndex) => { + const exportName = `unusedExport${padRoute(index)}_${String(extraIndex).padStart(2, '0')}`; + return `export const ${exportName} = '${routeId(index)}-${extraIndex}';`; + }); + + return [ + `import { clientValue } from '../client-data.client';`, + ...(isSpa ? [] : [`import { serverValue } from '../server-data.server';`]), + ...createSharedRouteExports(index, { includeHeaders: !isSpa }), + `export function links() { return []; }`, + `export function shouldRevalidate() { return false; }`, + `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, + `export async function clientAction() { return { ok: true }; }`, + `export async function clientMiddleware() { return undefined; }`, + ...(isSpa + ? [] + : [ + `export async function loader() { return { id: '${routeId(index)}', serverValue }; }`, + `export async function action() { return { ok: true }; }`, + `export function HydrateFallback() { return null; }`, + ]), + ...extraExports, + ].join('\n'); +}; + +const createReexportsRouteModule = (index, { isSpa }) => + [ + `export { default, handle, meta, shouldRevalidate } from '../route-reexports/reexport-${padRoute(index)}';`, + `export { clientLoader, clientAction, clientMiddleware${isSpa ? '' : ', HydrateFallback'} } from '../route-reexports/reexport-${padRoute(index)}';`, + ...(isSpa + ? [] + : [ + `export { loader, action, headers } from '../route-reexports/reexport-${padRoute(index)}';`, + ]), + `export * from '../route-reexports/reexport-all-${padRoute(index)}';`, + ].join('\n'); + +const createImportFanoutRouteModule = (index, { isSpa }) => { + const imports = Array.from({ length: 16 }, (_, fanoutIndex) => { + const suffix = String(fanoutIndex).padStart(2, '0'); + return `import { fanoutValue${suffix} } from '../fanout/fanout-${suffix}';`; + }); + const values = Array.from( + { length: 16 }, + (_, fanoutIndex) => `fanoutValue${String(fanoutIndex).padStart(2, '0')}` + ).join(', '); + + return [ + ...imports, + ...createSharedRouteExports(index, { includeHeaders: !isSpa }), + `const fanoutValues = [${values}];`, + `export function shouldRevalidate() { return fanoutValues.length > ${index % 7}; }`, + `export async function clientLoader() { return { values: fanoutValues }; }`, + ...(isSpa + ? [] + : [ + `export async function loader() { return { values: fanoutValues }; }`, + ]), + ].join('\n'); +}; + +const createChunkSaturatedRouteModule = (index, { isSpa }) => + [ + `import { clientValue } from '../client-data.client';`, + ...(isSpa ? [] : [`import { serverValue } from '../server-data.server';`]), + ...createSharedRouteExports(index, { includeHeaders: !isSpa }), + `const routeLabel = '${routeId(index)}';`, + `export function shouldRevalidate() { return routeLabel.length > 0; }`, + `export async function clientLoader() { return { routeLabel, clientValue }; }`, + `export async function clientAction() { return { ok: routeLabel, clientValue }; }`, + `export async function clientMiddleware() { return undefined; }`, + ...(isSpa + ? [] + : [ + `export function HydrateFallback() { return null; }`, + `export async function loader() { return { routeLabel, serverValue }; }`, + `export async function action() { return { ok: true, serverValue }; }`, + ]), + ].join('\n'); + +const createRouteModule = (index, profile, { isSpa, fixture }) => { + if (fixture === 'export-heavy') { + return createExportHeavyRouteModule(index, { isSpa }); + } + if (fixture === 'reexports') { + return createReexportsRouteModule(index, { isSpa }); + } + if (fixture === 'import-fanout') { + return createImportFanoutRouteModule(index, { isSpa }); + } + if (fixture === 'chunk-saturated') { + return createChunkSaturatedRouteModule(index, { isSpa }); + } + return createDefaultRouteModule(index, profile, { isSpa }); +}; + const createRoutesConfig = routeCount => { const routes = []; for (let index = 1; index <= routeCount; index += 1) { @@ -141,13 +257,87 @@ const createReactRouterConfig = variant => { ].join('\n'); }; +const writeReexportFixtures = async (root, routeCount, { isSpa }) => { + await mkdir(path.join(root, 'app/route-reexports'), { recursive: true }); + const batchSize = 64; + for (let batchStart = 0; batchStart < routeCount; batchStart += batchSize) { + await Promise.all( + Array.from( + { length: Math.min(batchSize, routeCount - batchStart) }, + (_, batchIndex) => { + const routeIndex = batchStart + batchIndex; + const index = routeIndex + 1; + const module = [ + `import { clientValue } from '../client-data.client';`, + ...(isSpa + ? [] + : [`import { serverValue } from '../server-data.server';`]), + ...createSharedRouteExports(index, { includeHeaders: !isSpa }), + `export function shouldRevalidate() { return false; }`, + `export async function clientLoader() { return { id: '${routeId(index)}', clientValue }; }`, + `export async function clientAction() { return { ok: true }; }`, + `export async function clientMiddleware() { return undefined; }`, + ...(isSpa + ? [] + : [ + `export async function loader() { return { id: '${routeId(index)}', serverValue }; }`, + `export async function action() { return { ok: true }; }`, + `export function HydrateFallback() { return null; }`, + ]), + ].join('\n'); + const exportAllModule = [ + `export const reexportedValue${padRoute(index)} = '${routeId(index)}';`, + `export function reexportedHelper${padRoute(index)}() { return reexportedValue${padRoute(index)}; }`, + ].join('\n'); + return Promise.all([ + writeFile( + path.join( + root, + `app/route-reexports/reexport-${padRoute(index)}.ts` + ), + `${module}\n` + ), + writeFile( + path.join( + root, + `app/route-reexports/reexport-all-${padRoute(index)}.ts` + ), + `${exportAllModule}\n` + ), + ]); + } + ) + ); + } +}; + +const writeFanoutFixtures = async root => { + await mkdir(path.join(root, 'app/fanout'), { recursive: true }); + await Promise.all( + Array.from({ length: 16 }, (_, fanoutIndex) => { + const suffix = String(fanoutIndex).padStart(2, '0'); + return writeFile( + path.join(root, `app/fanout/fanout-${suffix}.ts`), + `export const fanoutValue${suffix} = '${suffix}';\n` + ); + }) + ); +}; + export async function generateSyntheticFixture({ root, routeCount, variant, sourceMap = false, pluginImportPath = 'rsbuild-plugin-react-router', + fixture = 'default', }) { + if (!stressFixtureNames.has(fixture)) { + throw new Error( + `Unknown benchmark fixture "${fixture}". Use ${[...stressFixtureNames].join(', ')}.` + ); + } + const isSpa = variant === 'spa'; await rm(root, { recursive: true, force: true }); @@ -195,13 +385,20 @@ export async function generateSyntheticFixture({ `export const serverValue = 'server';\n` ); + if (fixture === 'reexports') { + await writeReexportFixtures(root, routeCount, { isSpa }); + } + if (fixture === 'import-fanout') { + await writeFanoutFixtures(root); + } + await Promise.all( Array.from({ length: routeCount }, (_, routeIndex) => { const index = routeIndex + 1; const profile = routeExportProfiles[index % routeExportProfiles.length]; return writeFile( path.join(root, 'app', routeFile(index)), - `${createRouteModule(index, profile, { isSpa })}\n` + `${createRouteModule(index, profile, { isSpa, fixture })}\n` ); }) ); @@ -211,5 +408,6 @@ export async function generateSyntheticFixture({ routeCount, variant, sourceMap, + fixture, }; } diff --git a/scripts/compare-client-entry-analysis.mjs b/scripts/compare-client-entry-analysis.mjs new file mode 100644 index 0000000..9370d94 --- /dev/null +++ b/scripts/compare-client-entry-analysis.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; + +const expectedSchema = + 'rsbuild-plugin-react-router/client-entry-analysis-benchmark'; +const expectedSchemaVersion = 1; + +const { values } = parseArgs({ + allowPositionals: false, + strict: true, + options: { + before: { type: 'string' }, + after: { type: 'string' }, + }, +}); + +if (!values.before || !values.after) { + throw new Error( + 'Usage: node scripts/compare-client-entry-analysis.mjs --before --after ' + ); +} + +const readJson = async file => JSON.parse(await readFile(file, 'utf8')); + +const validateResult = (result, label) => { + if (result.schema !== expectedSchema) { + throw new Error( + `${label} has unsupported schema ${JSON.stringify(result.schema)}; expected ${JSON.stringify(expectedSchema)}.` + ); + } + if (result.schemaVersion !== expectedSchemaVersion) { + throw new Error( + `${label} has unsupported schemaVersion ${JSON.stringify(result.schemaVersion)}; expected ${expectedSchemaVersion}.` + ); + } +}; + +const percentDelta = (beforeValue, afterValue) => { + if (beforeValue == null || afterValue == null || beforeValue === 0) { + return '-'; + } + return `${(((afterValue - beforeValue) / beforeValue) * 100).toFixed(1)}%`; +}; + +const formatMs = value => (value == null ? '-' : `${value.toFixed(3)}ms`); +const formatBytes = value => + value == null ? '-' : `${Math.round(value / 1024).toLocaleString()} KiB`; +const formatCount = value => + value == null ? '-' : Math.round(value).toLocaleString(); + +const metric = (result, path) => + path.split('.').reduce((value, key) => value?.[key], result); + +const sameConfigKeys = [ + 'routeCount', + 'variant', + 'fixture', + 'splitRouteModules', + 'cacheMode', + 'iterations', + 'warmup', +]; + +const before = await readJson(values.before); +const after = await readJson(values.after); +validateResult(before, 'before'); +validateResult(after, 'after'); + +const mismatches = sameConfigKeys.filter( + key => JSON.stringify(before[key]) !== JSON.stringify(after[key]) +); +if (mismatches.length > 0) { + throw new Error( + `Cannot compare benchmark files with different ${mismatches.join(', ')} values.` + ); +} +if ( + JSON.stringify(before.environments) !== JSON.stringify(after.environments) +) { + throw new Error( + 'Cannot compare benchmark files with different environments.' + ); +} + +const rows = [ + { + label: 'transform/export-info mean', + before: metric(before, 'summary.phases.transformExportMs.mean'), + after: metric(after, 'summary.phases.transformExportMs.mean'), + format: formatMs, + }, + { + label: 'transform/export-info p95', + before: metric(before, 'summary.phases.transformExportMs.p95'), + after: metric(after, 'summary.phases.transformExportMs.p95'), + format: formatMs, + }, + { + label: 'route-chunk-info mean', + before: metric(before, 'summary.phases.routeChunkInfoMs.mean'), + after: metric(after, 'summary.phases.routeChunkInfoMs.mean'), + format: formatMs, + }, + { + label: 'filter/codegen-string mean', + before: metric(before, 'summary.phases.filterCodegenMs.mean'), + after: metric(after, 'summary.phases.filterCodegenMs.mean'), + format: formatMs, + }, + { + label: 'total per-route mean', + before: metric(before, 'summary.phases.totalMs.mean'), + after: metric(after, 'summary.phases.totalMs.mean'), + format: formatMs, + }, + { + label: 'iteration wall mean', + before: metric(before, 'summary.iterationWallMs.mean'), + after: metric(after, 'summary.iterationWallMs.mean'), + format: formatMs, + }, + { + label: 'heap delta mean', + before: metric(before, 'summary.heapDeltaBytes.mean'), + after: metric(after, 'summary.heapDeltaBytes.mean'), + format: formatBytes, + }, + { + label: 'route executions', + before: metric(before, 'operationCounts.routeExecutions'), + after: metric(after, 'operationCounts.routeExecutions'), + format: formatCount, + }, + { + label: 'export names scanned', + before: metric(before, 'operationCounts.exportNames'), + after: metric(after, 'operationCounts.exportNames'), + format: formatCount, + }, + { + label: 'generated reexports', + before: metric(before, 'operationCounts.reexports'), + after: metric(after, 'operationCounts.reexports'), + format: formatCount, + }, +]; + +console.log( + `Client-entry analysis comparison: ${before.routeCount} routes, ${before.variant}, ${before.fixture}, environments=${before.environments.join(',')}` +); +console.log(''); +console.log('| Metric | Before | After | Delta |'); +console.log('|---|---:|---:|---:|'); +for (const row of rows) { + console.log( + `| ${row.label} | ${row.format(row.before)} | ${row.format(row.after)} | ${percentDelta(row.before, row.after)} |` + ); +} diff --git a/src/constants.ts b/src/constants.ts index 6ad1d63..7af426d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -65,6 +65,10 @@ export const NAMED_COMPONENT_EXPORTS = [ 'ErrorBoundary', ] as const; +export const NAMED_COMPONENT_EXPORTS_SET: ReadonlySet = new Set( + NAMED_COMPONENT_EXPORTS +); + export const SERVER_EXPORTS = { loader: 'loader', action: 'action', diff --git a/src/export-utils.ts b/src/export-utils.ts index 378333f..e4ff510 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -30,7 +30,6 @@ type BundlerRouteAnalysisCacheEntry = { }; type RouteModuleAnalysis = { - source: string; code: string; exports: string[]; exportAllModules: string[]; @@ -72,6 +71,15 @@ const setBoundedCacheEntry = ( cache.set(key, value); }; +const cachePromiseOnReject = ( + promise: Promise, + invalidate: () => void +): Promise => + promise.catch(error => { + invalidate(); + throw error; + }); + const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { const ext = extname(resourcePath) as keyof typeof JS_LOADERS; return JS_LOADERS[ext] ?? 'js'; @@ -89,20 +97,22 @@ export const transformToEsm = async ( return cached.transformed; } - const transformed = esbuild - .transform(code, { - jsx: 'automatic', - format: 'esm', - platform: 'neutral', - loader: getEsbuildLoader(resourcePath), - }) - .then(result => result.code) - .catch(error => { + let transformed: Promise; + transformed = cachePromiseOnReject( + esbuild + .transform(code, { + jsx: 'automatic', + format: 'esm', + platform: 'neutral', + loader: getEsbuildLoader(resourcePath), + }) + .then(result => result.code), + () => { if (transformCache.get(resourcePath)?.transformed === transformed) { transformCache.delete(resourcePath); } - throw error; - }); + } + ); setBoundedCacheEntry(transformCache, resourcePath, { source: code, @@ -145,34 +155,36 @@ export const getBundlerRouteAnalysis = async ( return cachedRouteChunkInfo; } - const routeChunkInfo = detectRouteChunksIfEnabled( - cache, - config, - resourcePath, - code - ).catch(error => { - if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { - routeChunkInfoCache.delete(cacheKey); + let routeChunkInfo: Promise; + routeChunkInfo = cachePromiseOnReject( + detectRouteChunksIfEnabled(cache, config, resourcePath, code), + () => { + if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { + routeChunkInfoCache.delete(cacheKey); + } } - throw error; - }); + ); routeChunkInfoCache.set(cacheKey, routeChunkInfo); return routeChunkInfo; }, }; - })().catch(error => { - if (bundlerRouteAnalysisCache.get(resourcePath)?.analysis === analysis) { + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + bundlerRouteAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { bundlerRouteAnalysisCache.delete(resourcePath); } - throw error; }); setBoundedCacheEntry(bundlerRouteAnalysisCache, resourcePath, { source, - analysis, + analysis: trackedAnalysis, }); - return analysis; + return trackedAnalysis; }; export const getExportNamesAndExportAll = async ( @@ -203,15 +215,20 @@ export const getExportNamesAndExportAll = async ( } } return { exportNames: Array.from(exportNames), exportAllModules }; - })().catch(error => { - if (exportInfoCache.get(code) === exportInfo) { + })(); + + let trackedExportInfo: Promise<{ + exportNames: string[]; + exportAllModules: string[]; + }>; + trackedExportInfo = cachePromiseOnReject(exportInfo, () => { + if (exportInfoCache.get(code) === trackedExportInfo) { exportInfoCache.delete(code); } - throw error; }); - setBoundedCacheEntry(exportInfoCache, code, exportInfo); - return exportInfo; + setBoundedCacheEntry(exportInfoCache, code, trackedExportInfo); + return trackedExportInfo; }; export const getRouteModuleAnalysis = async ( @@ -228,24 +245,22 @@ export const getRouteModuleAnalysis = async ( const code = await transformToEsm(source, resourcePath); const { exportNames, exportAllModules } = await getExportNamesAndExportAll(code); - return { source, code, exports: exportNames, exportAllModules }; - })().catch(error => { - if (routeModuleAnalysisCache.get(resourcePath)?.analysis === analysis) { + return { code, exports: exportNames, exportAllModules }; + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + routeModuleAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { routeModuleAnalysisCache.delete(resourcePath); } - throw error; }); setBoundedCacheEntry(routeModuleAnalysisCache, resourcePath, { mtimeMs: stats.mtimeMs, size: stats.size, - analysis, + analysis: trackedAnalysis, }); - return analysis; -}; - -export const getRouteModuleExports = async ( - resourcePath: string -): Promise => { - return (await getRouteModuleAnalysis(resourcePath)).exports; + return trackedAnalysis; }; diff --git a/src/index.ts b/src/index.ts index cccb412..fba53d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,15 +5,14 @@ import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; -import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; + import { generate, parse } from './babel.js'; import { BUILD_CLIENT_ROUTE_QUERY_STRING, - CLIENT_ROUTE_EXPORTS_SET, JS_EXTENSIONS, PLUGIN_NAME, SERVER_ONLY_ROUTE_EXPORTS, @@ -52,21 +51,21 @@ import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js' import { createRequestHandler, matchRoutes } from 'react-router'; import { getBundlerRouteAnalysis, - getExportNames, getExportNamesAndExportAll, getRouteModuleAnalysis, transformToEsm, } from './export-utils.js'; import { getRouteChunkEntryName, - getRouteChunkIfEnabled, getRouteChunkModuleId, - getRouteChunkNameFromModuleId, routeChunkExportNames, - validateRouteChunks, type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; +import { + createRouteChunkArtifact, + createRouteClientEntryArtifact, +} from './route-artifacts.js'; import { validateRouteConfig } from './route-config.js'; import { getBuildManifest, @@ -75,7 +74,11 @@ import { import { warnOnClientSourceMaps } from './warnings/warn-on-client-source-maps.js'; import { validatePluginOrderFromConfig } from './validation/validate-plugin-order.js'; import { getSsrExternals } from './ssr-externals.js'; -import { createReactRouterPerformanceProfiler } from './performance.js'; +import { + createReactRouterPerformanceProfiler, + roundMs, +} from './performance.js'; +import { mapVirtualModules } from './virtual-modules.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -396,7 +399,6 @@ export const pluginReactRouter = ( const isBuild = api.context.action === 'build'; const splitRouteModules = future?.v8_splitRouteModules ?? false; - const enforceSplitRouteModules = splitRouteModules === 'enforce'; const routeChunkConfig: RouteChunkConfig = { splitRouteModules, appDirectory, @@ -481,8 +483,7 @@ export const pluginReactRouter = ( } if (logPerformance) { performanceProfiler.flush(environment.name, { - compilerLifecycleMs: - Math.round((performance.now() - setupStartMs) * 10) / 10, + compilerLifecycleMs: roundMs(performance.now() - setupStartMs), }); } }); @@ -1006,10 +1007,10 @@ export const pluginReactRouter = ( const allowedActionOriginsForBuild = allowedActionOrigins === false ? undefined : allowedActionOrigins; - // Create virtual modules for React Router - const vmodTempDir = `rspack-virtual-module-${process.pid}-${Math.random() - .toString(16) - .slice(2)}`; + // Create virtual modules for React Router. Rspack's built-in + // VirtualModulesPlugin registers resolvable file paths, so keep public + // requests as bare `virtual/react-router/*` ids and seed matching + // `node_modules/virtual/react-router/*.js` virtual files. const createVirtualModulePlugin = (publicPath: string) => { const bundleVirtualModules = Object.fromEntries( Object.entries(routesByServerBundleId).map( @@ -1044,8 +1045,8 @@ export const pluginReactRouter = ( ]) ); - return new RspackVirtualModulePlugin( - { + return new rspack.experiments.VirtualModulesPlugin( + mapVirtualModules({ 'virtual/react-router/browser-manifest': 'export default {};', 'virtual/react-router/server-manifest': 'export default {};', 'virtual/react-router/server-build': generateServerBuild(routes, { @@ -1064,8 +1065,7 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), - }, - vmodTempDir + }) ); }; @@ -1175,42 +1175,35 @@ export const pluginReactRouter = ( // Always include node environment, even for SPA mode (`ssr:false`), // because React Router still needs a server build to prerender the // root route into a hydratable `index.html` at build time. - ...(true - ? { - node: { - source: { - entry: nodeEntries, - }, - output: { - distPath: { - root: resolve(buildDirectory, 'server'), - }, - target: config.environments?.node?.output?.target || 'node', - filename: { - js: '[name].js', - }, - }, - tools: { - rspack: { - target: options.federation ? 'async-node' : 'node', - externals: nodeExternals, - dependencies: ['web'], - externalsType: resolvedServerOutput, - output: { - chunkFormat: resolvedServerOutput, - chunkLoading: nodeChunkLoading, - workerChunkLoading: nodeChunkLoading, - wasmLoading: 'fetch', - module: resolvedServerOutput === 'module', - }, - // optimization: { - // runtimeChunk: 'single', - // }, - }, - }, + node: { + source: { + entry: nodeEntries, + }, + output: { + distPath: { + root: resolve(buildDirectory, 'server'), + }, + target: config.environments?.node?.output?.target || 'node', + filename: { + js: '[name].js', + }, + }, + tools: { + rspack: { + target: options.federation ? 'async-node' : 'node', + externals: nodeExternals, + dependencies: ['web'], + externalsType: resolvedServerOutput, + output: { + chunkFormat: resolvedServerOutput, + chunkLoading: nodeChunkLoading, + workerChunkLoading: nodeChunkLoading, + wasmLoading: 'fetch', + module: resolvedServerOutput === 'module', }, - } - : {}), + }, + }, + }, }, }); }); @@ -1372,37 +1365,14 @@ export const pluginReactRouter = ( 'route:client-entry', args.resource, async () => { - const analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - const exportNames = await analysis.getExportNames(); - const isServer = args.environment?.name === 'node'; - const chunkedExports = - !isServer && isBuild && splitRouteModules - ? ( - await analysis.getRouteChunkInfo( - routeChunkCache, - routeChunkConfig - ) - ).chunkedExports - : []; - const chunkedExportSet = new Set(chunkedExports); - const reexports = exportNames.filter(exp => { - if (chunkedExportSet.has(exp)) { - return false; - } - return ( - CLIENT_ROUTE_EXPORTS_SET.has(exp) || - (isServer && SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp)) - ); + return createRouteClientEntryArtifact({ + code: args.code, + resourcePath: args.resourcePath, + environmentName: args.environment?.name, + isBuild, + routeChunkCache, + routeChunkConfig, }); - const target = `${args.resourcePath}?react-router-route`; - return { - code: `export { ${reexports.join(', ')} } from ${JSON.stringify( - target - )};`, - }; } ) ); @@ -1418,54 +1388,14 @@ export const pluginReactRouter = ( 'route:chunk', args.resource, async () => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - const preventEmptyChunkSnippet = (reason: string) => - `Math.random()<0&&console.log(${JSON.stringify(reason)});`; - - if (!isBuild || !splitRouteModules) { - return { - code: preventEmptyChunkSnippet('Split route modules disabled'), - map: null, - }; - } - - const chunkName = getRouteChunkNameFromModuleId(args.resource); - if (!chunkName) { - throw new Error(`Invalid route chunk name in "${args.resource}"`); - } - - const transformed = await transformToEsm( - args.code, - args.resourcePath - ); - const chunk = await getRouteChunkIfEnabled( + return createRouteChunkArtifact({ + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + isBuild, routeChunkCache, routeChunkConfig, - args.resourcePath, - chunkName, - transformed - ); - - if (enforceSplitRouteModules && chunkName === 'main' && chunk) { - const exportNames = await getExportNames(chunk); - validateRouteChunks({ - config: routeChunkConfig, - id: args.resourcePath, - valid: { - clientAction: !exportNames.includes('clientAction'), - clientLoader: !exportNames.includes('clientLoader'), - clientMiddleware: !exportNames.includes('clientMiddleware'), - HydrateFallback: !exportNames.includes('HydrateFallback'), - }, - }); - } - - return { - code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), - map: null, - }; + }); } ) ); @@ -1481,9 +1411,6 @@ export const pluginReactRouter = ( 'route:split-exports', args.resource, async () => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } if (!isBuild || !splitRouteModules) { return { code: args.code, map: null }; } @@ -1556,10 +1483,6 @@ export const pluginReactRouter = ( 'module:server-only-guard', args.resource, async () => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - const relativePath = relative(process.cwd(), args.resourcePath); throw new Error( `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` @@ -1579,10 +1502,6 @@ export const pluginReactRouter = ( 'module:client-only-stub', args.resource, async () => { - if (args.environment?.name !== 'node') { - return { code: args.code, map: null }; - } - const code = await transformToEsm(args.code, args.resourcePath); const { exportNames: directExportNames, exportAllModules } = await getExportNamesAndExportAll(code); @@ -1741,60 +1660,53 @@ export const pluginReactRouter = ( args.resource, async () => { let code: string; - let exportNames: string[] | undefined; try { const analysis = await getBundlerRouteAnalysis( args.code, args.resourcePath ); code = analysis.code; + + // Match React Router Vite behavior: + // In SPA mode, server-only route exports are invalid (except root `loader`), + // and `HydrateFallback` is only allowed on the root route. if (args.environment.name === 'web' && !ssr && isSpaMode) { - exportNames = await analysis.getExportNames(); + const resolvedExportNames = await analysis.getExportNames(); + const isRootRoute = args.resourcePath === rootRoutePath; + + const invalidServerOnly = resolvedExportNames.filter(exp => { + if (isRootRoute && exp === 'loader') return false; + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); + }); + + if (invalidServerOnly.length > 0) { + const list = invalidServerOnly + .map(e => `\`${e}\``) + .join(', '); + throw new Error( + `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + + `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + + if ( + !isRootRoute && + resolvedExportNames.includes('HydrateFallback') + ) { + throw new Error( + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${relative(process.cwd(), args.resourcePath)}\`. ` + + `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } } } catch (error) { console.error(args.resourcePath); throw error; } - // Match React Router Vite behavior: - // In SPA mode, server-only route exports are invalid (except root `loader`), - // and `HydrateFallback` is only allowed on the root route. - // - // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan - // the ESBuild-transformed JS output. - if (args.environment.name === 'web' && !ssr && isSpaMode) { - const resolvedExportNames = - exportNames ?? (await getExportNames(code)); - - const isRootRoute = args.resourcePath === rootRoutePath; - - const invalidServerOnly = resolvedExportNames.filter(exp => { - if (isRootRoute && exp === 'loader') return false; - return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); - }); - - if (invalidServerOnly.length > 0) { - const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); - throw new Error( - `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - - if ( - !isRootRoute && - resolvedExportNames.includes('HydrateFallback') - ) { - throw new Error( - `SPA Mode: Invalid \`HydrateFallback\` export found in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`. ` + - `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - } - const defaultExportMatch = code.match( /\n\s{0,}([\w\d_]+)\sas default,?/ ); @@ -1812,10 +1724,7 @@ export const pluginReactRouter = ( const ast = parse(code, { sourceType: 'module' }); if (args.environment.name === 'web') { - const mutableServerOnlyRouteExports = [ - ...SERVER_ONLY_ROUTE_EXPORTS, - ]; - removeExports(ast, mutableServerOnlyRouteExports); + removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); } transformRoute(ast); if (args.environment.name === 'web') { diff --git a/src/manifest.ts b/src/manifest.ts index 4d532c7..6c06793 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -7,6 +7,7 @@ import { combineURLs, createRouteId } from './plugin-utils.js'; import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js'; import { detectRouteChunksIfEnabled, + EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME, getRouteChunkEntryName, validateRouteChunks, type RouteChunkCache, @@ -14,7 +15,6 @@ import { } from './route-chunks.js'; import { getRouteModuleAnalysis } from './export-utils.js'; -// Helper functions export function configRoutesToRouteManifest( appDirectory: string, routes: RouteConfigEntry[], @@ -189,12 +189,7 @@ export async function getReactRouterManifestForDev( | 'clientMiddleware' | 'HydrateFallback', boolean - > = { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - }; + > = { ...EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME }; try { const { code, exports: exportNames } = diff --git a/src/performance.ts b/src/performance.ts index 278aa1e..fb049ca 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -19,6 +19,8 @@ type EnvironmentTimings = Map; const MAX_SLOWEST_ENTRIES = 5; +export const roundMs = (value: number): number => Math.round(value * 10) / 10; + export type ReactRouterPerformanceReport = { environment: string; compilerLifecycleMs?: number; @@ -77,8 +79,6 @@ export const createReactRouterPerformanceProfiler = ({ return timing; }; - const roundMs = (value: number) => Math.round(value * 10) / 10; - const computeWallMs = (intervals: OperationInterval[]) => { if (intervals.length === 0) { return 0; diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 519c18a..065b26e 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -6,11 +6,15 @@ import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; import type { Babel, NodePath, ParseResult } from './babel.js'; import { t, traverse } from './babel.js'; -import { NAMED_COMPONENT_EXPORTS, JS_EXTENSIONS } from './constants.js'; +import { + NAMED_COMPONENT_EXPORTS, + NAMED_COMPONENT_EXPORTS_SET, + JS_EXTENSIONS, +} from './constants.js'; export function validateDestructuredExports( id: Babel.ArrayPattern | Babel.ObjectPattern, - exportsToRemove: string[] + exportsToRemove: readonly string[] ): void { if (id.type === 'ArrayPattern') { for (const element of id.elements) { @@ -175,7 +179,7 @@ export function generateWithProps() { export const removeExports = ( ast: ParseResult, - exportsToRemove: string[] + exportsToRemove: readonly string[] ): void => { const previouslyReferencedIdentifiers = findReferencedIdentifiers(ast); let exportsFiltered = false; @@ -452,5 +456,5 @@ export const transformRoute = (ast: ParseResult): void => { function isNamedComponentExport( name: string ): name is (typeof NAMED_COMPONENT_EXPORTS)[number] { - return (NAMED_COMPONENT_EXPORTS as readonly string[]).includes(name); + return NAMED_COMPONENT_EXPORTS_SET.has(name); } diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts new file mode 100644 index 0000000..2f67a9a --- /dev/null +++ b/src/route-artifacts.ts @@ -0,0 +1,157 @@ +import { + CLIENT_ROUTE_EXPORTS_SET, + SERVER_ONLY_ROUTE_EXPORTS_SET, +} from './constants.js'; +import { + getBundlerRouteAnalysis, + getExportNames, + transformToEsm, +} from './export-utils.js'; +import { + buildEnforceChunkValidity, + getRouteChunkIfEnabled, + getRouteChunkNameFromModuleId, + validateRouteChunks, + type RouteChunkCache, + type RouteChunkConfig, +} from './route-chunks.js'; + +export type RouteClientEntryArtifactOptions = { + code: string; + resourcePath: string; + environmentName?: string; + isBuild: boolean; + routeChunkCache: RouteChunkCache | undefined; + routeChunkConfig: RouteChunkConfig; +}; + +export type RouteClientEntryArtifact = { + code: string; +}; + +export type RouteChunkArtifactOptions = { + code: string; + resource: string; + resourcePath: string; + isBuild: boolean; + routeChunkCache: RouteChunkCache | undefined; + routeChunkConfig: RouteChunkConfig; +}; + +export type RouteChunkArtifact = { + code: string; + map: null; +}; + +const preventEmptyChunkSnippet = (reason: string) => + `Math.random()<0&&console.log(${JSON.stringify(reason)});`; + +export const buildRouteClientEntryCode = ({ + exportNames, + chunkedExports, + isServer, + resourcePath, +}: { + exportNames: readonly string[]; + chunkedExports: readonly string[]; + isServer: boolean; + resourcePath: string; +}): { code: string; reexports: string[] } => { + const chunkedExportSet = new Set(chunkedExports); + const reexports = exportNames.filter(exp => { + if (chunkedExportSet.has(exp)) { + return false; + } + return ( + CLIENT_ROUTE_EXPORTS_SET.has(exp) || + (isServer && SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp)) + ); + }); + const target = `${resourcePath}?react-router-route`; + return { + code: `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`, + reexports, + }; +}; + +/** + * Generate the route entry reexport module from bundler-transformed route + * analysis. Web builds omit server-only exports and split-route chunk exports; + * node entries keep server-only exports and never exclude chunked exports. + */ +export const createRouteClientEntryArtifact = async ({ + code, + resourcePath, + environmentName, + isBuild, + routeChunkCache, + routeChunkConfig, +}: RouteClientEntryArtifactOptions): Promise => { + const analysis = await getBundlerRouteAnalysis(code, resourcePath); + const exportNames = await analysis.getExportNames(); + const isServer = environmentName === 'node'; + const splitRouteModules = routeChunkConfig.splitRouteModules; + const chunkedExports = + !isServer && isBuild && splitRouteModules + ? (await analysis.getRouteChunkInfo(routeChunkCache, routeChunkConfig)) + .chunkedExports + : []; + return { + code: buildRouteClientEntryCode({ + exportNames, + chunkedExports, + isServer, + resourcePath, + }).code, + }; +}; + +/** + * Generate a split route chunk artifact from route source transformed to ESM. + * This mirrors the route:chunk transform behavior, including disabled-split + * empty chunks, invalid chunk-name validation, and enforce-mode main validation. + */ +export const createRouteChunkArtifact = async ({ + code, + resource, + resourcePath, + isBuild, + routeChunkCache, + routeChunkConfig, +}: RouteChunkArtifactOptions): Promise => { + const splitRouteModules = routeChunkConfig.splitRouteModules; + if (!isBuild || !splitRouteModules) { + return { + code: preventEmptyChunkSnippet('Split route modules disabled'), + map: null, + }; + } + + const chunkName = getRouteChunkNameFromModuleId(resource); + if (!chunkName) { + throw new Error(`Invalid route chunk name in "${resource}"`); + } + + const transformed = await transformToEsm(code, resourcePath); + const chunk = await getRouteChunkIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + chunkName, + transformed + ); + + if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) { + const exportNames = await getExportNames(chunk); + validateRouteChunks({ + config: routeChunkConfig, + id: resourcePath, + valid: buildEnforceChunkValidity(exportNames), + }); + } + + return { + code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), + map: null, + }; +}; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index b9813e2..8bf08b4 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -55,6 +55,13 @@ export const routeChunkNames: RouteChunkName[] = [ ...routeChunkExportNames, ]; +const createRouteChunkExportMap = ( + getValue: (exportName: RouteChunkExportName) => boolean +): Record => + Object.fromEntries( + routeChunkExportNames.map(exportName => [exportName, getValue(exportName)]) + ) as Record; + const routeChunkQueryStringPrefix = '?route-chunk='; const routeChunkQueryStrings: Record = { @@ -624,12 +631,9 @@ const cloneVariableExportForKeys = ( const detectRouteChunksFromAnalysis = ( analysis: RouteChunkAnalysis ): RouteChunkInfo => { - const hasRouteChunkByExportName = Object.fromEntries( - routeChunkExportNames.map(exportName => [ - exportName, - analysis.chunkableExports.has(exportName), - ]) - ) as Record; + const hasRouteChunkByExportName = createRouteChunkExportMap(exportName => + analysis.chunkableExports.has(exportName) + ); const chunkedExports = routeChunkExportNames.filter( exportName => hasRouteChunkByExportName[exportName] ); @@ -903,6 +907,20 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; +export const EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME: Record< + RouteChunkExportName, + boolean +> = createRouteChunkExportMap(() => false); + +export const buildEnforceChunkValidity = ( + exportNames: readonly string[] +): Record => { + const exportNameSet = new Set(exportNames); + return createRouteChunkExportMap( + exportName => !exportNameSet.has(exportName) + ); +}; + export const detectRouteChunksIfEnabled: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig, @@ -917,12 +935,7 @@ export const detectRouteChunksIfEnabled: ( const noRouteChunks = (): RouteChunkInfo => ({ chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, - hasRouteChunkByExportName: { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - } as Record, + hasRouteChunkByExportName: { ...EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME }, }); if (!config.splitRouteModules) { diff --git a/src/types.ts b/src/types.ts index aa985fa..81feb60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,19 +36,6 @@ export type PluginOptions = { logPerformance?: boolean; }; -/** - * Arguments passed to transform functions - */ -export type TransformArgs = { - code: string; - resource: string; - resourcePath: string; - context?: string | null; - environment?: { - name: string; - }; -}; - export type RouteManifestItem = Omit & { module: string; clientActionModule?: string; diff --git a/src/virtual-modules.ts b/src/virtual-modules.ts new file mode 100644 index 0000000..9fe3a78 --- /dev/null +++ b/src/virtual-modules.ts @@ -0,0 +1,30 @@ +const VIRTUAL_MODULE_PREFIX = 'virtual/react-router/'; + +export const getVirtualModuleFilePath = (moduleId: string): string => { + if (!moduleId.startsWith(VIRTUAL_MODULE_PREFIX)) { + throw new Error( + `Virtual module id must start with ${JSON.stringify(VIRTUAL_MODULE_PREFIX)}: ${moduleId}` + ); + } + + const relativeId = moduleId.slice(VIRTUAL_MODULE_PREFIX.length); + const segments = relativeId.split('/'); + if ( + !relativeId || + segments.some(segment => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid virtual module id: ${moduleId}`); + } + + return `node_modules/${moduleId}.js`; +}; + +export const mapVirtualModules = ( + modules: Record +): Record => + Object.fromEntries( + Object.entries(modules).map(([moduleId, contents]) => [ + getVirtualModuleFilePath(moduleId), + contents, + ]) + ); diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts index d5fb8ff..83a6e0b 100644 --- a/tests/benchmark-fixture.test.ts +++ b/tests/benchmark-fixture.test.ts @@ -107,6 +107,81 @@ describe('benchmark fixture generator', () => { } }); + it('generates deterministic named stress fixture shapes', async () => { + const { benchmarkFixtureNames, generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + expect(benchmarkFixtureNames).toEqual([ + 'default', + 'export-heavy', + 'reexports', + 'import-fanout', + 'chunk-saturated', + ]); + + const expectations = [ + { + fixture: 'export-heavy', + routeFile: 'app/routes/route-0001.tsx', + snippets: ['unusedExport0001_31', 'export async function clientLoader'], + }, + { + fixture: 'reexports', + routeFile: 'app/routes/route-0001.tsx', + snippets: [ + "export * from '../route-reexports/reexport-all-0001'", + 'app/route-reexports/reexport-0001.ts', + ], + }, + { + fixture: 'import-fanout', + routeFile: 'app/routes/route-0001.tsx', + snippets: ["from '../fanout/fanout-15'", 'fanoutValues'], + }, + { + fixture: 'chunk-saturated', + routeFile: 'app/routes/route-0001.tsx', + snippets: ['export async function clientAction', 'HydrateFallback'], + }, + ]; + + for (const { fixture, routeFile, snippets } of expectations) { + const rootA = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-a-')); + const rootB = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-b-')); + + try { + const result = await generateSyntheticFixture({ + root: rootA, + routeCount: 4, + variant: 'ssr-esm-split', + fixture, + }); + await generateSyntheticFixture({ + root: rootB, + routeCount: 4, + variant: 'ssr-esm-split', + fixture, + }); + + expect(result.fixture).toBe(fixture); + const routeModuleA = readFileSync(join(rootA, routeFile), 'utf8'); + const routeModuleB = readFileSync(join(rootB, routeFile), 'utf8'); + expect(routeModuleA).toBe(routeModuleB); + + for (const snippet of snippets) { + if (snippet.startsWith('app/')) { + expect(existsSync(join(rootA, snippet))).toBe(true); + } else { + expect(routeModuleA).toContain(snippet); + } + } + } finally { + rmSync(rootA, { recursive: true, force: true }); + rmSync(rootB, { recursive: true, force: true }); + } + } + }); + it('accepts equals-form CLI options before benchmark selection', () => { const result = spawnSync( process.execPath, diff --git a/tests/features.test.ts b/tests/features.test.ts index e213b41..0a1cb0b 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1,6 +1,8 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it, rstest } from '@rstest/core'; +import { rspack } from '@rsbuild/core'; import { pluginReactRouter } from '../src'; +import { getVirtualModuleFilePath } from '../src/virtual-modules'; describe('pluginReactRouter', () => { describe('basic configuration', () => { @@ -85,10 +87,55 @@ describe('pluginReactRouter', () => { const plugins = config.tools?.rspack?.plugins || []; const virtualModulePlugin = plugins.find( - (p) => p.constructor.name === 'RspackVirtualModulePlugin' + (p: any) => p.constructor.name === 'VirtualModulesPlugin' ); expect(virtualModulePlugin).toBeDefined(); + + const compiler = { + context: '/virtual/project', + hooks: { + afterEnvironment: { + tap: (_name: string, handler: () => void) => handler(), + }, + }, + } as any; + virtualModulePlugin.apply(compiler); + + const virtualFiles = + rspack.experiments.VirtualModulesPlugin.__internal__take_virtual_files( + compiler + ); + const virtualFilePaths = virtualFiles?.map(file => file.path) || []; + + expect(virtualFilePaths).toContain( + '/virtual/project/node_modules/virtual/react-router/browser-manifest.js' + ); + expect(virtualFilePaths).toContain( + '/virtual/project/node_modules/virtual/react-router/server-build.js' + ); + expect(virtualFilePaths).toContain( + '/virtual/project/node_modules/virtual/react-router/with-props.js' + ); + expect(virtualFilePaths).not.toContain( + '/virtual/project/virtual/react-router/browser-manifest' + ); + }); + + it('should map bare React Router virtual module ids to resolvable files', () => { + expect( + getVirtualModuleFilePath('virtual/react-router/browser-manifest') + ).toBe('node_modules/virtual/react-router/browser-manifest.js'); + expect( + getVirtualModuleFilePath('virtual/react-router/server-build-edge') + ).toBe('node_modules/virtual/react-router/server-build-edge.js'); + + expect(() => + getVirtualModuleFilePath('virtual/react-router/../server-build') + ).toThrow('Invalid virtual module id'); + expect(() => + getVirtualModuleFilePath('virtual/other/server-build') + ).toThrow('Virtual module id must start'); }); }); diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts new file mode 100644 index 0000000..0a56a7a --- /dev/null +++ b/tests/route-artifacts.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from '@rstest/core'; +import { getBundlerRouteAnalysis } from '../src/export-utils'; +import { + createRouteChunkArtifact, + createRouteClientEntryArtifact, +} from '../src/route-artifacts'; +import { + getRouteChunkIfEnabled, + getRouteChunkModuleId, + type RouteChunkCache, + type RouteChunkConfig, + type RouteChunkName, +} from '../src/route-chunks'; + +const routeChunkConfig: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const disabledRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: false, +}; + +const enforceRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: 'enforce', +}; + +const resourcePath = '/app/routes/demo.tsx'; +const routeRequest = `${resourcePath}?react-router-route`; + +const createRouteChunk = async ( + source: string, + chunkName: RouteChunkName, + options: { + config?: RouteChunkConfig; + cache?: RouteChunkCache; + isBuild?: boolean; + } = {} +) => + createRouteChunkArtifact({ + code: source, + resource: getRouteChunkModuleId(resourcePath, chunkName), + resourcePath, + routeChunkConfig: options.config ?? routeChunkConfig, + routeChunkCache: options.cache ?? new Map(), + isBuild: options.isBuild ?? true, + }); + +describe('route artifact helpers', () => { + describe('createRouteClientEntryArtifact', () => { + it('generates web route reexports that filter server-only exports', async () => { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export { meta as meta }; + const meta = () => []; + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + routeChunkCache: new Map(), + }); + + expect(result).toEqual({ + code: `export { clientLoader, default, meta } from ${JSON.stringify( + routeRequest + )};`, + }); + }); + + it('includes server-only route exports for node route entries', async () => { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function loader() { return null; } + export async function action() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'node', + isBuild: true, + routeChunkConfig, + routeChunkCache: new Map(), + }); + + expect(result).toEqual({ + code: `export { action, clientLoader, default, loader } from ${JSON.stringify( + routeRequest + )};`, + }); + }); + + it('excludes split client exports from web build route entries', async () => { + const result = await createRouteClientEntryArtifact({ + code: ` + export const clientAction = async () => {}; + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + routeChunkCache: new Map(), + }); + + expect(result).toEqual({ + code: `export { default } from ${JSON.stringify(routeRequest)};`, + }); + }); + }); + + describe('createRouteChunkArtifact', () => { + it('returns the disabled split-route empty snippet with a null map', async () => { + await expect( + createRouteChunk(`export const clientLoader = async () => {};`, 'clientLoader', { + config: disabledRouteChunkConfig, + isBuild: true, + }) + ).resolves.toEqual({ + code: 'Math.random()<0&&console.log("Split route modules disabled");', + map: null, + }); + }); + + it('rejects invalid route chunk names before generating code', async () => { + await expect( + createRouteChunkArtifact({ + code: `export const clientLoader = async () => {};`, + resource: `${resourcePath}?route-chunk=invalid`, + resourcePath, + routeChunkConfig, + routeChunkCache: new Map(), + isBuild: true, + }) + ).rejects.toThrow(`Invalid route chunk name in "${resourcePath}?route-chunk=invalid"`); + }); + + it('generates the same route chunk code as the existing transformed ESM path', async () => { + const source = ` + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + const cache: RouteChunkCache = new Map(); + const analysis = await getBundlerRouteAnalysis(source, resourcePath); + const expectedCode = await getRouteChunkIfEnabled( + cache, + routeChunkConfig, + resourcePath, + 'clientAction', + analysis.code + ); + + const result = await createRouteChunk(source, 'clientAction', { cache }); + + expect(result).toEqual({ code: expectedCode, map: null }); + }); + + it('validates enforce-mode main chunks against generated chunk exports', async () => { + await expect( + createRouteChunk( + ` + const shared = () => null; + export const clientAction = async () => shared(); + export default function Route() { return shared(); } + `, + 'main', + { + config: enforceRouteChunkConfig, + } + ) + ).rejects.toThrow('Error splitting route module: routes/demo.tsx'); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a860a14..f4cde81 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -91,7 +91,7 @@ rstest.mock('@scripts/test-helper', () => ({ tools: { rspack: { plugins: [ - { constructor: { name: 'RspackVirtualModulePlugin' } }, + { constructor: { name: 'VirtualModulesPlugin' } }, ], }, }, From 4e97c1d891a184c8875b1f14ef9089281c7de21c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:27:28 +0200 Subject: [PATCH 13/30] perf: simplify route artifact helpers --- scripts/bench-client-entry-analysis.mjs | 2 +- scripts/benchmark/fixture.mjs | 8 ++++---- src/manifest.ts | 27 +++++++------------------ src/route-artifacts.ts | 21 +++++++++---------- src/route-chunks.ts | 18 ++++++++++++++--- tests/features.test.ts | 9 ++++++--- tests/route-artifacts.test.ts | 9 +++------ 7 files changed, 46 insertions(+), 48 deletions(-) diff --git a/scripts/bench-client-entry-analysis.mjs b/scripts/bench-client-entry-analysis.mjs index ea32505..579732b 100644 --- a/scripts/bench-client-entry-analysis.mjs +++ b/scripts/bench-client-entry-analysis.mjs @@ -343,7 +343,7 @@ const main = async () => { environment, cacheMode: args.cache, splitRouteModules, - routeChunkCache: args.cache === 'cold' ? new Map() : routeChunkCache, + routeChunkCache: args.cache === 'cold' ? undefined : routeChunkCache, routeChunkConfig, internals, }); diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index e37f9a9..739d5fe 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -10,15 +10,15 @@ const routeExportProfiles = [ 'client-server-imports', ]; -const stressFixtureNames = new Set([ +export const benchmarkFixtureNames = [ 'default', 'export-heavy', 'reexports', 'import-fanout', 'chunk-saturated', -]); +]; -export const benchmarkFixtureNames = [...stressFixtureNames]; +const stressFixtureNames = new Set(benchmarkFixtureNames); export const padRoute = number => String(number).padStart(4, '0'); @@ -334,7 +334,7 @@ export async function generateSyntheticFixture({ }) { if (!stressFixtureNames.has(fixture)) { throw new Error( - `Unknown benchmark fixture "${fixture}". Use ${[...stressFixtureNames].join(', ')}.` + `Unknown benchmark fixture "${fixture}". Use ${benchmarkFixtureNames.join(', ')}.` ); } diff --git a/src/manifest.ts b/src/manifest.ts index 6c06793..8afdcf8 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -6,8 +6,9 @@ import type { Rspack } from '@rsbuild/core'; import { combineURLs, createRouteId } from './plugin-utils.js'; import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js'; import { + buildManifestChunkValidity, + createEmptyRouteChunkByExportName, detectRouteChunksIfEnabled, - EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME, getRouteChunkEntryName, validateRouteChunks, type RouteChunkCache, @@ -183,13 +184,7 @@ export async function getReactRouterManifestForDev( const routeFilePath = resolve(context, route.file); let exports = new Set(); let routeModuleExports: string[] = []; - let hasRouteChunkByExportName: Record< - | 'clientAction' - | 'clientLoader' - | 'clientMiddleware' - | 'HydrateFallback', - boolean - > = { ...EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME }; + let hasRouteChunkByExportName = createEmptyRouteChunkByExportName(); try { const { code, exports: exportNames } = @@ -223,24 +218,16 @@ export async function getReactRouterManifestForDev( const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); - const hasHydrateFallback = exports.has(CLIENT_EXPORTS.HydrateFallback); const hasDefaultExport = exports.has('default'); if (isBuild && enforceSplitRouteModules && routeChunkConfig) { validateRouteChunks({ config: routeChunkConfig, id: routeFilePath, - valid: { - clientAction: - !hasClientAction || hasRouteChunkByExportName.clientAction, - clientLoader: - !hasClientLoader || hasRouteChunkByExportName.clientLoader, - clientMiddleware: - !hasClientMiddleware || - hasRouteChunkByExportName.clientMiddleware, - HydrateFallback: - !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, - }, + valid: buildManifestChunkValidity( + exports, + hasRouteChunkByExportName + ), }); } diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 2f67a9a..02b3dbd 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -9,6 +9,7 @@ import { } from './export-utils.js'; import { buildEnforceChunkValidity, + emptyRouteChunkSnippet, getRouteChunkIfEnabled, getRouteChunkNameFromModuleId, validateRouteChunks, @@ -21,11 +22,11 @@ export type RouteClientEntryArtifactOptions = { resourcePath: string; environmentName?: string; isBuild: boolean; - routeChunkCache: RouteChunkCache | undefined; + routeChunkCache?: RouteChunkCache; routeChunkConfig: RouteChunkConfig; }; -export type RouteClientEntryArtifact = { +type RouteClientEntryArtifact = { code: string; }; @@ -34,18 +35,15 @@ export type RouteChunkArtifactOptions = { resource: string; resourcePath: string; isBuild: boolean; - routeChunkCache: RouteChunkCache | undefined; + routeChunkCache?: RouteChunkCache; routeChunkConfig: RouteChunkConfig; }; -export type RouteChunkArtifact = { +type RouteChunkArtifact = { code: string; map: null; }; -const preventEmptyChunkSnippet = (reason: string) => - `Math.random()<0&&console.log(${JSON.stringify(reason)});`; - export const buildRouteClientEntryCode = ({ exportNames, chunkedExports, @@ -57,9 +55,10 @@ export const buildRouteClientEntryCode = ({ isServer: boolean; resourcePath: string; }): { code: string; reexports: string[] } => { - const chunkedExportSet = new Set(chunkedExports); + const chunkedExportSet = + chunkedExports.length > 0 ? new Set(chunkedExports) : undefined; const reexports = exportNames.filter(exp => { - if (chunkedExportSet.has(exp)) { + if (chunkedExportSet?.has(exp)) { return false; } return ( @@ -122,7 +121,7 @@ export const createRouteChunkArtifact = async ({ const splitRouteModules = routeChunkConfig.splitRouteModules; if (!isBuild || !splitRouteModules) { return { - code: preventEmptyChunkSnippet('Split route modules disabled'), + code: emptyRouteChunkSnippet('Split route modules disabled'), map: null, }; } @@ -151,7 +150,7 @@ export const createRouteChunkArtifact = async ({ } return { - code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), + code: chunk ?? emptyRouteChunkSnippet(`No ${chunkName} chunk`), map: null, }; }; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 8bf08b4..9ef85d6 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -62,6 +62,9 @@ const createRouteChunkExportMap = ( routeChunkExportNames.map(exportName => [exportName, getValue(exportName)]) ) as Record; +export const emptyRouteChunkSnippet = (reason: string): string => + `Math.random()<0&&console.log(${JSON.stringify(reason)});`; + const routeChunkQueryStringPrefix = '?route-chunk='; const routeChunkQueryStrings: Record = { @@ -907,10 +910,10 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; -export const EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME: Record< +export const createEmptyRouteChunkByExportName = (): Record< RouteChunkExportName, boolean -> = createRouteChunkExportMap(() => false); +> => createRouteChunkExportMap(() => false); export const buildEnforceChunkValidity = ( exportNames: readonly string[] @@ -921,6 +924,15 @@ export const buildEnforceChunkValidity = ( ); }; +export const buildManifestChunkValidity = ( + exportNames: ReadonlySet, + hasRouteChunkByExportName: Readonly> +): Record => + createRouteChunkExportMap( + exportName => + !exportNames.has(exportName) || hasRouteChunkByExportName[exportName] + ); + export const detectRouteChunksIfEnabled: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig, @@ -935,7 +947,7 @@ export const detectRouteChunksIfEnabled: ( const noRouteChunks = (): RouteChunkInfo => ({ chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, - hasRouteChunkByExportName: { ...EMPTY_ROUTE_CHUNK_BY_EXPORT_NAME }, + hasRouteChunkByExportName: createEmptyRouteChunkByExportName(), }); if (!config.splitRouteModules) { diff --git a/tests/features.test.ts b/tests/features.test.ts index 0a1cb0b..a494069 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1,6 +1,7 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it, rstest } from '@rstest/core'; import { rspack } from '@rsbuild/core'; +import path from 'node:path'; import { pluginReactRouter } from '../src'; import { getVirtualModuleFilePath } from '../src/virtual-modules'; @@ -107,15 +108,17 @@ describe('pluginReactRouter', () => { compiler ); const virtualFilePaths = virtualFiles?.map(file => file.path) || []; + const virtualModulePath = (id: string) => + path.join(compiler.context, getVirtualModuleFilePath(id)); expect(virtualFilePaths).toContain( - '/virtual/project/node_modules/virtual/react-router/browser-manifest.js' + virtualModulePath('virtual/react-router/browser-manifest') ); expect(virtualFilePaths).toContain( - '/virtual/project/node_modules/virtual/react-router/server-build.js' + virtualModulePath('virtual/react-router/server-build') ); expect(virtualFilePaths).toContain( - '/virtual/project/node_modules/virtual/react-router/with-props.js' + virtualModulePath('virtual/react-router/with-props') ); expect(virtualFilePaths).not.toContain( '/virtual/project/virtual/react-router/browser-manifest' diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index 0a56a7a..16628c2 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -5,6 +5,7 @@ import { createRouteClientEntryArtifact, } from '../src/route-artifacts'; import { + emptyRouteChunkSnippet, getRouteChunkIfEnabled, getRouteChunkModuleId, type RouteChunkCache, @@ -45,7 +46,7 @@ const createRouteChunk = async ( resource: getRouteChunkModuleId(resourcePath, chunkName), resourcePath, routeChunkConfig: options.config ?? routeChunkConfig, - routeChunkCache: options.cache ?? new Map(), + routeChunkCache: options.cache, isBuild: options.isBuild ?? true, }); @@ -64,7 +65,6 @@ describe('route artifact helpers', () => { environmentName: 'web', isBuild: false, routeChunkConfig: disabledRouteChunkConfig, - routeChunkCache: new Map(), }); expect(result).toEqual({ @@ -86,7 +86,6 @@ describe('route artifact helpers', () => { environmentName: 'node', isBuild: true, routeChunkConfig, - routeChunkCache: new Map(), }); expect(result).toEqual({ @@ -107,7 +106,6 @@ describe('route artifact helpers', () => { environmentName: 'web', isBuild: true, routeChunkConfig, - routeChunkCache: new Map(), }); expect(result).toEqual({ @@ -124,7 +122,7 @@ describe('route artifact helpers', () => { isBuild: true, }) ).resolves.toEqual({ - code: 'Math.random()<0&&console.log("Split route modules disabled");', + code: emptyRouteChunkSnippet('Split route modules disabled'), map: null, }); }); @@ -136,7 +134,6 @@ describe('route artifact helpers', () => { resource: `${resourcePath}?route-chunk=invalid`, resourcePath, routeChunkConfig, - routeChunkCache: new Map(), isBuild: true, }) ).rejects.toThrow(`Invalid route chunk name in "${resourcePath}?route-chunk=invalid"`); From 338e32c8fef43a820056a5135bdbdbd7172dc31e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:12:43 +0200 Subject: [PATCH 14/30] perf: clean up route artifact helpers --- src/index.ts | 83 ++++++++++++++++++++---------------------- src/manifest.ts | 1 - src/route-artifacts.ts | 10 ----- src/route-chunks.ts | 5 --- 4 files changed, 39 insertions(+), 60 deletions(-) diff --git a/src/index.ts b/src/index.ts index fba53d1..f8e7030 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1659,52 +1659,47 @@ export const pluginReactRouter = ( 'route:module', args.resource, async () => { - let code: string; - try { - const analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - code = analysis.code; - - // Match React Router Vite behavior: - // In SPA mode, server-only route exports are invalid (except root `loader`), - // and `HydrateFallback` is only allowed on the root route. - if (args.environment.name === 'web' && !ssr && isSpaMode) { - const resolvedExportNames = await analysis.getExportNames(); - const isRootRoute = args.resourcePath === rootRoutePath; - - const invalidServerOnly = resolvedExportNames.filter(exp => { - if (isRootRoute && exp === 'loader') return false; - return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); - }); - - if (invalidServerOnly.length > 0) { - const list = invalidServerOnly - .map(e => `\`${e}\``) - .join(', '); - throw new Error( - `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } + const analysis = await getBundlerRouteAnalysis( + args.code, + args.resourcePath + ); + let code = analysis.code; + + // Match React Router Vite behavior: + // In SPA mode, server-only route exports are invalid (except root `loader`), + // and `HydrateFallback` is only allowed on the root route. + if (args.environment.name === 'web' && !ssr && isSpaMode) { + const resolvedExportNames = await analysis.getExportNames(); + const isRootRoute = args.resourcePath === rootRoutePath; + const relativePath = relative(process.cwd(), args.resourcePath); + + const invalidServerOnly = resolvedExportNames.filter(exp => { + if (isRootRoute && exp === 'loader') return false; + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); + }); - if ( - !isRootRoute && - resolvedExportNames.includes('HydrateFallback') - ) { - throw new Error( - `SPA Mode: Invalid \`HydrateFallback\` export found in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`. ` + - `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } + if (invalidServerOnly.length > 0) { + const list = invalidServerOnly + .map(e => `\`${e}\``) + .join(', '); + throw new Error( + `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + + `\`${relativePath}\`: ${list}. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + + if ( + !isRootRoute && + resolvedExportNames.includes('HydrateFallback') + ) { + throw new Error( + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${relativePath}\`. ` + + `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); } - } catch (error) { - console.error(args.resourcePath); - throw error; } const defaultExportMatch = code.match( diff --git a/src/manifest.ts b/src/manifest.ts index 8afdcf8..bb75d0e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -180,7 +180,6 @@ export async function getReactRouterManifestForDev( const assets = getAssetsForChunk(routeEntryName); const jsAssets = assets.filter(asset => asset.endsWith('.js')) || []; let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; - // Read and analyze the route file to check for exports const routeFilePath = resolve(context, route.file); let exports = new Set(); let routeModuleExports: string[] = []; diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 02b3dbd..442a82f 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -73,11 +73,6 @@ export const buildRouteClientEntryCode = ({ }; }; -/** - * Generate the route entry reexport module from bundler-transformed route - * analysis. Web builds omit server-only exports and split-route chunk exports; - * node entries keep server-only exports and never exclude chunked exports. - */ export const createRouteClientEntryArtifact = async ({ code, resourcePath, @@ -105,11 +100,6 @@ export const createRouteClientEntryArtifact = async ({ }; }; -/** - * Generate a split route chunk artifact from route source transformed to ESM. - * This mirrors the route:chunk transform behavior, including disabled-split - * empty chunks, invalid chunk-name validation, and enforce-mode main validation. - */ export const createRouteChunkArtifact = async ({ code, resource, diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 9ef85d6..120a15f 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -591,7 +591,6 @@ const cloneImportForNames = ( node: t.ImportDeclaration, importedIdentifierNames: ReadonlySet ) => { - // Shallow clone is safe here: only the top-level specifiers array is reassigned. const clonedNode = t.cloneNode(node, false); clonedNode.specifiers = node.specifiers.filter(specifier => importedIdentifierNames.has(specifier.local.name) @@ -612,7 +611,6 @@ const cloneVariableExportForKeys = ( t.isVariableDeclaration(node.declaration), 'Expected export declaration to contain variable declarations' ); - // Shallow clones are safe here: only declaration/declarations array references are reassigned. const clonedNode = t.cloneNode(node, false); const clonedDeclaration = t.cloneNode(node.declaration, false); clonedDeclaration.declarations = node.declaration.declarations.filter( @@ -699,7 +697,6 @@ const getChunkedExportFromAnalysis = ( if (node.specifiers.length === 0) { return null; } - // Shallow clone is safe here: only the top-level specifiers array is reassigned. const clonedNode = t.cloneNode(node, false); clonedNode.specifiers = node.specifiers.filter( specifier => getExportedName(specifier.exported) === exportName @@ -755,7 +752,6 @@ const omitChunkedExportsFromAnalysis = ( if (node.specifiers.length === 0) { return t.cloneNode(node, false); } - // Shallow clone is safe here: only the top-level specifiers array is reassigned. const clonedNode = t.cloneNode(node, false); clonedNode.specifiers = node.specifiers.filter(specifier => { const importedName = specifier.local.name; @@ -819,7 +815,6 @@ const omitChunkedExportsFromAnalysis = ( if (node.specifiers.length === 0) { return t.cloneNode(node, false); } - // Shallow clone is safe here: only the top-level specifiers array is reassigned. const clonedNode = t.cloneNode(node, false); clonedNode.specifiers = node.specifiers.filter(specifier => { const exportedName = getExportedName(specifier.exported); From deb46744231a00a6c9112ab8d6674a235ef282b1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:22:53 +0200 Subject: [PATCH 15/30] perf: simplify yuku route analysis cleanup --- config/rslib.config.ts | 2 -- src/export-utils.ts | 16 +++------------- src/index.ts | 2 -- src/plugin-utils.ts | 7 +------ src/route-artifacts.ts | 6 ++++++ src/route-chunks.ts | 2 +- tests/export-utils.test.ts | 3 +-- tests/route-chunks-cache.test.ts | 3 ++- 8 files changed, 14 insertions(+), 27 deletions(-) diff --git a/config/rslib.config.ts b/config/rslib.config.ts index 44553e2..9b76aba 100644 --- a/config/rslib.config.ts +++ b/config/rslib.config.ts @@ -13,7 +13,6 @@ export const nodeMinifyConfig: Minify = { css: false, jsOptions: { minimizerOptions: { - // preserve variable name and disable minify for easier debugging mangle: false, minify: false, compress: true, @@ -21,7 +20,6 @@ export const nodeMinifyConfig: Minify = { }, }; -// Clean tsc cache to ensure the dts files can be generated correctly export const pluginCleanTscCache: RsbuildPlugin = { name: 'plugin-clean-tsc-cache', setup(api) { diff --git a/src/export-utils.ts b/src/export-utils.ts index 10b09e6..e796496 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -95,7 +95,7 @@ const parseProgram = (code: string, resourcePath?: string) => { if (errors.length > 0) { throw new Error(errors.map(error => error.message).join('\n')); } - return result.program as AnyNode; + return result.program; }; const getIdentifierNamesFromPattern = ( @@ -232,18 +232,8 @@ export const transformToEsm = async ( let transformed: Promise; transformed = cachePromiseOnReject( (async () => { - const result = parse(code, { - sourceType: 'module', - lang: langFromPath(resourcePath), - preserveParens: true, - }); - const errors = result.diagnostics.filter( - diagnostic => diagnostic.severity === 'error' - ); - if (errors.length > 0) { - throw new Error(errors.map(error => error.message).join('\n')); - } - const stripped = strip(result.program, { comments: 'some' }); + const program = parseProgram(code, resourcePath); + const stripped = strip(program, { comments: 'some' }); if (stripped.errors.length > 0) { throw new Error(stripped.errors.map(error => error.message).join('\n')); } diff --git a/src/index.ts b/src/index.ts index f8e7030..6eb58ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1311,7 +1311,6 @@ export const pluginReactRouter = ( } ); - // Add manifest transformations api.transform( { test: /virtual\/react-router\/(browser|server)-manifest/, @@ -1322,7 +1321,6 @@ export const pluginReactRouter = ( 'manifest:transform', args.resource, async () => { - // For browser manifest, return a placeholder that will be modified by the plugin if (args.environment.name === 'web') { return { code: `window.__reactRouterManifest = "PLACEHOLDER";`, diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 2fb8521..2e2e6df 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -109,11 +109,6 @@ export function createRouteId(file: string): string { return normalize(stripFileExtension(file)); } -/** - * Find a file with any of the supported JavaScript extensions - * @param basePath - The base path without extension - * @returns The file path with extension if found, or a default path - */ export function findEntryFile(basePath: string): string { for (const ext of JS_EXTENSIONS) { const filePath = `${basePath}${ext}`; @@ -121,7 +116,7 @@ export function findEntryFile(basePath: string): string { return filePath; } } - return `${basePath}.tsx`; // Default to .tsx if no file exists + return `${basePath}.tsx`; } export function generateWithProps() { diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 28cf23d..8ede2ff 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -122,6 +122,12 @@ export const createRouteChunkArtifact = async ({ if (!chunkName) { throw new Error(`Invalid route chunk name in "${resource}"`); } + if (chunkName !== 'main' && !code.includes(chunkName)) { + return { + code: emptyRouteChunkSnippet(`No ${chunkName} chunk`), + map: null, + }; + } const transformed = await transformToEsm(code, resourcePath); const chunk = await getRouteChunkIfEnabled( diff --git a/src/route-chunks.ts b/src/route-chunks.ts index a2e7772..e6d586d 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -23,7 +23,7 @@ export type RouteChunkConfig = { rootRouteFile: string; }; -export type RouteChunkCacheEntry = { +type RouteChunkCacheEntry = { value: T; version: string; }; diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index 11064a9..c6bc673 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from '@rstest/core'; -import { getBundlerRouteAnalysis } from '../src/export-utils'; import { parse } from '../src/babel'; -import { transformToEsm } from '../src/export-utils'; +import { getBundlerRouteAnalysis, transformToEsm } from '../src/export-utils'; const routeChunkConfig = { splitRouteModules: true as const, diff --git a/tests/route-chunks-cache.test.ts b/tests/route-chunks-cache.test.ts index e4255b8..fd00f66 100644 --- a/tests/route-chunks-cache.test.ts +++ b/tests/route-chunks-cache.test.ts @@ -3,6 +3,7 @@ import { detectRouteChunksIfEnabled, getRouteChunkIfEnabled, routeChunkNames, + type RouteChunkCache, type RouteChunkConfig, type RouteChunkInfo, type RouteChunkName, @@ -36,7 +37,7 @@ const nonChunkableCode = ` `; const collectRouteChunkOracle = async ( - cache: Map | undefined, + cache: RouteChunkCache | undefined, code = chunkableCode ) => { const info = await detectRouteChunksIfEnabled(cache, config, routeId, code); From fd8849cf5305d06d19f6db40147d89c4f759cdb9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:35:56 +0200 Subject: [PATCH 16/30] perf: clear build request stream timeouts --- scripts/benchmark/fixture.mjs | 1 + src/index.ts | 260 +++++++++++++++++++-------------- src/templates/entry.server.tsx | 11 +- src/types.ts | 11 ++ tests/index.test.ts | 21 +++ 5 files changed, 191 insertions(+), 113 deletions(-) diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 739d5fe..39e0cac 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -229,6 +229,7 @@ const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { ' plugins: [', ' pluginReactRouter({', ...(ssr ? [` serverOutput: 'module',`] : []), + ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '1' ? { lazyCompilation: true } : {}),`, ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, ' }),', ' ],', diff --git a/src/index.ts b/src/index.ts index 6eb58ce..c898ede 100644 --- a/src/index.ts +++ b/src/index.ts @@ -538,6 +538,24 @@ export const pluginReactRouter = ( const normalizePrerenderMatchPath = (path: string) => `/${path}/`.replace(/^\/\/+/, '/'); + const withBuildRequest = async ( + input: string | URL, + init: RequestInit | undefined, + handle: (request: Request) => Promise + ): Promise => { + const controller = new AbortController(); + try { + return await handle( + new Request(input, { + ...init, + signal: controller.signal, + }) + ); + } finally { + controller.abort(); + } + }; + const prerenderData = async ( handler: (request: Request) => Promise, prerenderPath: string, @@ -567,28 +585,29 @@ export const pluginReactRouter = ( if (onlyRoutes?.length) { url.searchParams.set('_routes', onlyRoutes.join(',')); } - const request = new Request(url, requestInit); - const response = await handler(request); - const data = await response.text(); + return withBuildRequest(url, requestInit, async request => { + const response = await handler(request); + const data = await response.text(); - if (response.status !== 200 && response.status !== 202) { - throw new Error( - `Prerender (data): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` path.\n` + - `${normalizedPath}` - ); - } + if (response.status !== 200 && response.status !== 202) { + throw new Error( + `Prerender (data): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` path.\n` + + `${normalizedPath}` + ); + } - const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, data); - api.logger.info( - `Prerender (data): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` - ); - return data; + const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, data); + api.logger.info( + `Prerender (data): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + return data; + }); }; const prerenderRoute = async ( @@ -601,17 +620,17 @@ export const pluginReactRouter = ( /\/\/+/g, '/' ); - const request = new Request( + await withBuildRequest( `http://localhost${normalizedPath}`, - requestInit - ); - const response = await handler(request); - let html = await response.text(); - - if (redirectStatusCodes.has(response.status)) { - const location = response.headers.get('Location'); - const delay = response.status === 302 ? 2 : 0; - html = ` + requestInit, + async request => { + const response = await handler(request); + let html = await response.text(); + + if (redirectStatusCodes.has(response.status)) { + const location = response.headers.get('Location'); + const delay = response.status === 302 ? 2 : 0; + html = ` Redirecting to: ${location} @@ -623,26 +642,28 @@ export const pluginReactRouter = ( `; - } else if (response.status !== 200) { - throw new Error( - `Prerender (html): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + - html - ); - } + } else if (response.status !== 200) { + throw new Error( + `Prerender (html): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + + html + ); + } - const outputPath = resolve( - clientBuildDir, - ...normalizedPath.split('/'), - 'index.html' - ); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, html); - api.logger.info( - `Prerender (html): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/'), + 'index.html' + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, html); + api.logger.info( + `Prerender (html): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + } ); }; @@ -655,29 +676,34 @@ export const pluginReactRouter = ( const normalizedPath = `${basename}${prerenderPath}/` .replace(/\/\/+/g, '/') .replace(/\/$/g, ''); - const request = new Request( + await withBuildRequest( `http://localhost${normalizedPath}`, - requestInit - ); - const response = await handler(request); - const content = Buffer.from(await response.arrayBuffer()); + requestInit, + async request => { + const response = await handler(request); + const content = Buffer.from(await response.arrayBuffer()); - if (response.status !== 200) { - throw new Error( - `Prerender (resource): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + - content.toString('utf8') - ); - } + if (response.status !== 200) { + throw new Error( + `Prerender (resource): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + + content.toString('utf8') + ); + } - const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, content); - api.logger.info( - `Prerender (resource): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/') + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content); + api.logger.info( + `Prerender (resource): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + } ); }; @@ -686,51 +712,56 @@ export const pluginReactRouter = ( build: any, clientBuildDir: string ): Promise => { - const request = new Request(`http://localhost${basename}`, { - headers: { - 'X-React-Router-SPA-Mode': 'yes', + await withBuildRequest( + `http://localhost${basename}`, + { + headers: { + 'X-React-Router-SPA-Mode': 'yes', + }, }, - }); - const response = await handler(request); - const html = await response.text(); - const isPrerenderSpaFallback = build.prerender?.includes('/'); - const filename = isPrerenderSpaFallback - ? '__spa-fallback.html' - : 'index.html'; - - if (response.status !== 200) { - if (isPrerenderSpaFallback) { - throw new Error( - `Prerender: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + - html - ); - } - throw new Error( - `SPA Mode: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + - html - ); - } + async request => { + const response = await handler(request); + const html = await response.text(); + const isPrerenderSpaFallback = build.prerender?.includes('/'); + const filename = isPrerenderSpaFallback + ? '__spa-fallback.html' + : 'index.html'; + + if (response.status !== 200) { + if (isPrerenderSpaFallback) { + throw new Error( + `Prerender: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + + html + ); + } + throw new Error( + `SPA Mode: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + + html + ); + } - if ( - !html.includes('window.__reactRouterContext =') || - !html.includes('window.__reactRouterRouteModules =') - ) { - throw new Error( - 'SPA Mode: Did you forget to include `` in your root route? ' + - 'Your pre-rendered HTML cannot hydrate without ``.' - ); - } + if ( + !html.includes('window.__reactRouterContext =') || + !html.includes('window.__reactRouterRouteModules =') + ) { + throw new Error( + 'SPA Mode: Did you forget to include `` in your root route? ' + + 'Your pre-rendered HTML cannot hydrate without ``.' + ); + } - const outputPath = resolve(clientBuildDir, filename); - await writeFile(outputPath, html); - const prettyPath = relative(process.cwd(), outputPath); - if (build.prerender?.length) { - api.logger.info(`Prerender (html): SPA Fallback -> ${prettyPath}`); - } else { - api.logger.info(`SPA Mode: Generated ${prettyPath}`); - } + const outputPath = resolve(clientBuildDir, filename); + await writeFile(outputPath, html); + const prettyPath = relative(process.cwd(), outputPath); + if (build.prerender?.length) { + api.logger.info(`Prerender (html): SPA Fallback -> ${prettyPath}`); + } else { + api.logger.info(`SPA Mode: Generated ${prettyPath}`); + } + } + ); }; const validateSsrFalsePrerenderExports = async ( @@ -814,7 +845,6 @@ export const pluginReactRouter = ( } }; - // Handle SPA mode and prerendering after build api.onAfterBuild(async ({ environments }) => { const webEnv = environments.web; if (!webEnv) { @@ -1106,12 +1136,18 @@ export const pluginReactRouter = ( `virtual/react-router/server-build-${bundleId}`; } + const lazyCompilation = + pluginOptions.lazyCompilation === undefined + ? {} + : { lazyCompilation: pluginOptions.lazyCompilation }; + return mergeRsbuildConfig(config, { output: { assetPrefix: config.output?.assetPrefix || '/', }, dev: { writeToDisk: true, + ...lazyCompilation, // Only add SSR middleware if SSR is enabled and not using a custom server // In SPA mode (ssr: false), we just serve static files from the client build setupMiddlewares: diff --git a/src/templates/entry.server.tsx b/src/templates/entry.server.tsx index c0e202c..aecef83 100644 --- a/src/templates/entry.server.tsx +++ b/src/templates/entry.server.tsx @@ -30,11 +30,17 @@ export default function handleRequest( (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; + let abortDelay: ReturnType | undefined; + let ready = false; const { pipe, abort } = renderToPipeableStream( , { [readyOption]() { + ready = true; + if (readyOption === 'onAllReady' && abortDelay) { + clearTimeout(abortDelay); + } shellRendered = true; const body = new PassThrough(); const stream = createReadableStreamFromReadable(body); @@ -62,6 +68,9 @@ export default function handleRequest( } ); - setTimeout(abort, ABORT_DELAY); + abortDelay = setTimeout(abort, ABORT_DELAY); + if (readyOption === 'onAllReady' && ready) { + clearTimeout(abortDelay); + } }); } diff --git a/src/types.ts b/src/types.ts index 81feb60..0d8d31a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { RsbuildConfig } from '@rsbuild/core'; + export type Route = { id: string; parentId?: string; @@ -28,6 +30,15 @@ export type PluginOptions = { */ federation?: boolean; + /** + * Opt in to Rsbuild's dev-only lazy compilation behavior. + * + * This forwards to `dev.lazyCompilation` and does not affect production + * builds. + * @default undefined + */ + lazyCompilation?: NonNullable['lazyCompilation']; + /** * Emit structured React Router plugin timing logs after each compiler * environment finishes. diff --git a/tests/index.test.ts b/tests/index.test.ts index 47c3e4b..b00f193 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -31,6 +31,27 @@ describe('pluginReactRouter', () => { expect(nodeConfig.output.module).toBe(false); }); + it('should forward lazy compilation when explicitly configured', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([ + pluginReactRouter({ + lazyCompilation: { + entries: true, + imports: true, + }, + }), + ]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toEqual({ + entries: true, + imports: true, + }); + }); + it('should configure web environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, From dcdd5142950212e4c5c486e5bee556db719dd988 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:02:58 +0200 Subject: [PATCH 17/30] chore: add performance changeset Document the performance optimization and benchmark tooling updates for release. --- .changeset/bright-routes-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-routes-run.md diff --git a/.changeset/bright-routes-run.md b/.changeset/bright-routes-run.md new file mode 100644 index 0000000..3c52877 --- /dev/null +++ b/.changeset/bright-routes-run.md @@ -0,0 +1,5 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Improve route analysis and route chunking performance for larger applications, with benchmark tooling to track build overhead. From f28c70be5b713a705c6a31482b32420a4463201a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:14:11 +0200 Subject: [PATCH 18/30] perf: reduce route analysis and chunk overhead (#42) Co-authored-by: Matthew Davis --- .changeset/fast-routes-dance.md | 5 + scripts/bench-client-entry-analysis.mjs | 2 +- src/export-utils.ts | 98 +++++---- src/index.ts | 48 ++--- src/manifest.ts | 19 +- src/modify-browser-manifest.ts | 5 +- src/plugin-utils.ts | 271 +++++++++++++++++++----- src/route-artifacts.ts | 3 +- src/route-chunks.ts | 117 +++++----- tests/export-utils.test.ts | 83 +++++++- tests/remove-exports.test.ts | 207 ++++++++++++++++++ 11 files changed, 657 insertions(+), 201 deletions(-) create mode 100644 .changeset/fast-routes-dance.md diff --git a/.changeset/fast-routes-dance.md b/.changeset/fast-routes-dance.md new file mode 100644 index 0000000..6555d81 --- /dev/null +++ b/.changeset/fast-routes-dance.md @@ -0,0 +1,5 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Reduce route analysis and route chunking overhead by reusing transformed export metadata and cached route chunk analysis. diff --git a/scripts/bench-client-entry-analysis.mjs b/scripts/bench-client-entry-analysis.mjs index 579732b..e3bd66a 100644 --- a/scripts/bench-client-entry-analysis.mjs +++ b/scripts/bench-client-entry-analysis.mjs @@ -172,7 +172,7 @@ const runRoute = async ({ benchmarkSource, benchmarkResourcePath ); - const exportNames = await analysis.getExportNames(); + const exportNames = analysis.exportNames; return { analysis, exportNames }; }); diff --git a/src/export-utils.ts b/src/export-utils.ts index e796496..5865640 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -10,12 +10,19 @@ import { type TransformCacheEntry = { source: string; - transformed: Promise; + transformed: Promise; }; -export type BundlerRouteAnalysis = { - code: string; - getExportNames: () => Promise; +type ExportInfo = { + readonly exportNames: readonly string[]; + readonly exportAllModules: readonly string[]; +}; + +type TransformedModule = ExportInfo & { + readonly code: string; +}; + +export type BundlerRouteAnalysis = TransformedModule & { getRouteChunkInfo: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig @@ -28,9 +35,9 @@ type BundlerRouteAnalysisCacheEntry = { }; type RouteModuleAnalysis = { - code: string; - exports: string[]; - exportAllModules: string[]; + readonly code: string; + readonly exports: readonly string[]; + readonly exportAllModules: readonly string[]; }; type RouteModuleAnalysisCacheEntry = { @@ -40,10 +47,7 @@ type RouteModuleAnalysisCacheEntry = { }; const transformCache = new Map(); -const exportInfoCache = new Map< - string, - Promise<{ exportNames: string[]; exportAllModules: string[] }> ->(); +const exportInfoCache = new Map>(); const bundlerRouteAnalysisCache = new Map< string, BundlerRouteAnalysisCacheEntry @@ -147,11 +151,18 @@ const getExportedName = (node: AnyNode): string | null => { }; const isTypeOnlyExport = (node: AnyNode): boolean => - node.exportKind === 'type' || node.type === 'TSExportAssignment'; + node.exportKind === 'type' || + node.type === 'TSExportAssignment' || + (node.type === 'ExportDefaultDeclaration' && + node.declaration?.type === 'TSInterfaceDeclaration'); const collectExportNames = (program: AnyNode): string[] => { const exportNames = new Set(); for (const statement of program.body ?? []) { + if (isTypeOnlyExport(statement)) { + continue; + } + if (statement.type === 'ExportAllDeclaration') { const exported = getExportedName(statement.exported); if (exported) { @@ -168,10 +179,6 @@ const collectExportNames = (program: AnyNode): string[] => { if (statement.type !== 'ExportNamedDeclaration') { continue; } - if (isTypeOnlyExport(statement)) { - continue; - } - const declaration = statement.declaration; if (declaration) { if (declaration.type === 'VariableDeclaration') { @@ -206,7 +213,10 @@ const collectExportNames = (program: AnyNode): string[] => { const collectExportAllModules = (program: AnyNode): string[] => { const modules: string[] = []; for (const statement of program.body ?? []) { - if (statement.type !== 'ExportAllDeclaration') { + if ( + statement.type !== 'ExportAllDeclaration' || + isTypeOnlyExport(statement) + ) { continue; } if (statement.exported) { @@ -220,16 +230,16 @@ const collectExportAllModules = (program: AnyNode): string[] => { return modules; }; -export const transformToEsm = async ( +const getTransformedModule = async ( code: string, resourcePath: string -): Promise => { +): Promise => { const cached = transformCache.get(resourcePath); if (cached?.source === code) { return cached.transformed; } - let transformed: Promise; + let transformed: Promise; transformed = cachePromiseOnReject( (async () => { const program = parseProgram(code, resourcePath); @@ -237,7 +247,11 @@ export const transformToEsm = async ( if (stripped.errors.length > 0) { throw new Error(stripped.errors.map(error => error.message).join('\n')); } - return stripped.code; + return { + code: stripped.code, + exportNames: collectExportNames(program), + exportAllModules: collectExportAllModules(program), + }; })(), () => { if (transformCache.get(resourcePath)?.transformed === transformed) { @@ -253,7 +267,14 @@ export const transformToEsm = async ( return transformed; }; -export const getExportNames = async (code: string): Promise => { +export const transformToEsm = async ( + code: string, + resourcePath: string +): Promise => (await getTransformedModule(code, resourcePath)).code; + +export const getExportNames = async ( + code: string +): Promise => { return (await getExportNamesAndExportAll(code)).exportNames; }; @@ -267,16 +288,11 @@ export const getBundlerRouteAnalysis = async ( } const analysis = (async () => { - const code = await transformToEsm(source, resourcePath); - let exportNames: Promise | undefined; + const transformed = await getTransformedModule(source, resourcePath); const routeChunkInfoCache = new Map>(); return { - code, - getExportNames: () => { - exportNames ??= getExportNames(code); - return exportNames; - }, + ...transformed, getRouteChunkInfo: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig @@ -289,7 +305,12 @@ export const getBundlerRouteAnalysis = async ( let routeChunkInfo: Promise; routeChunkInfo = cachePromiseOnReject( - detectRouteChunksIfEnabled(cache, config, resourcePath, code), + detectRouteChunksIfEnabled( + cache, + config, + resourcePath, + transformed.code + ), () => { if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { routeChunkInfoCache.delete(cacheKey); @@ -321,7 +342,7 @@ export const getBundlerRouteAnalysis = async ( export const getExportNamesAndExportAll = async ( code: string -): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { +): Promise => { const cached = exportInfoCache.get(code); if (cached) { return cached; @@ -335,10 +356,7 @@ export const getExportNamesAndExportAll = async ( }; })(); - let trackedExportInfo: Promise<{ - exportNames: string[]; - exportAllModules: string[]; - }>; + let trackedExportInfo: Promise; trackedExportInfo = cachePromiseOnReject(exportInfo, () => { if (exportInfoCache.get(code) === trackedExportInfo) { exportInfoCache.delete(code); @@ -360,10 +378,12 @@ export const getRouteModuleAnalysis = async ( const analysis = (async () => { const source = await readFile(resourcePath, 'utf8'); - const code = await transformToEsm(source, resourcePath); - const { exportNames, exportAllModules } = - await getExportNamesAndExportAll(code); - return { code, exports: exportNames, exportAllModules }; + const transformed = await getTransformedModule(source, resourcePath); + return { + code: transformed.code, + exports: transformed.exportNames, + exportAllModules: transformed.exportAllModules, + }; })(); let trackedAnalysis: Promise; diff --git a/src/index.ts b/src/index.ts index c898ede..53cb597 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,14 +46,14 @@ import { getReactRouterManifestForDev, getRouteManifestModuleExports, configRoutesToRouteManifest, + REACT_ROUTER_MANIFEST_STATS_OPTIONS, + type ReactRouterManifestStats, } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { getBundlerRouteAnalysis, - getExportNamesAndExportAll, getRouteModuleAnalysis, - transformToEsm, } from './export-utils.js'; import { getRouteChunkEntryName, @@ -415,6 +415,7 @@ export const pluginReactRouter = ( type ReactRouterManifest = Awaited< ReturnType >; + let latestBrowserManifest: ReactRouterManifest | null = null; let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; @@ -468,10 +469,10 @@ export const pluginReactRouter = ( const outputClientPath = resolve(buildDirectory, 'client'); const assetsBuildDirectory = relative(process.cwd(), outputClientPath); - let clientStats: Rspack.StatsCompilation | undefined; + let clientStats: ReactRouterManifestStats | undefined; api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { - clientStats = stats?.toJson(); + clientStats = stats?.toJson(REACT_ROUTER_MANIFEST_STATS_OPTIONS); } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -895,15 +896,17 @@ export const pluginReactRouter = ( const requestHandler = createRequestHandler(build, 'production'); if (isPrerenderEnabled) { - const manifest = await getReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - ); if (!ssr) { + const manifest = + latestBrowserManifest ?? + (await getReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + )); await validateSsrFalsePrerenderExports(manifest, prerenderPaths); } @@ -1021,11 +1024,6 @@ export const pluginReactRouter = ( } if (buildEnd) { - const buildManifest = await getBuildManifest({ - reactRouterConfig: resolvedConfigWithRoutes, - routes, - rootDirectory: process.cwd(), - }); await buildEnd({ buildManifest, reactRouterConfig: resolvedConfigWithRoutes, @@ -1292,6 +1290,7 @@ export const pluginReactRouter = ( 'manifest:stage', 'virtual/react-router/browser-manifest', () => { + latestBrowserManifest = manifest; const baseServerManifest = { ...manifest, sri, @@ -1473,7 +1472,7 @@ export const pluginReactRouter = ( return { code: args.code, map: null }; } - const sourceExports = await analysis.getExportNames(); + const sourceExports = analysis.exportNames; const chunkedExportSet = new Set(chunkedExports); const isMainChunkExport = (name: string) => !chunkedExportSet.has(name); @@ -1536,9 +1535,12 @@ export const pluginReactRouter = ( 'module:client-only-stub', args.resource, async () => { - const code = await transformToEsm(args.code, args.resourcePath); + const analysis = await getBundlerRouteAnalysis( + args.code, + args.resourcePath + ); const { exportNames: directExportNames, exportAllModules } = - await getExportNamesAndExportAll(code); + analysis; const exportNames = new Set(directExportNames); const unresolvedExportAll = new Set(); const visitedModules = new Set(); @@ -1703,7 +1705,7 @@ export const pluginReactRouter = ( // In SPA mode, server-only route exports are invalid (except root `loader`), // and `HydrateFallback` is only allowed on the root route. if (args.environment.name === 'web' && !ssr && isSpaMode) { - const resolvedExportNames = await analysis.getExportNames(); + const resolvedExportNames = analysis.exportNames; const isRootRoute = args.resourcePath === rootRoutePath; const relativePath = relative(process.cwd(), args.resourcePath); @@ -1713,9 +1715,7 @@ export const pluginReactRouter = ( }); if (invalidServerOnly.length > 0) { - const list = invalidServerOnly - .map(e => `\`${e}\``) - .join(', '); + const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); throw new Error( `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + `\`${relativePath}\`: ${list}. ` + diff --git a/src/manifest.ts b/src/manifest.ts index bb75d0e..fc6abde 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -79,6 +79,16 @@ export type ReactRouterManifestForDev = { routes: Record; }; +export type ReactRouterManifestStats = Pick< + Rspack.StatsCompilation, + 'assetsByChunkName' +>; + +export const REACT_ROUTER_MANIFEST_STATS_OPTIONS = { + all: false, + assets: true, +} as const; + export type RouteManifestModuleExports = Record; const routeManifestModuleExports = new WeakMap< @@ -138,7 +148,7 @@ export async function getReactRouterManifestForDev( routes: Record, //@ts-ignore options: PluginOptions, - clientStats: Rspack.StatsCompilation | undefined, + clientStats: ReactRouterManifestStats | undefined, context: string, assetPrefix = '/', routeChunkOptions?: RouteChunkManifestOptions @@ -182,7 +192,7 @@ export async function getReactRouterManifestForDev( let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; const routeFilePath = resolve(context, route.file); let exports = new Set(); - let routeModuleExports: string[] = []; + let routeModuleExports: readonly string[] = []; let hasRouteChunkByExportName = createEmptyRouteChunkByExportName(); try { @@ -223,10 +233,7 @@ export async function getReactRouterManifestForDev( validateRouteChunks({ config: routeChunkConfig, id: routeFilePath, - valid: buildManifestChunkValidity( - exports, - hasRouteChunkByExportName - ), + valid: buildManifestChunkValidity(exports, hasRouteChunkByExportName), }); } diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 50dff2e..13bd4b5 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -5,6 +5,7 @@ import type { Rspack } from '@rsbuild/core'; import { getReactRouterManifestForDev, getReactRouterManifestPath, + REACT_ROUTER_MANIFEST_STATS_OPTIONS, } from './manifest.js'; import { combineURLs } from './plugin-utils.js'; import jsesc from 'jsesc'; @@ -35,7 +36,9 @@ export function createModifyBrowserManifestPlugin( compiler.hooks.emit.tapAsync( 'ModifyBrowserManifest', async (compilation: Rspack.Compilation, callback) => { - const stats = compilation.getStats().toJson(); + const stats = compilation + .getStats() + .toJson(REACT_ROUTER_MANIFEST_STATS_OPTIONS); const manifest = await getReactRouterManifestForDev( routes, pluginOptions, diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 2e2e6df..87f2790 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -288,7 +288,21 @@ const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { ) { return true; } - if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement') { + if ( + parent.type === 'ExportSpecifier' || + parent.type === 'ExportDefaultSpecifier' || + parent.type === 'ExportNamespaceSpecifier' + ) { + return true; + } + if (parent.type === 'ImportSpecifier' && parent.imported === node) { + return true; + } + if ( + parent.type === 'LabeledStatement' || + parent.type === 'BreakStatement' || + parent.type === 'ContinueStatement' + ) { return true; } return false; @@ -296,9 +310,9 @@ const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); -const collectReferencedNames = (program: AnyNode): Set => { +const collectReferencedNames = (node: AnyNode): Set => { const referenced = new Set(); - walk(program as any, { + walk(node as any, { Identifier(node: AnyNode, ctx: any) { const parent = ctx.parent as AnyNode | null; if (!isNonReferenceIdentifier(node, parent)) { @@ -322,8 +336,14 @@ const collectReferencedNames = (program: AnyNode): Set => { referenced.add(node.name); } }, - ExportSpecifier(node: AnyNode) { - if (node.local?.name && node.exportKind !== 'type') { + ExportSpecifier(node: AnyNode, ctx: any) { + const declaration = ctx.parent as AnyNode | null; + if ( + !declaration?.source && + declaration?.exportKind !== 'type' && + node.local?.name && + node.exportKind !== 'type' + ) { referenced.add(node.local.name); } }, @@ -345,66 +365,175 @@ const getExportedName = (specifier: AnyNode): string | null => { return null; }; -const collectExportedLocalNames = (program: AnyNode): Set => { - const names = new Set(); +type TopLevelDeclaration = { + referencedNames: Set; +}; + +type TopLevelDeclarationGraph = { + declarationsByNode: Map; + declarationsByName: Map>; +}; + +const createTopLevelDeclarationGraph = ( + program: AnyNode +): TopLevelDeclarationGraph => { + const declarationsByNode = new Map(); + const declarationsByName = new Map>(); + + const registerDeclaration = ( + node: AnyNode, + declarationNode: AnyNode, + declaredNames: Set + ) => { + const declaration: TopLevelDeclaration = { + referencedNames: collectReferencedNames(declarationNode), + }; + declarationsByNode.set(node, declaration); + for (const name of declaredNames) { + const namedDeclarations = declarationsByName.get(name) ?? new Set(); + namedDeclarations.add(declaration); + declarationsByName.set(name, namedDeclarations); + } + }; + for (const statement of program.body ?? []) { - if (statement.type === 'ExportDefaultDeclaration') { - if (statement.declaration?.id?.name) { - names.add(statement.declaration.id.name); + if (statement.type === 'VariableDeclaration') { + for (const declarator of statement.declarations) { + registerDeclaration( + declarator, + declarator, + getPatternIdentifierNames(declarator.id) + ); } continue; } - if (statement.type !== 'ExportNamedDeclaration') { + if ( + statement.type === 'FunctionDeclaration' || + statement.type === 'ClassDeclaration' + ) { + registerDeclaration(statement, statement, getDeclaredNames(statement)); + } + } + + return { declarationsByNode, declarationsByName }; +}; + +const collectLiveTopLevelDeclarations = ( + program: AnyNode, + graph: TopLevelDeclarationGraph +): Set => { + const pendingNames: string[] = []; + + for (const statement of program.body ?? []) { + if (statement.type === 'VariableDeclaration') { continue; } - if (statement.declaration) { - for (const name of getDeclaredNames(statement.declaration)) { - names.add(name); - } + if (graph.declarationsByNode.has(statement)) { + continue; } - for (const specifier of statement.specifiers ?? []) { - if (specifier.local?.name && specifier.exportKind !== 'type') { - names.add(specifier.local.name); + for (const name of collectReferencedNames(statement)) { + pendingNames.push(name); + } + } + + // This is intentionally name-based and conservative: shadowing may retain a + // declaration, but it must never make a live declaration removable. + const visitedNames = new Set(); + const liveDeclarations = new Set(); + while (pendingNames.length > 0) { + const name = pendingNames.pop(); + if (!name || visitedNames.has(name)) { + continue; + } + visitedNames.add(name); + for (const declaration of graph.declarationsByName.get(name) ?? []) { + if (!liveDeclarations.has(declaration)) { + liveDeclarations.add(declaration); + for (const referencedName of declaration.referencedNames) { + pendingNames.push(referencedName); + } } } } - return names; + + return liveDeclarations; }; -const removeUnusedTopLevelDeclarations = (program: AnyNode): void => { - let changed = true; - while (changed) { - changed = false; - const referenced = collectReferencedNames(program); - const exported = collectExportedLocalNames(program); - for (const statement of [...program.body]) { - if (statement.type !== 'VariableDeclaration') { - if ( - (statement.type === 'FunctionDeclaration' || - statement.type === 'ClassDeclaration') && - statement.id?.name && - !referenced.has(statement.id.name) && - !exported.has(statement.id.name) - ) { - removeFromArray(program.body, statement); - changed = true; - } - continue; +const declarationReferencesName = ( + declaration: TopLevelDeclaration, + names: ReadonlySet, + graph: TopLevelDeclarationGraph, + cache: Map, + visitedNames = new Set() +): boolean => { + const cached = cache.get(declaration); + if (cached !== undefined) { + return cached; + } + + for (const referencedName of declaration.referencedNames) { + if (names.has(referencedName)) { + cache.set(declaration, true); + return true; + } + if (visitedNames.has(referencedName)) { + continue; + } + visitedNames.add(referencedName); + for (const referencedDeclaration of graph.declarationsByName.get( + referencedName + ) ?? []) { + if ( + declarationReferencesName( + referencedDeclaration, + names, + graph, + cache, + visitedNames + ) + ) { + cache.set(declaration, true); + return true; } + } + } + cache.set(declaration, false); + return false; +}; + +const removeNewlyDeadTopLevelDeclarations = ( + program: AnyNode, + graph: TopLevelDeclarationGraph, + previouslyLive: ReadonlySet, + removedExportReferencedNames: ReadonlySet +): void => { + const currentlyLive = collectLiveTopLevelDeclarations(program, graph); + const removedReferenceCache = new Map(); + const isRemovableDeadDeclaration = (node: AnyNode) => { + const declaration = graph.declarationsByNode.get(node); + if (!declaration || currentlyLive.has(declaration)) { + return false; + } + return ( + previouslyLive.has(declaration) || + declarationReferencesName( + declaration, + removedExportReferencedNames, + graph, + removedReferenceCache + ) + ); + }; + + program.body = program.body.filter((statement: AnyNode) => { + if (statement.type === 'VariableDeclaration') { statement.declarations = statement.declarations.filter( - (declarator: AnyNode) => { - const names = getPatternIdentifierNames(declarator.id); - return Array.from(names).some( - name => referenced.has(name) || exported.has(name) - ); - } + (declarator: AnyNode) => !isRemovableDeadDeclaration(declarator) ); - if (statement.declarations.length === 0) { - removeFromArray(program.body, statement); - changed = true; - } + return statement.declarations.length > 0; } - } + return !isRemovableDeadDeclaration(statement); + }); }; export const removeExports = ( @@ -412,8 +541,24 @@ export const removeExports = ( exportsToRemove: readonly string[] ): void => { const program = getProgram(ast); - let exportsFiltered = false; + const declarationGraph = createTopLevelDeclarationGraph(program); + const previouslyLive = collectLiveTopLevelDeclarations( + program, + declarationGraph + ); + let exportsChanged = false; const removedExportLocalNames = new Set(); + const removedExportReferencedNames = new Set(); + const trackRemovedExportReferences = (node: AnyNode | null | undefined) => { + if (!node) { + return; + } + const declaration = declarationGraph.declarationsByNode.get(node); + for (const name of declaration?.referencedNames ?? + collectReferencedNames(node)) { + removedExportReferencedNames.add(name); + } + }; for (const statement of [...program.body]) { if (statement.type === 'ExportNamedDeclaration') { @@ -425,9 +570,10 @@ export const removeExports = ( } const exportedName = getExportedName(specifier); if (exportedName && exportsToRemove.includes(exportedName)) { - exportsFiltered = true; + exportsChanged = true; if (specifier.local?.name) { removedExportLocalNames.add(specifier.local.name); + removedExportReferencedNames.add(specifier.local.name); } return false; } @@ -445,8 +591,10 @@ export const removeExports = ( (declarator: AnyNode) => { if (declarator.id.type === 'Identifier') { if (exportsToRemove.includes(declarator.id.name)) { - exportsFiltered = true; + exportsChanged = true; removedExportLocalNames.add(declarator.id.name); + removedExportReferencedNames.add(declarator.id.name); + trackRemovedExportReferences(declarator); return false; } return true; @@ -467,7 +615,10 @@ export const removeExports = ( declaration.id?.name && exportsToRemove.includes(declaration.id.name) ) { + exportsChanged = true; removedExportLocalNames.add(declaration.id.name); + removedExportReferencedNames.add(declaration.id.name); + trackRemovedExportReferences(statement); removeFromArray(program.body, statement); } } @@ -476,12 +627,16 @@ export const removeExports = ( statement.type === 'ExportDefaultDeclaration' && exportsToRemove.includes('default') ) { + exportsChanged = true; const declaration = statement.declaration; if (declaration?.type === 'Identifier') { removedExportLocalNames.add(declaration.name); + removedExportReferencedNames.add(declaration.name); } else if (declaration?.id?.name) { removedExportLocalNames.add(declaration.id.name); + removedExportReferencedNames.add(declaration.id.name); } + trackRemovedExportReferences(statement); removeFromArray(program.body, statement); } } @@ -494,15 +649,19 @@ export const removeExports = ( if ( left?.type === 'MemberExpression' && left.object?.type === 'Identifier' && - (exportsToRemove.includes(left.object.name) || - removedExportLocalNames.has(left.object.name)) + removedExportLocalNames.has(left.object.name) ) { removeFromArray(program.body, statement); } } - if (exportsFiltered || removedExportLocalNames.size > 0) { - removeUnusedTopLevelDeclarations(program); + if (exportsChanged) { + removeNewlyDeadTopLevelDeclarations( + program, + declarationGraph, + previouslyLive, + removedExportReferencedNames + ); } }; diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 8ede2ff..eaa89dd 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -84,7 +84,6 @@ export const createRouteClientEntryArtifact = async ({ routeChunkConfig, }: RouteClientEntryArtifactOptions): Promise => { const analysis = await getBundlerRouteAnalysis(code, resourcePath); - const exportNames = await analysis.getExportNames(); const isServer = environmentName === 'node'; const splitRouteModules = routeChunkConfig.splitRouteModules; const chunkedExports = @@ -94,7 +93,7 @@ export const createRouteClientEntryArtifact = async ({ : []; return { code: buildRouteClientEntryCode({ - exportNames, + exportNames: analysis.exportNames, chunkedExports, isServer, resourcePath, diff --git a/src/route-chunks.ts b/src/route-chunks.ts index e6d586d..3e92cd4 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -85,14 +85,11 @@ const invariant: (value: unknown, message: string) => asserts value = ( }; const getOrSetFromCache = ( - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, key: string, version: string, getValue: () => T ): T => { - if (!cache) { - return getValue(); - } const entry = cache.get(key) as RouteChunkCacheEntry | undefined; if (entry?.version === version) { return entry.value; @@ -104,12 +101,14 @@ const getOrSetFromCache = ( type AnalyzedModule = { module: Module; + // Dependency sets use these node identities. Consumers must shallow-copy + // any node whose children they narrow instead of mutating this cached AST. program: AnyNode; }; const analyzeCode = ( code: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): AnalyzedModule => { return getOrSetFromCache(cache, `${cacheKey}::analyzeCode`, code, () => { @@ -129,12 +128,6 @@ const analyzeCode = ( }); }; -const cloneProgram = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string -): AnyNode => structuredClone(analyzeCode(code, cache, cacheKey).program); - type ExportDependencies = { topLevelStatements: Set; topLevelNonModuleStatements: Set; @@ -192,11 +185,6 @@ const getExportedName = (exported: AnyNode): string => { return String(exported.value); }; -const sameNode = (left: AnyNode, right: AnyNode): boolean => - left.type === right.type && - left.start === right.start && - left.end === right.end; - const setsIntersect = (set1: Set, set2: Set) => { let smallerSet = set1; let largerSet = set2; @@ -214,7 +202,7 @@ const setsIntersect = (set1: Set, set2: Set) => { const getExportDependencies = ( code: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): Map => { return getOrSetFromCache( @@ -317,7 +305,7 @@ const getExportDependencies = ( const hasChunkableExport = ( code: string, exportName: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ) => { return getOrSetFromCache( @@ -390,16 +378,16 @@ const filterImportSpecifiers = ( if (node.specifiers.length === 0) { return node; } - node.specifiers = node.specifiers.filter((specifier: AnyNode) => + const specifiers = node.specifiers.filter((specifier: AnyNode) => shouldKeep(specifier.local.name) ); - return node.specifiers.length > 0 ? node : null; + return specifiers.length > 0 ? { ...node, specifiers } : null; }; const getChunkedExport = ( code: string, exportName: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): string | undefined => { return getOrSetFromCache( @@ -414,18 +402,9 @@ const getChunkedExport = ( const dependencies = exportDependencies.get(exportName); invariant(dependencies, 'Expected export to have dependencies'); - const topLevelStatementsArray = Array.from( - dependencies.topLevelStatements - ); - const exportedVariableDeclaratorsArray = Array.from( - dependencies.exportedVariableDeclarators - ); - - const program = cloneProgram(code, cache, cacheKey); - program.body = program.body - .filter((node: AnyNode) => - topLevelStatementsArray.some(statement => sameNode(node, statement)) - ) + const program = analyzeCode(code, cache, cacheKey).program; + const body = program.body + .filter((node: AnyNode) => dependencies.topLevelStatements.has(node)) .map((node: AnyNode) => { if (node.type !== 'ImportDeclaration') { return node; @@ -449,13 +428,16 @@ const getChunkedExport = ( } const { declaration } = node; if (declaration?.type === 'VariableDeclaration') { - declaration.declarations = declaration.declarations.filter( + const declarations = declaration.declarations.filter( (declarationNode: AnyNode) => - exportedVariableDeclaratorsArray.some(declarator => - sameNode(declarationNode, declarator) - ) + dependencies.exportedVariableDeclarators.has(declarationNode) ); - return declaration.declarations.length > 0 ? node : null; + return declarations.length > 0 + ? { + ...node, + declaration: { ...declaration, declarations }, + } + : null; } if ( declaration?.type === 'FunctionDeclaration' || @@ -464,17 +446,17 @@ const getChunkedExport = ( return declaration.id?.name === exportName ? node : null; } if (node.type === 'ExportNamedDeclaration') { - node.specifiers = node.specifiers.filter( + const specifiers = node.specifiers.filter( (specifier: AnyNode) => getExportedName(specifier.exported) === exportName ); - return node.specifiers.length > 0 ? node : null; + return specifiers.length > 0 ? { ...node, specifiers } : null; } throw new Error('Unknown export node type'); }) .filter(Boolean) as AnyNode[]; - return generateCode(program); + return generateCode({ ...program, body }); } ); }; @@ -482,7 +464,7 @@ const getChunkedExport = ( const omitChunkedExports = ( code: string, exportNames: string[], - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): string | undefined => { return getOrSetFromCache( @@ -518,16 +500,9 @@ const omitChunkedExports = ( } } - const omittedStatementsArray = Array.from(omittedStatements); - const omittedExportedVariableDeclaratorsArray = Array.from( - omittedExportedVariableDeclarators - ); - - const program = cloneProgram(code, cache, cacheKey); - program.body = program.body - .filter((node: AnyNode) => - omittedStatementsArray.every(statement => !sameNode(node, statement)) - ) + const program = analyzeCode(code, cache, cacheKey).program; + const body = program.body + .filter((node: AnyNode) => !omittedStatements.has(node)) .map((node: AnyNode) => { if (node.type !== 'ImportDeclaration') { return node; @@ -559,13 +534,16 @@ const omitChunkedExports = ( return isOmitted('default') ? null : node; } if (node.declaration?.type === 'VariableDeclaration') { - node.declaration.declarations = - node.declaration.declarations.filter((declarationNode: AnyNode) => - omittedExportedVariableDeclaratorsArray.every( - declarator => !sameNode(declarationNode, declarator) - ) - ); - return node.declaration.declarations.length > 0 ? node : null; + const declarations = node.declaration.declarations.filter( + (declarationNode: AnyNode) => + !omittedExportedVariableDeclarators.has(declarationNode) + ); + return declarations.length > 0 + ? { + ...node, + declaration: { ...node.declaration, declarations }, + } + : null; } if ( node.declaration?.type === 'FunctionDeclaration' || @@ -574,17 +552,19 @@ const omitChunkedExports = ( return isOmitted(node.declaration.id.name) ? null : node; } if (node.type === 'ExportNamedDeclaration') { - node.specifiers = node.specifiers.filter((specifier: AnyNode) => { + const specifiers = node.specifiers.filter((specifier: AnyNode) => { const exportedName = getExportedName(specifier.exported); return !isOmitted(exportedName); }); - return node.specifiers.length > 0 || node.declaration ? node : null; + return specifiers.length > 0 || node.declaration + ? { ...node, specifiers } + : null; } throw new Error('Unknown node type'); }) .filter(Boolean) as AnyNode[]; - return generateCode(program); + return generateCode({ ...program, body }); } ); }; @@ -594,8 +574,9 @@ export const detectRouteChunks = ( cache: RouteChunkCache | undefined, cacheKey: string ): RouteChunkInfo => { + const analysisCache = cache ?? new Map(); const hasRouteChunkByExportName = createRouteChunkExportMap(exportName => - hasChunkableExport(code, exportName, cache, cacheKey) + hasChunkableExport(code, exportName, analysisCache, cacheKey) ); const chunkedExports = Object.entries(hasRouteChunkByExportName) .filter(([, isChunked]) => isChunked) @@ -619,10 +600,16 @@ export const getRouteChunkCode: ( cache: RouteChunkCache | undefined, cacheKey: string ) => { + const analysisCache = cache ?? new Map(); if (chunkName === 'main') { - return omitChunkedExports(code, routeChunkExportNames, cache, cacheKey); + return omitChunkedExports( + code, + routeChunkExportNames, + analysisCache, + cacheKey + ); } - return getChunkedExport(code, chunkName, cache, cacheKey); + return getChunkedExport(code, chunkName, analysisCache, cacheKey); }; export const getRouteChunkModuleId = ( diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index c6bc673..a9036f6 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from '@rstest/core'; import { parse } from '../src/babel'; -import { getBundlerRouteAnalysis, transformToEsm } from '../src/export-utils'; +import { + getBundlerRouteAnalysis, + getExportNamesAndExportAll, + transformToEsm, +} from '../src/export-utils'; const routeChunkConfig = { splitRouteModules: true as const, @@ -21,15 +25,12 @@ describe('getBundlerRouteAnalysis', () => { expect(second).toBe(first); expect(second.code).toBe(first.code); - expect(second.getExportNames()).toBe(first.getExportNames()); + expect(second.exportNames).toBe(first.exportNames); expect(second.getRouteChunkInfo(undefined, routeChunkConfig)).toBe( first.getRouteChunkInfo(undefined, routeChunkConfig) ); - expect(await first.getExportNames()).toEqual([ - 'clientAction', - 'default', - ]); + expect(first.exportNames).toEqual(['clientAction', 'default']); await expect( first.getRouteChunkInfo(undefined, routeChunkConfig) ).resolves.toMatchObject({ @@ -51,7 +52,75 @@ describe('getBundlerRouteAnalysis', () => { ); expect(updated).not.toBe(initial); - await expect(updated.getExportNames()).resolves.toEqual(['clientLoader']); + expect(updated.exportNames).toEqual(['clientLoader']); + }); + + it('collects runtime exports and export-all modules from the initial parse', async () => { + const analysis = await getBundlerRouteAnalysis( + ` + export type LoaderData = { value: string }; + export interface RouteHandle { title: string } + export type * from './types'; + export type * as typeHelpers from './type-helpers'; + export * from './shared'; + export * as helpers from './helpers'; + export const loader = () => null; + export default function Route() { return null; } + `, + '/app/routes/runtime-exports.tsx' + ); + + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + expect(exportInfo).toEqual({ + exportNames: ['helpers', 'loader', 'default'], + exportAllModules: ['./shared'], + }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); + }); + + it('does not report an erased default interface as a runtime export', async () => { + const analysis = await getBundlerRouteAnalysis( + `export default interface RouteType { value: string }`, + '/app/routes/type-only-default.tsx' + ); + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + + expect(exportInfo).toEqual({ exportNames: [], exportAllModules: [] }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); + }); + + it('does not report erased ambient declarations as runtime exports', async () => { + const analysis = await getBundlerRouteAnalysis( + ` + export declare function loader(): void; + export declare const action: () => void; + export declare class ServerOnly {} + export const clientLoader = () => null; + `, + '/app/routes/ambient-exports.tsx' + ); + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + + expect(exportInfo).toEqual({ + exportNames: ['clientLoader'], + exportAllModules: [], + }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); }); }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index 44f94d7..ef0e7ec 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -74,6 +74,27 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); + it('does not treat imported names as local references', () => { + const code = ` + import { + loaderDependency as dependency, + unrelated as loaderDependency, + } from './data.server'; + export function loader() { + return dependency(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + expect(generate(ast).code).not.toContain('./data.server'); + }); + it('keeps top-level declarations referenced from JSX after removing exports', () => { const code = ` export function loader() { @@ -97,4 +118,190 @@ describe('removeExports', () => { expect(result).toContain('function ProgressBar'); expect(result).toContain(' { + const code = ` + const leaf = 1, middle = leaf; + export function loader() { + return middle; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('leaf'); + expect(result).not.toContain('middle'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('removes every declaration in a deep dead dependency chain', () => { + const helperCount = 64; + const helpers = Array.from({ length: helperCount }, (_, index) => { + const value = + index === helperCount - 1 ? '1' : `helper${index + 1}()`; + return `const helper${index} = () => ${value};`; + }).join('\n'); + const code = ` + ${helpers} + export function loader() { + return helper0(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toMatch(/\bhelper\d+\b/); + expect(result).toContain('Route'); + }); + + it('preserves declarations that were already unused before export removal', () => { + const code = ` + import { register } from './registry'; + const registration = register(); + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + const result = generate(ast).code; + expect(result).toContain("import { register } from './registry'"); + expect(result).toContain('const registration = register()'); + expect(result).not.toContain('loader'); + }); + + it('removes pre-existing unused declarations that reference removed export locals', () => { + const code = ` + const leaked = loader; + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('leaked'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('removes pre-existing unused declarations that retain server-only imports', () => { + const code = ` + import { readSecret } from './data.server'; + const leaked = readSecret(); + export function loader() { + return readSecret(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + const result = generate(ast).code; + expect(result).not.toContain('./data.server'); + expect(result).not.toContain('leaked'); + expect(result).not.toContain('readSecret'); + expect(result).toContain('Route'); + }); + + it('removes multiple pre-existing unused declarations through shared removed export dependencies', () => { + const code = ` + const shared = () => loader(); + const first = () => shared(); + const second = () => shared(); + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('shared'); + expect(result).not.toContain('first'); + expect(result).not.toContain('second'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('does not treat an exported alias as a reference to its exported name', () => { + const code = ` + const loader = register(); + const implementation = () => null; + export { implementation as loader }; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).toContain('const loader = register()'); + expect(result).not.toContain('implementation'); + }); + + it('removes a dead declaration cycle reached only by a removed export', () => { + const code = ` + const first = () => second(); + const second = () => first(); + export function loader() { + return first(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('first'); + expect(result).not.toContain('second'); + expect(result).toContain('Route'); + }); + + it('removes dependencies of an anonymous default export', () => { + const code = ` + const render = () => null; + export default () => render(); + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['default']); + + expect(generate(ast).code).not.toContain('render'); + }); }); From f69bc2eb6591610ea2fae2579171e84905cf0f5b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:22:00 +0200 Subject: [PATCH 19/30] perf: enable lazy compilation by default --- scripts/benchmark/fixture.mjs | 2 +- src/index.ts | 1 + src/types.ts | 4 ++-- tests/index.test.ts | 12 ++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 39e0cac..2fced1f 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -229,7 +229,7 @@ const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { ' plugins: [', ' pluginReactRouter({', ...(ssr ? [` serverOutput: 'module',`] : []), - ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '1' ? { lazyCompilation: true } : {}),`, + ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '0' ? { lazyCompilation: false } : process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '1' ? { lazyCompilation: true } : {}),`, ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, ' }),', ' ],', diff --git a/src/index.ts b/src/index.ts index 53cb597..1654e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,6 +126,7 @@ export const pluginReactRouter = ( async setup(api) { const defaultOptions = { customServer: false, + lazyCompilation: true, serverOutput: 'module' as const, }; diff --git a/src/types.ts b/src/types.ts index 0d8d31a..198537e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,11 +31,11 @@ export type PluginOptions = { federation?: boolean; /** - * Opt in to Rsbuild's dev-only lazy compilation behavior. + * Configure Rsbuild's dev-only lazy compilation behavior. * * This forwards to `dev.lazyCompilation` and does not affect production * builds. - * @default undefined + * @default true */ lazyCompilation?: NonNullable['lazyCompilation']; diff --git a/tests/index.test.ts b/tests/index.test.ts index b00f193..f1f81da 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -15,6 +15,7 @@ describe('pluginReactRouter', () => { expect(config.dev.hmr).toBe(true); expect(config.dev.liveReload).toBe(true); expect(config.dev.writeToDisk).toBe(true); + expect(config.dev.lazyCompilation).toBe(true); }); it('should respect server output format', async () => { @@ -52,6 +53,17 @@ describe('pluginReactRouter', () => { }); }); + it('should allow lazy compilation to be disabled', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: false })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toBe(false); + }); + it('should configure web environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, From 0711614cc2170313e5b203f370620382f774a40f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:53:43 +0200 Subject: [PATCH 20/30] fix: keep lazy compilation opt-in --- src/index.ts | 6 ++++-- src/types.ts | 6 +++--- tests/index.test.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1654e56..fc1fdd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,7 +126,6 @@ export const pluginReactRouter = ( async setup(api) { const defaultOptions = { customServer: false, - lazyCompilation: true, serverOutput: 'module' as const, }; @@ -599,7 +598,10 @@ export const pluginReactRouter = ( ); } - const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/') + ); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, data); api.logger.info( diff --git a/src/types.ts b/src/types.ts index 198537e..a8cac36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,9 +33,9 @@ export type PluginOptions = { /** * Configure Rsbuild's dev-only lazy compilation behavior. * - * This forwards to `dev.lazyCompilation` and does not affect production - * builds. - * @default true + * This forwards to `dev.lazyCompilation` when set and does not affect + * production builds. Route modules are loaded synchronously during hydration, + * so this remains opt-in. */ lazyCompilation?: NonNullable['lazyCompilation']; diff --git a/tests/index.test.ts b/tests/index.test.ts index f1f81da..e3e9aba 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -15,7 +15,7 @@ describe('pluginReactRouter', () => { expect(config.dev.hmr).toBe(true); expect(config.dev.liveReload).toBe(true); expect(config.dev.writeToDisk).toBe(true); - expect(config.dev.lazyCompilation).toBe(true); + expect(config.dev.lazyCompilation).toBeUndefined(); }); it('should respect server output format', async () => { From fe68c845e9c038947345671413859f30b6136587 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:53:58 +0200 Subject: [PATCH 21/30] chore: clarify benchmark lazy compilation option --- scripts/benchmark/fixture.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 2fced1f..79b766d 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -220,6 +220,11 @@ const createRoutesConfig = routeCount => { const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { const ssr = variant !== 'spa'; + const lazyCompilationOption = + ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '0'` + + ` ? { lazyCompilation: false }` + + ` : process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '1'` + + ` ? { lazyCompilation: true } : {}),`; return [ `import { defineConfig } from '@rsbuild/core';`, @@ -229,7 +234,7 @@ const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { ' plugins: [', ' pluginReactRouter({', ...(ssr ? [` serverOutput: 'module',`] : []), - ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '0' ? { lazyCompilation: false } : process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '1' ? { lazyCompilation: true } : {}),`, + lazyCompilationOption, ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, ' }),', ' ],', From 59f88b8c5bd1e67dad709995042f3c054ab38cb2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:27:36 +0200 Subject: [PATCH 22/30] fix: restart dev server for route entry changes (#43) --- .../default-template/playwright.config.ts | 6 +- .../tests/e2e/dev-route-watch.test.ts | 151 ++++++++++ src/index.ts | 103 +++++-- src/route-watch.ts | 264 ++++++++++++++++++ tests/index.test.ts | 82 +++++- tests/route-watch.test.ts | 102 +++++++ tests/setup.ts | 5 +- 7 files changed, 691 insertions(+), 22 deletions(-) create mode 100644 examples/default-template/tests/e2e/dev-route-watch.test.ts create mode 100644 src/route-watch.ts create mode 100644 tests/route-watch.test.ts diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts index 66c43d8..6b32a51 100644 --- a/examples/default-template/playwright.config.ts +++ b/examples/default-template/playwright.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ }, // Run tests in files in parallel fullyParallel: false, + // This suite includes dev-route-watch, which mutates routes.ts and restarts + // the shared dev server. Keep this example serial so other tests do not race + // the intentional restart. + workers: 1, // Fail the build on CI if you accidentally left test.only in the source code forbidOnly: !!process.env.CI, // Retry on CI only @@ -47,4 +51,4 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, -}); \ No newline at end of file +}); diff --git a/examples/default-template/tests/e2e/dev-route-watch.test.ts b/examples/default-template/tests/e2e/dev-route-watch.test.ts new file mode 100644 index 0000000..32035c7 --- /dev/null +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -0,0 +1,151 @@ +import { expect, test, type Page } from '@playwright/test'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDirectory = join(__dirname, '../../app'); +const restartMarkerPath = join( + __dirname, + '../../build/client/.react-router/route-watch' +); +const routesConfigPath = join(appDirectory, 'routes.ts'); +const addedRoutePath = join(appDirectory, 'routes/dev-added-route.tsx'); +const addedRouteUrl = '/dev-added-route'; +const addedRouteText = 'Route added while dev server is running'; +const editedAddedRouteText = 'Route edited without dev server restart'; +const addedRouteConfigEntry = ` route('dev-added-route', 'routes/dev-added-route.tsx'),`; + +const removeAddedRouteConfig = (): boolean => { + const routesConfig = readFileSync(routesConfigPath, 'utf8'); + if (routesConfig.includes(addedRouteConfigEntry)) { + writeFileSync( + routesConfigPath, + routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '') + ); + return true; + } + return false; +}; + +const removeAddedRouteFile = (): boolean => { + if (existsSync(addedRoutePath)) { + rmSync(addedRoutePath, { force: true }); + return true; + } + return false; +}; + +const readRestartMarker = (): string | null => + existsSync(restartMarkerPath) + ? readFileSync(restartMarkerPath, 'utf8') + : null; + +const expectRestartMarkerStable = async ( + expectedMarker: string | null, + quietMs = 750 +) => { + const startedAt = Date.now(); + await expect + .poll( + () => { + const marker = readRestartMarker(); + if (marker !== expectedMarker) { + return `changed:${marker ?? 'missing'}`; + } + return Date.now() - startedAt >= quietMs ? 'stable' : 'waiting'; + }, + { intervals: [100], timeout: quietMs + 1000 } + ) + .toBe('stable'); +}; + +const waitForRouteText = async ( + page: Page, + url: string, + text: string +) => { + await expect + .poll( + async () => { + try { + const response = await page.request.get(url, { + timeout: 2000, + }); + if (!response.ok()) { + return `status:${response.status()}`; + } + const body = await response.text(); + return body.includes(text) ? 'ready' : 'missing-text'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + { timeout: 60000 } + ) + .toBe('ready'); +}; + +test.describe('dev route watch', () => { + test.setTimeout(90000); + + test.beforeEach(async ({ page }) => { + if (removeAddedRouteConfig()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + if (removeAddedRouteFile()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + }); + + test.afterEach(async ({ page }) => { + if (removeAddedRouteConfig()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + if (removeAddedRouteFile()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + }); + + test('serves a route added after the dev server starts without restarting on later edits', async ({ + page, + }) => { + await page.goto('/'); + await expect(page.locator('h1')).toContainText('Welcome to React Router'); + + writeFileSync( + addedRoutePath, + `export default function DevAddedRoute() { + return

${addedRouteText}

; +} +` + ); + + const routesConfig = readFileSync(routesConfigPath, 'utf8'); + writeFileSync( + routesConfigPath, + routesConfig.replace( + ' // Docs section with nested routes', + `${addedRouteConfigEntry}\n\n // Docs section with nested routes` + ) + ); + + await waitForRouteText(page, addedRouteUrl, addedRouteText); + + await page.goto(addedRouteUrl); + await expect(page.locator('h1')).toHaveText(addedRouteText); + + await expect.poll(readRestartMarker, { timeout: 10000 }).not.toBe(null); + const restartMarkerBefore = readRestartMarker(); + writeFileSync( + addedRoutePath, + `export default function DevAddedRoute() { + return

${editedAddedRouteText}

; +} +` + ); + + await waitForRouteText(page, addedRouteUrl, editedAddedRouteText); + await expectRestartMarkerStable(restartMarkerBefore); + }); +}); diff --git a/src/index.ts b/src/index.ts index fc1fdd1..4019e36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,15 @@ import { createRouteChunkArtifact, createRouteClientEntryArtifact, } from './route-artifacts.js'; +import { + createRouteTopologyWatcher, + createRouteManifestSnapshot, + emitRouteRestartMarkerAsset, + ensureDevRestartMarker, + getRouteRestartMarkerPath, + mergeWatchFiles, + type WatchFileConfig, +} from './route-watch.js'; import { validateRouteConfig } from './route-config.js'; import { getBuildManifest, @@ -198,7 +207,9 @@ export const pluginReactRouter = ( }); }); - const jiti = createJiti(process.cwd()); + const jiti = createJiti(process.cwd(), { + moduleCache: false, + }); // Read the react-router.config file first (supports .ts, .js, .mjs, etc.) const configPath = findEntryFile(resolve('react-router.config')); @@ -326,21 +337,24 @@ export const pluginReactRouter = ( ); } - const routeConfigExport = await jiti.import( - routesPath, - { - default: true, + const loadRouteConfig = async (): Promise => { + const routeConfigExport = await jiti.import( + routesPath, + { + default: true, + } + ); + const routeConfigValue = await routeConfigExport; + const validation = validateRouteConfig({ + routeConfigFile: relative(process.cwd(), routesPath), + routeConfig: routeConfigValue, + }); + if (!validation.valid) { + throw new Error(validation.message); } - ); - const routeConfigValue = await routeConfigExport; - const validation = validateRouteConfig({ - routeConfigFile: relative(process.cwd(), routesPath), - routeConfig: routeConfigValue, - }); - if (!validation.valid) { - throw new Error(validation.message); - } - const routeConfig = validation.routeConfig; + return validation.routeConfig; + }; + const routeConfig = await loadRouteConfig(); const entryClientPath = findEntryFile( resolve(appDirectory, 'entry.client') @@ -372,6 +386,14 @@ export const pluginReactRouter = ( // React Router's server build expects route files relative to `appDirectory` // so it can resolve them correctly during compilation. const rootRouteFile = relative(appDirectory, rootRoutePath); + const getWatchedRouteTopology = async (): Promise> => { + const latestRouteConfig = await loadRouteConfig(); + const latestRoutes = { + root: { path: '', id: 'root', file: rootRouteFile }, + ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), + }; + return createRouteManifestSnapshot(latestRoutes); + }; const routes = { root: { path: '', id: 'root', file: rootRouteFile }, @@ -411,6 +433,40 @@ export const pluginReactRouter = ( isBuild, cache: routeChunkCache, }; + const outputClientPath = resolve(buildDirectory, 'client'); + const assetsBuildDirectory = relative(process.cwd(), outputClientPath); + const watchDirectory = resolve(appDirectory); + const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); + const routeWatchFiles: WatchFileConfig[] = [ + { + paths: routesPath, + type: 'reload-server', + }, + { + paths: routeRestartMarkerPath, + type: 'reload-server', + }, + ]; + let closeRouteTopologyWatcher: (() => void) | undefined; + + api.onBeforeStartDevServer(async () => { + await ensureDevRestartMarker(routeRestartMarkerPath); + closeRouteTopologyWatcher = await createRouteTopologyWatcher({ + watchDirectory, + getRouteTopology: getWatchedRouteTopology, + restartMarkerPath: routeRestartMarkerPath, + onError: error => { + api.logger.warn( + `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` + ); + }, + }); + }); + + api.onCloseDevServer(() => { + closeRouteTopologyWatcher?.(); + closeRouteTopologyWatcher = undefined; + }); type ReactRouterManifest = Awaited< ReturnType @@ -466,9 +522,6 @@ export const pluginReactRouter = ( }); const routesByServerBundleId = getRoutesByServerBundleId(buildManifest); - const outputClientPath = resolve(buildDirectory, 'client'); - const assetsBuildDirectory = relative(process.cwd(), outputClientPath); - let clientStats: ReactRouterManifestStats | undefined; api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { @@ -1149,6 +1202,7 @@ export const pluginReactRouter = ( dev: { writeToDisk: true, ...lazyCompilation, + watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles), // Only add SSR middleware if SSR is enabled and not using a custom server // In SPA mode (ssr: false), we just serve static files from the client build setupMiddlewares: @@ -1333,6 +1387,19 @@ export const pluginReactRouter = ( } ); + if (isBuild) { + api.processAssets( + { stage: 'additional', targets: ['web'] }, + ({ sources, compilation }) => { + emitRouteRestartMarkerAsset({ + restartMarkerPath: routeRestartMarkerPath, + sources, + compilation, + }); + } + ); + } + api.processAssets( { stage: 'additional', targets: ['node'] }, ({ sources, compilation }) => { diff --git a/src/route-watch.ts b/src/route-watch.ts new file mode 100644 index 0000000..153a0c7 --- /dev/null +++ b/src/route-watch.ts @@ -0,0 +1,264 @@ +import { existsSync, readFileSync, watch, type FSWatcher } from 'node:fs'; +import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; +import type { ProcessAssetsHandler, RsbuildConfig } from '@rsbuild/core'; +import { dirname, resolve } from 'pathe'; +import type { Route } from './types.js'; + +export const ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch'; +const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch'; + +type RouteManifestSnapshotEntry = Pick< + Route, + 'caseSensitive' | 'file' | 'id' | 'index' | 'parentId' | 'path' +>; + +type WatchFilesConfig = NonNullable< + NonNullable['watchFiles'] +>; +export type WatchFileConfig = + | Exclude + | Extract[number]; + +type RouteDirectoryState = { + directories: Set; + routeTopology: Set; +}; + +type ProcessAssetsContext = Parameters[0]; +type RouteRestartMarkerAssetOptions = Pick< + ProcessAssetsContext, + 'compilation' | 'sources' +> & { + restartMarkerPath: string; +}; + +export const mergeWatchFiles = ( + existing: WatchFilesConfig | undefined, + additions: WatchFileConfig[] +): WatchFilesConfig => { + if (!existing) { + return additions as WatchFilesConfig; + } + return [ + ...(Array.isArray(existing) ? existing : [existing]), + ...additions, + ] as WatchFilesConfig; +}; + +export const getRouteRestartMarkerPath = (outputClientPath: string): string => + resolve(outputClientPath, ROUTE_RESTART_MARKER_ASSET); + +const readRestartMarkerContent = (restartMarkerPath: string): string => { + if (!existsSync(restartMarkerPath)) { + return INITIAL_RESTART_MARKER_CONTENT; + } + + try { + const content = readFileSync(restartMarkerPath, 'utf8'); + return content || INITIAL_RESTART_MARKER_CONTENT; + } catch { + return INITIAL_RESTART_MARKER_CONTENT; + } +}; + +export const emitRouteRestartMarkerAsset = ({ + restartMarkerPath, + sources, + compilation, +}: RouteRestartMarkerAssetOptions): void => { + const source = new sources.RawSource( + readRestartMarkerContent(restartMarkerPath) + ); + if (compilation.getAsset(ROUTE_RESTART_MARKER_ASSET)) { + compilation.updateAsset(ROUTE_RESTART_MARKER_ASSET, source); + return; + } + compilation.emitAsset(ROUTE_RESTART_MARKER_ASSET, source); +}; + +export const createRouteManifestSnapshot = ( + routes: Record +): Set => + new Set( + Object.entries(routes) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, route]) => + JSON.stringify([ + routeId, + route.id, + route.parentId ?? null, + route.path ?? null, + route.index ?? null, + route.caseSensitive ?? null, + route.file, + ]) + ) + ); + +export const ensureDevRestartMarker = async ( + restartMarkerPath: string +): Promise => { + // Build emits this marker through processAssets. Dev owns the watched file + // directly so ordinary rebuilds do not rewrite it and trigger reload loops. + await mkdir(dirname(restartMarkerPath), { recursive: true }); + try { + await access(restartMarkerPath); + } catch { + await writeFile(restartMarkerPath, INITIAL_RESTART_MARKER_CONTENT); + } +}; + +const areSetsEqual = (left: Set, right: Set): boolean => { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +}; + +const readRouteDirectoryState = async ({ + watchDirectory, + getRouteTopology, +}: { + watchDirectory: string; + getRouteTopology: () => Promise>; +}): Promise => { + const directories = new Set(); + + const walkDirectory = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + directories.add(directory); + await Promise.all( + entries.map(async entry => { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walkDirectory(entryPath); + } + }) + ); + }; + + await walkDirectory(watchDirectory); + return { + directories, + routeTopology: await getRouteTopology(), + }; +}; + +export const createRouteTopologyWatcher = async ({ + watchDirectory, + getRouteTopology, + restartMarkerPath, + onError, +}: { + watchDirectory: string; + getRouteTopology: () => Promise>; + restartMarkerPath: string; + onError: (error: unknown) => void; +}): Promise<() => void> => { + let state = await readRouteDirectoryState({ + watchDirectory, + getRouteTopology, + }); + let closed = false; + let rescanTimer: ReturnType | undefined; + let rescanQueue = Promise.resolve(); + const directoryWatchers = new Map(); + + const touchRestartMarker = async (): Promise => { + await mkdir(dirname(restartMarkerPath), { recursive: true }); + await writeFile(restartMarkerPath, String(Date.now())); + }; + + const closeRemovedDirectoryWatchers = ( + nextDirectories: Set + ): void => { + for (const [directory, watcher] of directoryWatchers) { + if (!nextDirectories.has(directory)) { + watcher.close(); + directoryWatchers.delete(directory); + } + } + }; + + const watchNewDirectories = (nextDirectories: Set): void => { + for (const directory of nextDirectories) { + if (directoryWatchers.has(directory)) { + continue; + } + try { + const watcher = watch(directory, () => { + scheduleRescan(); + }); + watcher.on('error', onError); + directoryWatchers.set(directory, watcher); + } catch (error) { + onError(error); + } + } + }; + + const syncDirectoryWatchers = (nextDirectories: Set): void => { + closeRemovedDirectoryWatchers(nextDirectories); + watchNewDirectories(nextDirectories); + }; + + const runRescan = async (): Promise => { + if (closed) { + return; + } + try { + const nextState = await readRouteDirectoryState({ + watchDirectory, + getRouteTopology, + }); + syncDirectoryWatchers(nextState.directories); + if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { + state = nextState; + await touchRestartMarker(); + return; + } + state = nextState; + } catch (error) { + onError(error); + } + }; + + const rescan = (): Promise => { + rescanQueue = rescanQueue.then(runRescan, runRescan); + return rescanQueue; + }; + + const scheduleRescan = (): void => { + if (rescanTimer) { + clearTimeout(rescanTimer); + } + rescanTimer = setTimeout(() => { + rescanTimer = undefined; + void rescan(); + }, 100); + }; + + syncDirectoryWatchers(state.directories); + + return () => { + closed = true; + if (rescanTimer) { + clearTimeout(rescanTimer); + } + for (const watcher of directoryWatchers.values()) { + watcher.close(); + } + directoryWatchers.clear(); + }; +}; diff --git a/tests/index.test.ts b/tests/index.test.ts index e3e9aba..4519dce 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ import { createStubRsbuild } from '@scripts/test-helper'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { pluginReactRouter } from '../src'; describe('pluginReactRouter', () => { @@ -18,6 +18,86 @@ describe('pluginReactRouter', () => { expect(config.dev.lazyCompilation).toBeUndefined(); }); + it('should restart the dev server when route entries are added', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: { + dev: { + watchFiles: { + paths: 'custom.config.ts', + type: 'reload-server', + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.watchFiles).toEqual( + expect.arrayContaining([ + { + paths: 'custom.config.ts', + type: 'reload-server', + }, + { + paths: expect.stringMatching(/app\/routes\.[cm]?[jt]sx?$/), + type: 'reload-server', + }, + { + paths: expect.stringMatching( + /build\/client\/\.react-router\/route-watch$/ + ), + type: 'reload-server', + }, + ]) + ); + }); + + it('emits the route restart marker as a web build asset', async () => { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + await rsbuild.unwrapConfig(); + + const processAssetsCall = rsbuild.processAssets.mock.calls.find( + ([options]) => + options.stage === 'additional' && options.targets?.includes('web') + ); + expect(processAssetsCall).toBeDefined(); + + const handler = processAssetsCall?.[1]; + const emitAsset = rstest.fn(); + const updateAsset = rstest.fn(); + const RawSource = class { + constructor(private readonly content: string) {} + source() { + return this.content; + } + size() { + return this.content.length; + } + }; + + handler({ + sources: { RawSource }, + compilation: { + getAsset: rstest.fn().mockReturnValue(undefined), + emitAsset, + updateAsset, + }, + }); + + expect(emitAsset).toHaveBeenCalledWith( + '.react-router/route-watch', + expect.any(RawSource) + ); + expect(emitAsset.mock.calls[0][1].source()).not.toBe(''); + expect(updateAsset).not.toHaveBeenCalled(); + }); + it('should respect server output format', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts new file mode 100644 index 0000000..0784ef3 --- /dev/null +++ b/tests/route-watch.test.ts @@ -0,0 +1,102 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { + createRouteManifestSnapshot, + ensureDevRestartMarker, + getRouteRestartMarkerPath, +} from '../src/route-watch'; + +describe('route watch restart marker', () => { + it('places the restart marker in the client build output', () => { + expect(getRouteRestartMarkerPath('/project/build/client')).toBe( + '/project/build/client/.react-router/route-watch' + ); + }); + + it('creates the restart marker when missing', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + try { + const markerPath = join(root, 'build/.react-router-route-watch'); + + await ensureDevRestartMarker(markerPath); + + expect(readFileSync(markerPath, 'utf8')).not.toBe(''); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not rewrite an existing restart marker on dev server startup', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + try { + const markerPath = join(root, 'build/.react-router-route-watch'); + mkdirSync(join(root, 'build'), { recursive: true }); + writeFileSync(markerPath, 'existing'); + + await ensureDevRestartMarker(markerPath); + + expect(readFileSync(markerPath, 'utf8')).toBe('existing'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('route watch topology snapshot', () => { + it('changes when route topology changes but route files stay the same', () => { + const baseRoutes = { + root: { id: 'root', path: '', file: 'root.tsx' }, + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + }; + + const changedRoutes = { + ...baseRoutes, + 'routes/demo': { + ...baseRoutes['routes/demo'], + path: 'renamed-demo', + }, + }; + + expect(createRouteManifestSnapshot(baseRoutes)).not.toEqual( + createRouteManifestSnapshot(changedRoutes) + ); + }); + + it('is stable for equivalent route manifests with different object insertion order', () => { + const first = createRouteManifestSnapshot({ + root: { id: 'root', path: '', file: 'root.tsx' }, + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + }); + + const second = createRouteManifestSnapshot({ + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + root: { id: 'root', path: '', file: 'root.tsx' }, + }); + + expect(second).toEqual(first); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index f4cde81..c8ea6b0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -51,7 +51,7 @@ const deepMerge = (base: any, overrides: any): any => { // Mock the @scripts/test-helper module rstest.mock('@scripts/test-helper', () => ({ - createStubRsbuild: rstest.fn().mockImplementation(async ({ rsbuildConfig = {} } = {}) => { + createStubRsbuild: rstest.fn().mockImplementation(async ({ action = 'dev', rsbuildConfig = {} } = {}) => { const baseConfig = { dev: { // Match Rsbuild defaults so plugin changes are observable in tests. @@ -110,6 +110,7 @@ rstest.mock('@scripts/test-helper', () => ({ unwrapConfig: rstest.fn(), processAssets: rstest.fn(), onBeforeStartDevServer: rstest.fn(), + onCloseDevServer: rstest.fn(), onBeforeBuild: rstest.fn(), onAfterBuild: rstest.fn(), getNormalizedConfig: rstest.fn().mockImplementation(() => mergedConfig), @@ -127,7 +128,7 @@ rstest.mock('@scripts/test-helper', () => ({ }, context: { rootPath: '/Users/bytedance/dev/rsbuild-plugin-react-router', - action: 'dev', + action, }, compiler: { webpack: { From 9a128d7f71613e6bdefe45031325c529343b4aac Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:57:34 +0200 Subject: [PATCH 23/30] fix: harden route export splitting --- package.json | 1 - pnpm-lock.yaml | 40 +++++++++------ scripts/bench-builds.mjs | 10 ++-- src/export-utils.ts | 3 +- src/index.ts | 2 +- src/manifest.ts | 3 ++ src/performance.ts | 2 + src/plugin-utils.ts | 97 ++++++++++++++++++++++++++++++++---- src/route-chunks.ts | 20 +++++--- tests/export-utils.test.ts | 11 ++++ tests/index.test.ts | 11 ++++ tests/plugin-utils.test.ts | 45 +++++++++++++++++ tests/remove-exports.test.ts | 32 ++++++++++++ tests/route-chunks.test.ts | 12 +++++ 14 files changed, 250 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index e7bfa0c..66105e3 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "jsesc": "^3.1.0", "pathe": "^2.0.3", "react-refresh": "^0.18.0", - "rspack-plugin-virtual-module": "^1.0.1", "yuku-analyzer": "0.5.38", "yuku-codegen": "0.5.38", "yuku-parser": "0.5.38" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa8cf0b..66c0ee9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 0.13.0 '@rspack/plugin-react-refresh': specifier: ^2.0.2 - version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0) + version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0) execa: specifier: ^9.6.1 version: 9.6.1 @@ -41,9 +41,6 @@ importers: react-refresh: specifier: ^0.18.0 version: 0.18.0 - rspack-plugin-virtual-module: - specifier: ^1.0.1 - version: 1.0.1 yuku-analyzer: specifier: 0.5.38 version: 0.5.38 @@ -68,7 +65,7 @@ importers: version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.0.8 version: 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -8459,9 +8456,6 @@ packages: resolution: {integrity: sha512-DCUkRKUBR1lSpHKRcxNvHaYwGrUVf9MsoE1u6gd0CF37I8vwwtWc4b+FA9OwYZ4QA/shslzAYorD3MMfd+Rs/Q==} engines: {node: ^20.19.0 || >=22.12.0} - rspack-plugin-virtual-module@1.0.1: - resolution: {integrity: sha512-NQJ3fXa1v0WayvfHMWbyqLUA3JIqgCkhIcIOnZscuisinxorQyIAo+bqcU5pCusMKSyPqVIWO3caQyl0s9VDAg==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -10543,7 +10537,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -12263,6 +12257,17 @@ snapshots: - '@rspack/core' - webpack + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - '@typescript/native-preview' + - core-js + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -12389,7 +12394,7 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: @@ -15534,7 +15539,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -16735,6 +16740,13 @@ snapshots: transitivePeerDependencies: - supports-color + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + optionalDependencies: + typescript: 5.9.3 + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 @@ -16744,10 +16756,6 @@ snapshots: rslog@2.1.3: {} - rspack-plugin-virtual-module@1.0.1: - dependencies: - fs-extra: 11.3.3 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -17042,7 +17050,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index 99979b9..e39a63f 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -301,6 +301,8 @@ const renderMarkdown = result => { '', `## ${benchmark.id} Plugin Operations`, '', + 'Total is the sum of all measured operation durations. Wall merges overlapping intervals to approximate elapsed plugin time. Max is the slowest single operation call.', + '', '| Environment | Operation | Count | Total | Wall | Max | Reports |', '|---|---|---:|---:|---:|---:|---:|' ); @@ -476,6 +478,9 @@ const main = async () => { }); } + const failed = benchmarks.some(benchmark => + benchmark.runs.some(run => run.status !== 0) + ); const result = { repo: 'rsbuild-plugin-react-router', commit: await git(['rev-parse', 'HEAD']), @@ -486,6 +491,7 @@ const main = async () => { profile: args.profile, iterations: args.iterations, warmup: args.warmup, + failed, benchmarks, }; @@ -494,9 +500,7 @@ const main = async () => { `Benchmark results written to ${path.resolve(rootDir, args.out)}` ); - if ( - benchmarks.some(benchmark => benchmark.runs.some(run => run.status !== 0)) - ) { + if (failed) { console.error('One or more measured benchmark builds failed.'); process.exitCode = 1; } diff --git a/src/export-utils.ts b/src/export-utils.ts index 5865640..7178f94 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -189,7 +189,8 @@ const collectExportNames = (program: AnyNode): string[] => { } } else if ( (declaration.type === 'FunctionDeclaration' || - declaration.type === 'ClassDeclaration') && + declaration.type === 'ClassDeclaration' || + declaration.type === 'TSEnumDeclaration') && declaration.id?.name ) { exportNames.add(declaration.id.name); diff --git a/src/index.ts b/src/index.ts index 4019e36..c18f1aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1435,7 +1435,7 @@ export const pluginReactRouter = ( const bundleMatch = args.resource.match( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); - const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); + const bundleId = bundleMatch?.[1]?.replace(/\.js$/, ''); const manifest = (isBuild && latestServerManifest diff --git a/src/manifest.ts b/src/manifest.ts index fc6abde..8134de2 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -221,6 +221,9 @@ export async function getReactRouterManifestForDev( hasRouteChunkByExportName = chunkInfo; } } catch (error) { + if (isBuild) { + throw error; + } console.error(`Failed to analyze route file ${routeFilePath}:`, error); } diff --git a/src/performance.ts b/src/performance.ts index fb049ca..4e46a0a 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -1,5 +1,7 @@ type OperationTiming = { count: number; + // Total sums every recorded duration, so parallel work can make it larger + // than elapsed wall-clock time. Use wallMs for non-overlapping elapsed time. totalMs: number; wallMs: number; maxMs: number; diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 87f2790..7521d6f 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -16,12 +16,29 @@ export function validateDestructuredExports( id: AnyNode, exportsToRemove: readonly string[] ): void { + if (id.type === 'Identifier') { + if (exportsToRemove.includes(id.name)) { + throw invalidDestructureError(id.name); + } + return; + } + + if (id.type === 'AssignmentPattern') { + validateDestructuredExports(id.left, exportsToRemove); + return; + } + if (id.type === 'ArrayPattern') { for (const element of id.elements ?? []) { if (!element) { continue; } + if (element.type === 'AssignmentPattern') { + validateDestructuredExports(element, exportsToRemove); + continue; + } + if ( element.type === 'Identifier' && exportsToRemove.includes(element.name) @@ -58,6 +75,7 @@ export function validateDestructuredExports( } if ( + property.value.type === 'AssignmentPattern' || property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' ) { @@ -88,6 +106,14 @@ export function toFunctionExpression(decl: AnyNode): AnyNode { }; } +export function toClassExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'ClassExpression', + declare: undefined, + }; +} + export function combineURLs(baseURL: string, relativeURL: string): string { return relativeURL ? `${baseURL.replace(/\/+$/, '')}/${relativeURL.replace(/^\/+/, '')}` @@ -561,6 +587,17 @@ export const removeExports = ( }; for (const statement of [...program.body]) { + if (statement.type === 'ExportAllDeclaration') { + const exportedName = statement.exported + ? getExportedName({ exported: statement.exported }) + : null; + if (!exportedName || exportsToRemove.includes(exportedName)) { + exportsChanged = true; + removeFromArray(program.body, statement); + } + continue; + } + if (statement.type === 'ExportNamedDeclaration') { if (statement.specifiers?.length) { statement.specifiers = statement.specifiers.filter( @@ -769,26 +806,45 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { const program = getProgram(ast); const usedNames = collectUsedNames(program); const hocs: Array<[string, string]> = []; + const componentWrapperDeclarations: AnyNode[] = []; - function getHocUid(hocName: string) { - let uid = `_${hocName}`; + function getUid(name: string) { + let uid = `_${name}`; let index = 2; while (usedNames.has(uid)) { - uid = `_${hocName}${index++}`; + uid = `_${name}${index++}`; } usedNames.add(uid); + return uid; + } + + function getHocUid(hocName: string) { + const uid = getUid(hocName); hocs.push([hocName, uid]); return identifier(uid); } + function wrapNamedComponentDeclaration(name: string, declaration: AnyNode) { + const uid = getHocUid(`with${name}Props`); + const expression = + declaration.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration.type === 'ClassDeclaration' + ? toClassExpression(declaration) + : declaration; + return variableDeclaration(name, callExpression(uid, [expression])); + } + for (const statement of program.body ?? []) { if (statement.type === 'ExportDefaultDeclaration') { const declaration = statement.declaration; const expr = declaration?.type === 'FunctionDeclaration' ? toFunctionExpression(declaration) + : declaration?.type === 'ClassDeclaration' + ? toClassExpression(declaration) : declaration; - if (expr && expr.type !== 'ClassDeclaration') { + if (expr) { const uid = getHocUid('withComponentProps'); statement.declaration = callExpression(uid, [expr]); } @@ -815,19 +871,42 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { } if ( - declaration?.type === 'FunctionDeclaration' && + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && declaration.id?.name && isNamedComponentExport(declaration.id.name) ) { const name = declaration.id.name; - const uid = getHocUid(`with${name}Props`); - statement.declaration = variableDeclaration( - name, - callExpression(uid, [toFunctionExpression(declaration)]) + statement.declaration = wrapNamedComponentDeclaration(name, declaration); + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') { + continue; + } + const exportedName = getExportedName(specifier); + if (!exportedName || !isNamedComponentExport(exportedName)) { + continue; + } + const localName = specifier.local?.name; + if (!localName) { + continue; + } + const wrappedLocalName = getUid(exportedName); + const uid = getHocUid(`with${exportedName}Props`); + componentWrapperDeclarations.push( + variableDeclaration( + wrappedLocalName, + callExpression(uid, [identifier(localName)]) + ) ); + specifier.local = identifier(wrappedLocalName); } } + program.body.push(...componentWrapperDeclarations); + if (hocs.length > 0) { program.body.unshift( importDeclaration( diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 3e92cd4..154cc6b 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -225,14 +225,14 @@ const getExportDependencies = ( exportedVariableDeclarators: new Set(), }; const visitedSymbols = new Set(); - const scannedStatements = new Set(); + const scannedNodes = new Set(); - const scanStatement = (statement: AnyNode) => { - if (scannedStatements.has(statement)) { + const scanNode = (node: AnyNode) => { + if (scannedNodes.has(node)) { return; } - scannedStatements.add(statement); - walk(statement as any, { + scannedNodes.add(node); + walk(node as any, { Identifier(node: AnyNode) { const reference = module.referenceOf(node as never); if (reference?.symbol) { @@ -265,7 +265,7 @@ const getExportDependencies = ( ) { dependencies.exportedVariableDeclarators.add(declarator); } - scanStatement(statement); + scanNode(declarator ?? statement); } for (const reference of symbol.references as any[]) { @@ -274,7 +274,11 @@ const getExportDependencies = ( reference.node ); addTopLevelStatement(module, dependencies, reference.node); - scanStatement(statement); + const declarator = getVariableDeclaratorForNode( + module, + reference.node + ); + scanNode(declarator ?? statement); } }; @@ -284,7 +288,7 @@ const getExportDependencies = ( visitSymbol(localSymbol); } else { const statement = getTopLevelStatementForNode(module, exportNode); - scanStatement(statement); + scanNode(statement); } exportDependencies.set(exportName, dependencies); diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index a9036f6..ef28132 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -83,6 +83,17 @@ describe('getBundlerRouteAnalysis', () => { ); }); + it('collects exported TypeScript enum names as runtime exports', async () => { + await expect( + getExportNamesAndExportAll( + `export enum Status { Active = 'active' }` + ) + ).resolves.toEqual({ + exportNames: ['Status'], + exportAllModules: [], + }); + }); + it('does not report an erased default interface as a runtime export', async () => { const analysis = await getBundlerRouteAnalysis( `export default interface RouteType { value: string }`, diff --git a/tests/index.test.ts b/tests/index.test.ts index 4519dce..3c6347c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -133,6 +133,17 @@ describe('pluginReactRouter', () => { }); }); + it('should allow lazy compilation to be enabled with a boolean', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: true })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toBe(true); + }); + it('should allow lazy compilation to be disabled', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 8c85258..ddb0e7d 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from '@rstest/core'; +import { generate, parse } from '../src/babel'; import { combineURLs, stripFileExtension, createRouteId, generateWithProps, normalizeAssetPrefix, + transformRoute, } from '../src/plugin-utils'; +const transformRouteCode = (code: string) => { + const ast = parse(code, { sourceType: 'module' }); + transformRoute(ast); + return generate(ast).code; +}; + describe('plugin-utils', () => { describe('combineURLs', () => { it('should combine base and relative URLs', () => { @@ -121,4 +129,41 @@ describe('plugin-utils', () => { expect(normalizeAssetPrefix('/assets/')).toBe('/assets/'); }); }); + + describe('transformRoute', () => { + it('wraps default class exports with component props', () => { + const result = transformRouteCode(` + export default class Route {} + `); + + expect(result).toContain('withComponentProps'); + expect(result).toMatch(/export default _withComponentProps\(class Route/); + }); + + it('wraps named class component exports', () => { + const result = transformRouteCode(` + export class ErrorBoundary {} + `); + + expect(result).toContain('withErrorBoundaryProps'); + expect(result).toMatch( + /export const ErrorBoundary = _withErrorBoundaryProps\(class ErrorBoundary/ + ); + }); + + it('wraps component exports declared through export specifiers', () => { + const result = transformRouteCode(` + function Boundary() { + return null; + } + export { Boundary as ErrorBoundary }; + `); + + expect(result).toContain('withErrorBoundaryProps'); + expect(result).toMatch( + /const _ErrorBoundary = _withErrorBoundaryProps\(Boundary\)/ + ); + expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); + }); + }); }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index ef0e7ec..bac6fba 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -74,6 +74,22 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); + it('removes export-all declarations when removing server-only exports', () => { + const code = ` + export * from './data.server'; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain("export * from './data.server'"); + expect(result).toContain('Route'); + }); + it('does not treat imported names as local references', () => { const code = ` import { @@ -140,6 +156,22 @@ describe('removeExports', () => { expect(result).toContain('Route'); }); + it('rejects destructured defaults for removed server-only exports', () => { + const code = ` + const route = { loader: async () => null }; + export const { loader = async () => null } = route; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).toThrowError( + 'Cannot remove destructured export "loader"' + ); + }); + it('removes every declaration in a deep dead dependency chain', () => { const helperCount = 64; const helpers = Array.from({ length: helperCount }, (_, index) => { diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index 13197ed..bfd19a0 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -214,6 +214,18 @@ describe('route chunks', () => { expect(result.hasRouteChunkByExportName.clientLoader).toBe(false); }); + it('does not scan sibling declarators from shared export statements as dependencies', async () => { + const code = ` + const serverOnly = () => null; + export const clientAction = async () => null, helper = serverOnly(); + export default function Route() { return helper; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientAction'); + }); + it('orders chunkedExports by routeChunkExportNames, not source order', async () => { const code = ` export function HydrateFallback() { return null; } From f3a263366d77139b9b6d84c0a85f189a4fd63327 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 04:04:40 +0200 Subject: [PATCH 24/30] feat: add parallel route transform executor --- rslib.config.ts | 2 + scripts/bench-builds.mjs | 32 +++ scripts/benchmark/fixture.mjs | 29 +- src/index.ts | 334 +++------------------- src/parallel-route-transform-worker.ts | 59 ++++ src/parallel-route-transforms.ts | 235 ++++++++++++++++ src/route-transform-tasks.ts | 358 ++++++++++++++++++++++++ src/types.ts | 13 + tests/benchmark-fixture.test.ts | 25 ++ tests/parallel-route-transforms.test.ts | 66 +++++ tests/setup.ts | 1 + 11 files changed, 862 insertions(+), 292 deletions(-) create mode 100644 src/parallel-route-transform-worker.ts create mode 100644 src/parallel-route-transforms.ts create mode 100644 src/route-transform-tasks.ts create mode 100644 tests/parallel-route-transforms.test.ts diff --git a/rslib.config.ts b/rslib.config.ts index 2966d22..f00f09c 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -8,6 +8,8 @@ const config = defineConfig({ source: { entry: { index: './src/index.ts', + 'parallel-route-transform-worker': + './src/parallel-route-transform-worker.ts', 'templates/entry.server': './src/templates/entry.server.tsx', 'templates/entry.client': './src/templates/entry.client.tsx', }, diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index e39a63f..dca6416 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -75,11 +75,28 @@ const parseArgs = argv => { }, clean: { type: 'string', default: 'build' }, filter: { type: 'string' }, + 'parallel-transforms': { type: 'string' }, 'fail-fast': { type: 'boolean', default: false }, 'skip-root-build': { type: 'boolean', default: false }, }, }); + const parseParallelTransforms = value => { + if (value === undefined || value === 'false' || value === '0') { + return false; + } + if (value === 'true' || value === '1' || value === 'auto') { + return true; + } + const maxWorkers = Number(value); + if (!Number.isInteger(maxWorkers) || maxWorkers < 1) { + throw new Error( + '--parallel-transforms must be true, false, auto, or a positive integer.' + ); + } + return { maxWorkers }; + }; + const args = { profile: values.profile, iterations: Number(values.iterations), @@ -88,6 +105,7 @@ const parseArgs = argv => { out: values.out, clean: values.clean, filter: values.filter ?? null, + parallelTransforms: parseParallelTransforms(values['parallel-transforms']), failFast: values['fail-fast'], skipRootBuild: values['skip-root-build'], }; @@ -270,6 +288,7 @@ const renderMarkdown = result => { `- Profile: ${result.profile}`, `- Iterations: ${result.iterations}`, `- Warmup: ${result.warmup}`, + `- Parallel transforms: ${formatParallelTransforms(result.parallelTransforms)}`, '', '| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | Max RSS | Plugin reports |', '|---|---:|---|---:|---:|---:|---:|---:|', @@ -352,6 +371,16 @@ const writeOutputs = async (result, args) => { } }; +const formatParallelTransforms = parallelTransforms => { + if (!parallelTransforms) { + return 'false'; + } + if (parallelTransforms === true) { + return 'true'; + } + return `maxWorkers=${parallelTransforms.maxWorkers}`; +}; + const git = async args => { const result = await runCommand({ command: 'git', @@ -417,6 +446,7 @@ const main = async () => { variant: benchmark.variant, sourceMap: benchmark.sourceMap ?? false, pluginImportPath, + parallelTransforms: args.parallelTransforms, }); const runs = []; @@ -469,6 +499,7 @@ const main = async () => { benchmarks.push({ ...benchmark, + parallelTransforms: args.parallelTransforms, cwd: path.relative(rootDir, fixtureRoot), command: 'node /node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs', @@ -491,6 +522,7 @@ const main = async () => { profile: args.profile, iterations: args.iterations, warmup: args.warmup, + parallelTransforms: args.parallelTransforms, failed, benchmarks, }; diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index 79b766d..d3749f5 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -218,7 +218,24 @@ const createRoutesConfig = routeCount => { ].join('\n'); }; -const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { +const renderParallelTransformsOption = parallelTransforms => { + if (!parallelTransforms) { + return []; + } + if (parallelTransforms === true) { + return [` parallelTransforms: true,`]; + } + return [ + ` parallelTransforms: { maxWorkers: ${parallelTransforms.maxWorkers} },`, + ]; +}; + +const createRsbuildConfig = ({ + variant, + sourceMap, + pluginImportPath, + parallelTransforms, +}) => { const ssr = variant !== 'spa'; const lazyCompilationOption = ` ...(process.env.REACT_ROUTER_BENCHMARK_LAZY_COMPILATION === '0'` + @@ -234,6 +251,7 @@ const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath }) => { ' plugins: [', ' pluginReactRouter({', ...(ssr ? [` serverOutput: 'module',`] : []), + ...renderParallelTransformsOption(parallelTransforms), lazyCompilationOption, ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, ' }),', @@ -337,6 +355,7 @@ export async function generateSyntheticFixture({ sourceMap = false, pluginImportPath = 'rsbuild-plugin-react-router', fixture = 'default', + parallelTransforms = false, }) { if (!stressFixtureNames.has(fixture)) { throw new Error( @@ -355,7 +374,12 @@ export async function generateSyntheticFixture({ ); await writeFile( path.join(root, 'rsbuild.config.mjs'), - createRsbuildConfig({ variant, sourceMap, pluginImportPath }) + createRsbuildConfig({ + variant, + sourceMap, + pluginImportPath, + parallelTransforms, + }) ); await writeFile( path.join(root, 'react-router.config.ts'), @@ -415,5 +439,6 @@ export async function generateSyntheticFixture({ variant, sourceMap, fixture, + parallelTransforms, }; } diff --git a/src/index.ts b/src/index.ts index c18f1aa..c725dc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; @@ -8,24 +7,17 @@ import type { RouteConfigEntry } from '@react-router/dev/routes'; import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; -import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; +import { dirname, relative, resolve } from 'pathe'; -import { generate, parse } from './babel.js'; import { BUILD_CLIENT_ROUTE_QUERY_STRING, - JS_EXTENSIONS, PLUGIN_NAME, - SERVER_ONLY_ROUTE_EXPORTS, - SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { generateWithProps, - removeExports, - transformRoute, findEntryFile, normalizeAssetPrefix, - removeUnusedImports, } from './plugin-utils.js'; import type { PluginOptions } from './types.js'; import { @@ -51,10 +43,6 @@ import { } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; -import { - getBundlerRouteAnalysis, - getRouteModuleAnalysis, -} from './export-utils.js'; import { getRouteChunkEntryName, getRouteChunkModuleId, @@ -62,10 +50,7 @@ import { type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; -import { - createRouteChunkArtifact, - createRouteClientEntryArtifact, -} from './route-artifacts.js'; +import { createRouteTransformExecutor } from './parallel-route-transforms.js'; import { createRouteTopologyWatcher, createRouteManifestSnapshot, @@ -427,6 +412,10 @@ export const pluginReactRouter = ( rootRouteFile, }; const routeChunkCache: RouteChunkCache = new Map(); + const routeTransformExecutor = createRouteTransformExecutor({ + parallelTransforms: pluginOptions.parallelTransforms, + routeChunkCache, + }); const routeChunkOptions = { splitRouteModules, rootRouteFile, @@ -467,6 +456,12 @@ export const pluginReactRouter = ( closeRouteTopologyWatcher?.(); closeRouteTopologyWatcher = undefined; }); + api.onCloseBuild(async () => { + await routeTransformExecutor.close(); + }); + api.onCloseDevServer(async () => { + await routeTransformExecutor.close(); + }); type ReactRouterManifest = Awaited< ReturnType @@ -1467,16 +1462,15 @@ export const pluginReactRouter = ( args.environment?.name, 'route:client-entry', args.resource, - async () => { - return createRouteClientEntryArtifact({ + async () => + routeTransformExecutor.run({ + kind: 'routeClientEntry', code: args.code, resourcePath: args.resourcePath, environmentName: args.environment?.name, isBuild, - routeChunkCache, routeChunkConfig, - }); - } + }) ) ); @@ -1490,16 +1484,15 @@ export const pluginReactRouter = ( args.environment?.name, 'route:chunk', args.resource, - async () => { - return createRouteChunkArtifact({ + async () => + routeTransformExecutor.run({ + kind: 'routeChunk', code: args.code, resource: args.resource, resourcePath: args.resourcePath, isBuild, - routeChunkCache, routeChunkConfig, - }); - } + }) ) ); @@ -1529,48 +1522,12 @@ export const pluginReactRouter = ( return { code: args.code, map: null }; } - const analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - const { hasRouteChunks, chunkedExports } = - await analysis.getRouteChunkInfo( - routeChunkCache, - routeChunkConfig - ); - if (!hasRouteChunks) { - return { code: args.code, map: null }; - } - - const sourceExports = analysis.exportNames; - const chunkedExportSet = new Set(chunkedExports); - const isMainChunkExport = (name: string) => - !chunkedExportSet.has(name); - const mainChunkReexports = sourceExports - .filter(isMainChunkExport) - .join(', '); - const chunkBasePath = `./${pathBasename(args.resourcePath)}`; - - return { - code: [ - mainChunkReexports - ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( - chunkBasePath, - 'main' - )}";` - : null, - ...chunkedExports.map( - exportName => - `export { ${exportName} } from "${getRouteChunkModuleId( - chunkBasePath, - exportName - )}";` - ), - ] - .filter(Boolean) - .join('\n'), - map: null, - }; + return routeTransformExecutor.run({ + kind: 'splitRouteExports', + code: args.code, + resourcePath: args.resourcePath, + routeChunkConfig, + }); } ) ); @@ -1604,154 +1561,12 @@ export const pluginReactRouter = ( args.environment?.name, 'module:client-only-stub', args.resource, - async () => { - const analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - const { exportNames: directExportNames, exportAllModules } = - analysis; - const exportNames = new Set(directExportNames); - const unresolvedExportAll = new Set(); - const visitedModules = new Set(); - - const resolveIndexFile = (dirPath: string): string | null => { - for (const ext of JS_EXTENSIONS) { - const candidate = resolve(dirPath, `index${ext}`); - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; - } - } - return null; - }; - - const resolvePathWithExtensions = ( - basePath: string - ): string | null => { - if (existsSync(basePath)) { - try { - const stats = statSync(basePath); - if (stats.isFile()) { - return basePath; - } - if (stats.isDirectory()) { - return resolveIndexFile(basePath); - } - } catch { - // Ignore invalid paths and fall back to extension probing. - } - } - - for (const ext of JS_EXTENSIONS) { - const candidate = `${basePath}${ext}`; - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; - } - } - - return resolveIndexFile(basePath); - }; - - const resolveExportAllModule = ( - specifier: string, - importerPath: string - ): string | null => { - if (specifier.startsWith('.') || specifier.startsWith('/')) { - const basePath = specifier.startsWith('/') - ? specifier - : resolve(dirname(importerPath), specifier); - const resolvedPath = resolvePathWithExtensions(basePath); - if (resolvedPath) { - return resolvedPath; - } - } - - try { - const resolver = createRequire( - pathToFileURL(importerPath).href - ); - return resolver.resolve(specifier); - } catch { - return null; - } - }; - - const collectExportNamesFromModule = async ( - modulePath: string - ): Promise => { - if (visitedModules.has(modulePath)) { - return; - } - visitedModules.add(modulePath); - const { - exports: moduleExportNames, - exportAllModules: moduleExportAll, - } = await getRouteModuleAnalysis(modulePath); - for (const name of moduleExportNames) { - if (name !== 'default') { - exportNames.add(name); - } - } - for (const nestedSpecifier of moduleExportAll) { - const nestedPath = resolveExportAllModule( - nestedSpecifier, - modulePath - ); - if (!nestedPath) { - unresolvedExportAll.add(nestedSpecifier); - continue; - } - await collectExportNamesFromModule(nestedPath); - } - }; - - for (const specifier of exportAllModules) { - const resolvedPath = resolveExportAllModule( - specifier, - args.resourcePath - ); - if (!resolvedPath) { - unresolvedExportAll.add(specifier); - continue; - } - await collectExportNamesFromModule(resolvedPath); - } - - if (unresolvedExportAll.size > 0) { - throw new Error( - `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + - `unresolvable specifier(s): ${Array.from(unresolvedExportAll) - .map(spec => `\`${spec}\``) - .join(', ')}. ` + - `Please explicitly re-export named bindings in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`.` - ); - } - return { - code: Array.from(exportNames) - .map(name => - name === 'default' - ? 'export default undefined;' - : `export const ${name} = undefined;` - ) - .join('\n'), - map: null, - }; - } + async () => + routeTransformExecutor.run({ + kind: 'clientOnlyStub', + code: args.code, + resourcePath: args.resourcePath, + }) ) ); @@ -1764,78 +1579,17 @@ export const pluginReactRouter = ( args.environment?.name, 'route:module', args.resource, - async () => { - const analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - let code = analysis.code; - - // Match React Router Vite behavior: - // In SPA mode, server-only route exports are invalid (except root `loader`), - // and `HydrateFallback` is only allowed on the root route. - if (args.environment.name === 'web' && !ssr && isSpaMode) { - const resolvedExportNames = analysis.exportNames; - const isRootRoute = args.resourcePath === rootRoutePath; - const relativePath = relative(process.cwd(), args.resourcePath); - - const invalidServerOnly = resolvedExportNames.filter(exp => { - if (isRootRoute && exp === 'loader') return false; - return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); - }); - - if (invalidServerOnly.length > 0) { - const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); - throw new Error( - `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + - `\`${relativePath}\`: ${list}. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - - if ( - !isRootRoute && - resolvedExportNames.includes('HydrateFallback') - ) { - throw new Error( - `SPA Mode: Invalid \`HydrateFallback\` export found in ` + - `\`${relativePath}\`. ` + - `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - } - - const defaultExportMatch = code.match( - /\n\s{0,}([\w\d_]+)\sas default,?/ - ); - if ( - defaultExportMatch && - typeof defaultExportMatch.index === 'number' - ) { - code = - code.slice(0, defaultExportMatch.index) + - code.slice( - defaultExportMatch.index + defaultExportMatch[0].length - ); - code += `\nexport default ${defaultExportMatch[1]};`; - } - - const ast = parse(code, { sourceType: 'module' }); - if (args.environment.name === 'web') { - removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); - } - transformRoute(ast); - if (args.environment.name === 'web') { - removeUnusedImports(ast); - } - - return generate(ast, { - sourceMaps: true, - filename: args.resource, - sourceFileName: args.resourcePath, - }); - } + async () => + routeTransformExecutor.run({ + kind: 'routeModule', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + environmentName: args.environment.name, + ssr, + isSpaMode, + rootRoutePath, + }) ) ); }, diff --git a/src/parallel-route-transform-worker.ts b/src/parallel-route-transform-worker.ts new file mode 100644 index 0000000..20f4095 --- /dev/null +++ b/src/parallel-route-transform-worker.ts @@ -0,0 +1,59 @@ +import { parentPort } from 'node:worker_threads'; +import { + executeRouteTransformTask, + type RouteTransformResult, + type RouteTransformTask, +} from './route-transform-tasks.js'; + +type WorkerRequest = { + id: number; + task: RouteTransformTask; +}; + +type WorkerErrorPayload = { + name?: string; + message: string; + stack?: string; +}; + +type WorkerResponse = + | { + id: number; + ok: true; + result: RouteTransformResult; + } + | { + id: number; + ok: false; + error: WorkerErrorPayload; + }; + +const serializeError = (error: unknown): WorkerErrorPayload => { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { + message: String(error), + }; +}; + +if (!parentPort) { + throw new Error('parallel route transform worker requires parentPort'); +} + +parentPort.on('message', async ({ id, task }: WorkerRequest) => { + try { + const result = await executeRouteTransformTask(task); + parentPort?.postMessage({ id, ok: true, result } satisfies WorkerResponse); + } catch (error) { + parentPort?.postMessage({ + id, + ok: false, + error: serializeError(error), + } satisfies WorkerResponse); + } +}); diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts new file mode 100644 index 0000000..afecce3 --- /dev/null +++ b/src/parallel-route-transforms.ts @@ -0,0 +1,235 @@ +import { availableParallelism, cpus } from 'node:os'; +import { Worker } from 'node:worker_threads'; +import { + executeRouteTransformTask, + type RouteTransformResult, + type RouteTransformTask, + type RouteTransformTaskOptions, +} from './route-transform-tasks.js'; +import type { PluginOptions } from './types.js'; + +export type ParallelTransformsConfig = NonNullable< + PluginOptions['parallelTransforms'] +> extends infer Config + ? Exclude + : never; + +export type RouteTransformExecutorOptions = RouteTransformTaskOptions & { + parallelTransforms?: PluginOptions['parallelTransforms']; +}; + +export type RouteTransformExecutor = { + run: (task: RouteTransformTask) => Promise; + close: () => Promise; +}; + +type WorkerResponse = + | { + id: number; + ok: true; + result: RouteTransformResult; + } + | { + id: number; + ok: false; + error: WorkerErrorPayload; + }; + +type WorkerErrorPayload = { + name?: string; + message: string; + stack?: string; +}; + +type PendingTask = { + resolve: (result: RouteTransformResult) => void; + reject: (error: Error) => void; +}; + +type WorkerState = { + worker: Worker; + pending: Map; +}; + +class WorkerStartupError extends Error { + constructor(message: string) { + super(message); + this.name = 'WorkerStartupError'; + } +} + +const DEFAULT_MAX_WORKERS = 8; + +const getDefaultWorkerCount = (): number => { + const cpuCount = + typeof availableParallelism === 'function' + ? availableParallelism() + : cpus().length; + return Math.max(1, Math.min(DEFAULT_MAX_WORKERS, cpuCount)); +}; + +const getConfiguredWorkerCount = ( + parallelTransforms: ParallelTransformsConfig +): number => { + if (parallelTransforms === true) { + return getDefaultWorkerCount(); + } + + const configured = parallelTransforms.maxWorkers; + if (configured === undefined) { + return getDefaultWorkerCount(); + } + if (!Number.isFinite(configured) || configured < 1) { + throw new Error( + '[react-router] parallelTransforms.maxWorkers must be at least 1.' + ); + } + return Math.floor(configured); +}; + +const hashString = (value: string): number => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +}; + +const deserializeWorkerError = (error: WorkerErrorPayload): Error => { + const result = new Error(error.message); + result.name = error.name ?? 'Error'; + if (error.stack) { + result.stack = error.stack; + } + return result; +}; + +const createWorkerUrl = (): URL => + new URL('./parallel-route-transform-worker.js', import.meta.url); + +const isWorkerStartupError = (error: unknown): error is WorkerStartupError => + error instanceof WorkerStartupError; + +class ParallelRouteTransformExecutor implements RouteTransformExecutor { + #closed = false; + #nextId = 1; + #workers: WorkerState[]; + + constructor( + workerCount: number, + private readonly options: RouteTransformTaskOptions + ) { + this.#workers = Array.from({ length: workerCount }, () => + this.#createWorkerState() + ); + } + + async run(task: RouteTransformTask): Promise { + if (this.#closed) { + return executeRouteTransformTask(task, this.options); + } + + try { + return await this.#runInWorker(task); + } catch (error) { + if (isWorkerStartupError(error)) { + return executeRouteTransformTask(task, this.options); + } + throw error; + } + } + + async close(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + const workers = this.#workers; + this.#workers = []; + await Promise.all( + workers.map(async state => { + for (const pending of state.pending.values()) { + pending.reject(new Error('Route transform worker closed.')); + } + state.pending.clear(); + await state.worker.terminate(); + }) + ); + } + + #createWorkerState(): WorkerState { + const worker = new Worker(createWorkerUrl()); + const state: WorkerState = { + worker, + pending: new Map(), + }; + + worker.on('message', (response: WorkerResponse) => { + const pending = state.pending.get(response.id); + if (!pending) { + return; + } + state.pending.delete(response.id); + if (response.ok) { + pending.resolve(response.result); + } else { + pending.reject(deserializeWorkerError(response.error)); + } + }); + + worker.on('error', (error: Error) => { + const startupError = new WorkerStartupError(error.message); + startupError.stack = error.stack; + for (const pending of state.pending.values()) { + pending.reject(startupError); + } + state.pending.clear(); + }); + + worker.on('exit', code => { + if (this.#closed || code === 0) { + return; + } + const startupError = new WorkerStartupError( + `Route transform worker exited with code ${code}.` + ); + for (const pending of state.pending.values()) { + pending.reject(startupError); + } + state.pending.clear(); + }); + + return state; + } + + #runInWorker(task: RouteTransformTask): Promise { + const workerIndex = + hashString(task.resourcePath) % Math.max(1, this.#workers.length); + const state = this.#workers[workerIndex]; + if (!state) { + return executeRouteTransformTask(task, this.options); + } + + const id = this.#nextId++; + return new Promise((resolve, reject) => { + state.pending.set(id, { resolve, reject }); + state.worker.postMessage({ id, task }); + }); + } +} + +export const createRouteTransformExecutor = ({ + parallelTransforms, + routeChunkCache, +}: RouteTransformExecutorOptions = {}): RouteTransformExecutor => { + const options = { routeChunkCache }; + if (!parallelTransforms) { + return { + run: task => executeRouteTransformTask(task, options), + close: async () => {}, + }; + } + + const workerCount = getConfiguredWorkerCount(parallelTransforms); + return new ParallelRouteTransformExecutor(workerCount, options); +}; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts new file mode 100644 index 0000000..d2beb7f --- /dev/null +++ b/src/route-transform-tasks.ts @@ -0,0 +1,358 @@ +import { existsSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; +import { generate, parse } from './babel.js'; +import { + JS_EXTENSIONS, + PLUGIN_NAME, + SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET, +} from './constants.js'; +import { + getBundlerRouteAnalysis, + getRouteModuleAnalysis, +} from './export-utils.js'; +import { + removeExports, + removeUnusedImports, + transformRoute, +} from './plugin-utils.js'; +import { + createRouteChunkArtifact, + createRouteClientEntryArtifact, +} from './route-artifacts.js'; +import { + getRouteChunkModuleId, + type RouteChunkCache, + type RouteChunkConfig, +} from './route-chunks.js'; + +export type RouteTransformResult = { + code: string; + map?: any; +}; + +type BaseRouteTransformTask = { + code: string; + resourcePath: string; +}; + +export type RouteClientEntryTransformTask = BaseRouteTransformTask & { + kind: 'routeClientEntry'; + environmentName?: string; + isBuild: boolean; + routeChunkConfig: RouteChunkConfig; +}; + +export type RouteChunkTransformTask = BaseRouteTransformTask & { + kind: 'routeChunk'; + resource: string; + isBuild: boolean; + routeChunkConfig: RouteChunkConfig; +}; + +export type SplitRouteExportsTransformTask = BaseRouteTransformTask & { + kind: 'splitRouteExports'; + routeChunkConfig: RouteChunkConfig; +}; + +export type ClientOnlyStubTransformTask = BaseRouteTransformTask & { + kind: 'clientOnlyStub'; +}; + +export type RouteModuleTransformTask = BaseRouteTransformTask & { + kind: 'routeModule'; + resource: string; + environmentName: string; + ssr: boolean; + isSpaMode: boolean; + rootRoutePath: string | null; +}; + +export type RouteTransformTask = + | RouteClientEntryTransformTask + | RouteChunkTransformTask + | SplitRouteExportsTransformTask + | ClientOnlyStubTransformTask + | RouteModuleTransformTask; + +export type RouteTransformTaskOptions = { + routeChunkCache?: RouteChunkCache; +}; + +const defaultRouteChunkCache: RouteChunkCache = new Map(); + +const getRouteChunkCache = (options?: RouteTransformTaskOptions) => + options?.routeChunkCache ?? defaultRouteChunkCache; + +const splitRouteExports = async ( + task: SplitRouteExportsTransformTask, + options?: RouteTransformTaskOptions +): Promise => { + const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); + const { hasRouteChunks, chunkedExports } = await analysis.getRouteChunkInfo( + getRouteChunkCache(options), + task.routeChunkConfig + ); + if (!hasRouteChunks) { + return { code: task.code, map: null }; + } + + const sourceExports = analysis.exportNames; + const chunkedExportSet = new Set(chunkedExports); + const mainChunkReexports = sourceExports + .filter(name => !chunkedExportSet.has(name)) + .join(', '); + const chunkBasePath = `./${pathBasename(task.resourcePath)}`; + + return { + code: [ + mainChunkReexports + ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( + chunkBasePath, + 'main' + )}";` + : null, + ...chunkedExports.map( + exportName => + `export { ${exportName} } from "${getRouteChunkModuleId( + chunkBasePath, + exportName + )}";` + ), + ] + .filter(Boolean) + .join('\n'), + map: null, + }; +}; + +const resolveIndexFile = (dirPath: string): string | null => { + for (const ext of JS_EXTENSIONS) { + const candidate = resolve(dirPath, `index${ext}`); + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + continue; + } + } + return null; +}; + +const resolvePathWithExtensions = (basePath: string): string | null => { + if (existsSync(basePath)) { + try { + const stats = statSync(basePath); + if (stats.isFile()) { + return basePath; + } + if (stats.isDirectory()) { + return resolveIndexFile(basePath); + } + } catch { + // Ignore invalid paths and fall back to extension probing. + } + } + + for (const ext of JS_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + continue; + } + } + + return resolveIndexFile(basePath); +}; + +const resolveExportAllModule = ( + specifier: string, + importerPath: string +): string | null => { + if (specifier.startsWith('.') || specifier.startsWith('/')) { + const basePath = specifier.startsWith('/') + ? specifier + : resolve(dirname(importerPath), specifier); + const resolvedPath = resolvePathWithExtensions(basePath); + if (resolvedPath) { + return resolvedPath; + } + } + + try { + const resolver = createRequire(pathToFileURL(importerPath).href); + return resolver.resolve(specifier); + } catch { + return null; + } +}; + +const createClientOnlyStub = async ( + task: ClientOnlyStubTransformTask +): Promise => { + const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); + const { exportNames: directExportNames, exportAllModules } = analysis; + const exportNames = new Set(directExportNames); + const unresolvedExportAll = new Set(); + const visitedModules = new Set(); + + const collectExportNamesFromModule = async ( + modulePath: string + ): Promise => { + if (visitedModules.has(modulePath)) { + return; + } + visitedModules.add(modulePath); + const { + exports: moduleExportNames, + exportAllModules: moduleExportAll, + } = await getRouteModuleAnalysis(modulePath); + for (const name of moduleExportNames) { + if (name !== 'default') { + exportNames.add(name); + } + } + for (const nestedSpecifier of moduleExportAll) { + const nestedPath = resolveExportAllModule(nestedSpecifier, modulePath); + if (!nestedPath) { + unresolvedExportAll.add(nestedSpecifier); + continue; + } + await collectExportNamesFromModule(nestedPath); + } + }; + + for (const specifier of exportAllModules) { + const resolvedPath = resolveExportAllModule(specifier, task.resourcePath); + if (!resolvedPath) { + unresolvedExportAll.add(specifier); + continue; + } + await collectExportNamesFromModule(resolvedPath); + } + + if (unresolvedExportAll.size > 0) { + throw new Error( + `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + + `unresolvable specifier(s): ${Array.from(unresolvedExportAll) + .map(spec => `\`${spec}\``) + .join(', ')}. ` + + `Please explicitly re-export named bindings in ` + + `\`${relative(process.cwd(), task.resourcePath)}\`.` + ); + } + + return { + code: Array.from(exportNames) + .map(name => + name === 'default' + ? 'export default undefined;' + : `export const ${name} = undefined;` + ) + .join('\n'), + map: null, + }; +}; + +const transformRouteModule = async ( + task: RouteModuleTransformTask +): Promise => { + const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); + let code = analysis.code; + + if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) { + const resolvedExportNames = analysis.exportNames; + const isRootRoute = task.resourcePath === task.rootRoutePath; + const relativePath = relative(process.cwd(), task.resourcePath); + + const invalidServerOnly = resolvedExportNames.filter(exp => { + if (isRootRoute && exp === 'loader') return false; + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); + }); + + if (invalidServerOnly.length > 0) { + const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); + throw new Error( + `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + + `\`${relativePath}\`: ${list}. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + + if (!isRootRoute && resolvedExportNames.includes('HydrateFallback')) { + throw new Error( + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${relativePath}\`. ` + + `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + } + + const defaultExportMatch = code.match(/\n\s{0,}([\w\d_]+)\sas default,?/); + if (defaultExportMatch && typeof defaultExportMatch.index === 'number') { + code = + code.slice(0, defaultExportMatch.index) + + code.slice(defaultExportMatch.index + defaultExportMatch[0].length); + code += `\nexport default ${defaultExportMatch[1]};`; + } + + const ast = parse(code, { sourceType: 'module' }); + if (task.environmentName === 'web') { + removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); + } + transformRoute(ast); + if (task.environmentName === 'web') { + removeUnusedImports(ast); + } + + return generate(ast, { + sourceMaps: true, + filename: task.resource, + sourceFileName: task.resourcePath, + }); +}; + +export const executeRouteTransformTask = async ( + task: RouteTransformTask, + options?: RouteTransformTaskOptions +): Promise => { + switch (task.kind) { + case 'routeClientEntry': + return createRouteClientEntryArtifact({ + code: task.code, + resourcePath: task.resourcePath, + environmentName: task.environmentName, + isBuild: task.isBuild, + routeChunkCache: getRouteChunkCache(options), + routeChunkConfig: task.routeChunkConfig, + }); + case 'routeChunk': + return createRouteChunkArtifact({ + code: task.code, + resource: task.resource, + resourcePath: task.resourcePath, + isBuild: task.isBuild, + routeChunkCache: getRouteChunkCache(options), + routeChunkConfig: task.routeChunkConfig, + }); + case 'splitRouteExports': + return splitRouteExports(task, options); + case 'clientOnlyStub': + return createClientOnlyStub(task); + case 'routeModule': + return transformRouteModule(task); + } +}; diff --git a/src/types.ts b/src/types.ts index a8cac36..ff59892 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,19 @@ export type PluginOptions = { * @default false */ logPerformance?: boolean; + + /** + * Run CPU-heavy route transforms in a worker-thread pool. + * + * Set to `true` to use an automatically sized pool, or pass + * `{ maxWorkers }` to cap the pool size. + * @default false + */ + parallelTransforms?: + | boolean + | { + maxWorkers?: number; + }; }; export type RouteManifestItem = Omit & { diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts index 83a6e0b..095ddb8 100644 --- a/tests/benchmark-fixture.test.ts +++ b/tests/benchmark-fixture.test.ts @@ -75,6 +75,30 @@ describe('benchmark fixture generator', () => { } }); + it('can enable parallel route transforms in benchmark config', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + parallelTransforms: { maxWorkers: 3 }, + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(result.parallelTransforms).toEqual({ maxWorkers: 3 }); + expect(rsbuildConfig).toContain( + 'parallelTransforms: { maxWorkers: 3 },' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('omits server-only route exports from SPA benchmark fixtures', async () => { const { generateSyntheticFixture } = await import( '../scripts/benchmark/fixture.mjs' @@ -191,6 +215,7 @@ describe('benchmark fixture generator', () => { '--iterations=1', '--warmup=0', '--filter=missing', + '--parallel-transforms=true', '--skip-root-build', ], { diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts new file mode 100644 index 0000000..967b440 --- /dev/null +++ b/tests/parallel-route-transforms.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@rstest/core'; +import { executeRouteTransformTask } from '../src/route-transform-tasks'; +import { createRouteTransformExecutor } from '../src/parallel-route-transforms'; +import type { RouteChunkConfig } from '../src/route-chunks'; + +const routeChunkConfig: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const disabledRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: false, +}; + +const resourcePath = '/app/routes/demo.tsx'; + +describe('parallel route transforms', () => { + it('executes route client entry tasks through the shared task executor', async () => { + await expect( + executeRouteTransformTask({ + kind: 'routeClientEntry', + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + }) + ).resolves.toEqual({ + code: `export { clientLoader, default } from "${resourcePath}?react-router-route";`, + }); + }); + + it('can execute route module tasks through worker-backed parallelism', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + }); + + try { + const result = await executor.run({ + kind: 'routeModule', + code: ` + import { serverValue } from '../server-data.server'; + export async function loader() { return serverValue; } + export default function Route() { return null; } + `, + resource: `${resourcePath}?react-router-route`, + resourcePath, + environmentName: 'web', + ssr: true, + isSpaMode: false, + rootRoutePath: '/app/root.tsx', + }); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index c8ea6b0..d86c4b1 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -111,6 +111,7 @@ rstest.mock('@scripts/test-helper', () => ({ processAssets: rstest.fn(), onBeforeStartDevServer: rstest.fn(), onCloseDevServer: rstest.fn(), + onCloseBuild: rstest.fn(), onBeforeBuild: rstest.fn(), onAfterBuild: rstest.fn(), getNormalizedConfig: rstest.fn().mockImplementation(() => mergedConfig), From 45ab9bcc617ad8364646f751e7181ad5c31ebf5b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:13:22 +0200 Subject: [PATCH 25/30] Remove planning artifacts from bundling performance branch (#44) --- .changeset/fast-routes-dance.md | 5 - README.md | 121 +++- benchmarks/README.md | 14 + benchmarks/chunk-precompute-methodology.md | 370 ----------- .../manifest-performance-methodology.md | 103 +-- .../default-template/playwright.config.ts | 6 +- package.json | 1 + performance-timing-semantics-analysis.md | 149 ----- pnpm-lock.yaml | 50 +- route-analysis-duplication-audit.md | 356 ----------- route-chunk-parse-traverse-analysis.md | 244 ------- scripts/bench-builds.mjs | 185 +++++- scripts/benchmark/fixture.mjs | 13 +- src/babel.ts | 21 +- src/export-utils.ts | 22 +- src/index.ts | 152 +++-- src/manifest.ts | 155 +++-- src/modify-browser-manifest.ts | 18 +- src/parallel-route-transform-worker.ts | 75 ++- src/parallel-route-transforms.ts | 248 +++++++- src/performance.ts | 70 +- src/plugin-utils.ts | 180 +++++- src/route-artifacts.ts | 35 +- src/route-chunks.ts | 319 ++++++---- src/route-transform-tasks.ts | 115 ++-- src/types.ts | 18 +- task/lexer-route-export-triage.md | 208 ------ task/route-chunk-correctness-test-spec.md | 437 ------------- task/route-chunk-precompute-plan.md | 321 ---------- ...fied-route-module-analysis-cache-triage.md | 598 ------------------ tests/benchmark-fixture.test.ts | 37 +- tests/export-utils.test.ts | 3 +- tests/features.test.ts | 31 +- tests/index.test.ts | 111 ++++ tests/manifest.test.ts | 126 +++- tests/modify-browser-manifest.test.ts | 184 ++++++ tests/parallel-route-transforms.test.ts | 404 +++++++++++- tests/performance.test.ts | 109 +++- tests/plugin-utils.test.ts | 35 + tests/remove-exports.test.ts | 32 + tests/route-artifacts.test.ts | 93 ++- tests/route-chunks-cache.test.ts | 27 +- tests/route-chunks.test.ts | 56 +- tests/setup.ts | 17 + 44 files changed, 2562 insertions(+), 3312 deletions(-) delete mode 100644 .changeset/fast-routes-dance.md delete mode 100644 benchmarks/chunk-precompute-methodology.md delete mode 100644 performance-timing-semantics-analysis.md delete mode 100644 route-analysis-duplication-audit.md delete mode 100644 route-chunk-parse-traverse-analysis.md delete mode 100644 task/lexer-route-export-triage.md delete mode 100644 task/route-chunk-correctness-test-spec.md delete mode 100644 task/route-chunk-precompute-plan.md delete mode 100644 task/unified-route-module-analysis-cache-triage.md create mode 100644 tests/modify-browser-manifest.test.ts diff --git a/.changeset/fast-routes-dance.md b/.changeset/fast-routes-dance.md deleted file mode 100644 index 6555d81..0000000 --- a/.changeset/fast-routes-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"rsbuild-plugin-react-router": patch ---- - -Reduce route analysis and route chunking overhead by reusing transformed export metadata and cached route chunk analysis. diff --git a/README.md b/README.md index 65a0a18..31d39ff 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ A Rsbuild plugin that provides seamless integration with React Router, supportin ## Features - - 🚀 Zero-config setup with sensible defaults - 🔄 Automatic route generation from file system - 🖥️ Server-Side Rendering (SSR) support @@ -58,11 +57,11 @@ export default defineConfig(() => { // Optional: Enable custom server mode customServer: false, // Optional: Specify server output format - serverOutput: "commonjs", + serverOutput: 'commonjs', // Optional: enable experimental support for module federation - federation: false - }), - pluginReact() + federation: false, + }), + pluginReact(), ], }; }); @@ -73,6 +72,7 @@ export default defineConfig(() => { The plugin uses a two-part configuration system: 1. **Plugin Options** (in `rsbuild.config.ts`): + ```ts pluginReactRouter({ /** @@ -87,7 +87,27 @@ pluginReactRouter({ * Options: "commonjs" | "module" * @default "module" */ - serverOutput?: "commonjs" | "module" + serverOutput?: "commonjs" | "module", + + /** + * Rsbuild dev-only lazy compilation behavior. + * @default false + */ + lazyCompilation?: boolean | Rspack.LazyCompilationOptions, + + /** + * Emit structured React Router plugin timing logs. + * @default false + */ + logPerformance?: boolean, + + /** + * Run route transforms in a worker-thread pool. + * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. + * @default true, inline for small route graphs or low-core CPUs; otherwise `available CPUs - 2`, capped at 8 workers, 6 workers for known large route graphs, or 2 workers for split builds and 1024+ route graphs. + */ + parallelTransforms?: boolean | { maxWorkers?: number }, + /** * Enable experimental support for module federation * @default false @@ -106,6 +126,7 @@ passing the build to React Router's request handler. ``` 2. **React Router Configuration** (in `react-router.config.*`): + ```ts import type { Config } from '@react-router/dev/config'; @@ -120,19 +141,19 @@ export default { * The file name for the server build output. * @default "index.js" */ - serverBuildFile: "index.js", + serverBuildFile: 'index.js', /** * The output format for the server build. * Options: "esm" | "cjs" * @default "esm" */ - serverModuleFormat: "esm", + serverModuleFormat: 'esm', /** * Split server bundles by route branch (advanced). */ - serverBundles: async ({ branch }) => branch[0]?.id ?? "main", + serverBundles: async ({ branch }) => branch[0]?.id ?? 'main', /** * Hook called after the build completes. @@ -262,7 +283,7 @@ For large sites, you can tune prerender concurrency: export default { ssr: false, prerender: { - paths: ['/','/about'], + paths: ['/', '/about'], unstable_concurrency: 4, }, } satisfies Config; @@ -275,7 +296,12 @@ If no configuration is provided, the following defaults will be used: ```ts // Plugin defaults (rsbuild.config.ts) { - customServer: false + customServer: false, + serverOutput: 'module', + federation: false, + lazyCompilation: false, + logPerformance: false, + parallelTransforms: true // adaptive worker pool } // Router defaults (react-router.config.ts) @@ -287,6 +313,19 @@ If no configuration is provided, the following defaults will be used: } ``` +`parallelTransforms: true` uses worker threads for large route builds. The default +worker count is `availableParallelism - 2`, capped at 8 workers. Known large +route graphs cap at 6 workers; split builds and 1024+ route graphs cap at 2 +workers. + +For builds with 256+ routes, detailed file-size reporting is compacted to totals +by default to avoid gzipping and printing thousands of assets. Set +`performance.printFileSize` to an object to customize that output. + +Route transform source maps are generated in development only. If you enable +Rsbuild source maps for faster local debugging, prefer a cheap JS map: +`output.sourceMap: { js: 'cheap-module-source-map', css: false }`. + ### Route Configuration Routes can be defined in `app/routes.ts` using the helper functions from `@react-router/dev/routes`: @@ -326,6 +365,7 @@ export default [ ``` The plugin provides several helper functions for defining routes: + - `index()` - Creates an index route - `route()` - Creates a regular route with a path - `layout()` - Creates a layout route with nested children @@ -336,6 +376,7 @@ The plugin provides several helper functions for defining routes: Route components support the following exports: #### Client-side Exports + - `default` - The route component - `ErrorBoundary` - Error boundary component - `HydrateFallback` - Loading component during hydration @@ -349,6 +390,7 @@ Route components support the following exports: - `shouldRevalidate` - Revalidation control #### Server-side Exports + - `loader` - Server-side data loading - `action` - Server-side form actions - `middleware` - Server-side middleware @@ -387,9 +429,9 @@ export default defineConfig(() => { return { plugins: [ pluginReactRouter({ - customServer: true - }), - pluginReact() + customServer: true, + }), + pluginReact(), ], }; }); @@ -398,6 +440,7 @@ export default defineConfig(() => { When using a custom server, you'll need to: 1. Create a server handler (`server/index.ts`): + ```ts import { createRequestHandler } from '@react-router/express'; @@ -413,6 +456,7 @@ export const app = createRequestHandler({ ``` 2. Set up your server entry point (`server.js`): + ```js import { createRsbuild, loadConfig } from '@rsbuild/core'; import express from 'express'; @@ -451,9 +495,11 @@ async function startServer() { devServer.connectWebSocket({ server }); } else { // Production mode - app.use(express.static(path.join(__dirname, 'build/client'), { - index: false - })); + app.use( + express.static(path.join(__dirname, 'build/client'), { + index: false, + }) + ); // Load the server bundle const serverBundle = await import('./build/server/static/js/app.js'); @@ -477,6 +523,7 @@ startServer().catch(console.error); ``` 3. Update your `package.json` scripts: + ```json { "scripts": { @@ -488,6 +535,7 @@ startServer().catch(console.error); ``` The custom server setup allows you to: + - Add custom middleware - Handle API routes - Integrate with databases @@ -500,6 +548,7 @@ The custom server setup allows you to: To deploy your React Router app to Cloudflare Workers: 1. **Configure Rsbuild** (`rsbuild.config.ts`): + ```ts import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; @@ -524,17 +573,24 @@ export default defineConfig({ module: true, }, resolve: { - conditionNames: ['workerd', 'worker', 'browser', 'import', 'require'], + conditionNames: [ + 'workerd', + 'worker', + 'browser', + 'import', + 'require', + ], }, }, }, }, }, - plugins: [pluginReactRouter({customServer: true}), pluginReact()], + plugins: [pluginReactRouter({ customServer: true }), pluginReact()], }); ``` 2. **Configure Wrangler** (`wrangler.toml`): + ```toml workers_dev = true name = "my-react-router-worker" @@ -552,6 +608,7 @@ VALUE_FROM_CLOUDFLARE = "Hello from Cloudflare" ``` 3. **Create Worker Entry** (`server/index.ts`): + ```ts import { createRequestHandler } from 'react-router'; @@ -588,6 +645,7 @@ export default { ``` 4. **Update Package Dependencies**: + ```json { "dependencies": { @@ -605,6 +663,7 @@ export default { ``` 5. **Setup Deployment Scripts** (`package.json`): + ```json { "scripts": { @@ -630,6 +689,7 @@ export default { ### Development Workflow: 1. Local Development: + ```bash # Start local development server npm run dev @@ -646,6 +706,7 @@ export default { ## Development The plugin automatically: + - Runs type generation during development and build - Sets up development server with live reload - Handles route-based code splitting @@ -667,17 +728,17 @@ CSS endpoint) are not supported 1:1. The repository includes several examples demonstrating different use cases: -| Example | Description | Port | Command | -|---------|-------------|------|---------| -| [default-template](./examples/default-template) | Standard SSR setup with React Router | 3000 | `pnpm dev` | -| [spa-mode](./examples/spa-mode) | Single Page Application (`ssr: false`) | 3001 | `pnpm dev` | -| [prerender](./examples/prerender) | Static prerendering for multiple routes | 3002 | `pnpm dev` | -| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | -| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | -| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | -| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | -| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | -| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | +| Example | Description | Port | Command | +| ----------------------------------------------------------------------- | --------------------------------------- | ---- | ---------- | +| [default-template](./examples/default-template) | Standard SSR setup with React Router | 3000 | `pnpm dev` | +| [spa-mode](./examples/spa-mode) | Single Page Application (`ssr: false`) | 3001 | `pnpm dev` | +| [prerender](./examples/prerender) | Static prerendering for multiple routes | 3002 | `pnpm dev` | +| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | +| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | +| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | +| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | +| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | +| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | Each example has unique ports configured to allow running multiple examples simultaneously. diff --git a/benchmarks/README.md b/benchmarks/README.md index 16918fb..8cb14a0 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -20,6 +20,20 @@ All benchmark profiles generate deterministic synthetic React Router apps under `.benchmark/fixtures/`, build the current plugin package once, then run Rsbuild builds with `pluginReactRouter({ logPerformance: true })`. +To capture Rspack tracing output for a benchmark, pass `--rspack-profile`: + +```sh +node scripts/bench-builds.mjs --profile=smoke --iterations=1 --warmup=0 --rspack-profile=OVERVIEW +node scripts/bench-builds.mjs --profile=full --filter=synthetic-1024 --iterations=1 --warmup=0 --rspack-profile=ALL +``` + +Trace directories are moved from fixture roots into +`.benchmark/results//rspack-profiles/` and referenced from the JSON +result. `ALL` can produce large traces; use it for targeted runs. +When `--rspack-trace-output` is provided, the benchmark writes one absolute +trace file per run under that directory so Rsbuild does not resolve the path +inside each generated `.rspack-profile-*` directory. + ## Baseline Shape The synthetic fixture keeps app behavior simple and scales route count/export diff --git a/benchmarks/chunk-precompute-methodology.md b/benchmarks/chunk-precompute-methodology.md deleted file mode 100644 index 29859a2..0000000 --- a/benchmarks/chunk-precompute-methodology.md +++ /dev/null @@ -1,370 +0,0 @@ -# Benchmark Methodology: Precomputed `RouteChunkAnalysis` vs Per-Query/Per-Export Babel - -This document defines the exact commands, fixtures, metrics, and comparison -procedure to evaluate replacing the current **lazy per-query / per-export** -Babel parse→traverse→generate behavior with a **precomputed -`RouteChunkAnalysis`** approach for route module splitting -(`future.v8_splitRouteModules`). - -It is the methodology reference for downstream implementation tasks. No code -changes are required to run the **baseline** half; the **precompute** half needs -the implementation behind a toggle before its commands produce numbers. - ---- - -## 1. What we are comparing - -### Current behavior (lazy, per-query / per-export) - -Source of truth: `src/route-chunks.ts`, `src/index.ts`, `src/manifest.ts`. - -When `v8_splitRouteModules` is enabled, each route module is analyzed lazily -and redundantly across the build lifecycle: - -| Call site | Operation name | What it triggers | -| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ | -| `route:client-entry` transform (`index.ts:1383`) | `route:client-entry` | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | -| `route:split-exports` transform (`index.ts:1509`) | `route:split-exports` | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | -| manifest build (`manifest.ts:204`) | (inside manifest staging) | `detectRouteChunksIfEnabled` → `hasChunkableExport` × 4 exports | -| `?route-chunk=` query transform (`index.ts:1446`) | `route:chunk` | `getRouteChunkIfEnabled` → `getChunkedExport`/`omitChunkedExports` | - -Each `hasChunkableExport(name)` → `getExportDependencies()` → `codeToAst()` -(**Babel parse**) + `traverse()`. Each chunk extraction additionally calls -`generate()` and re-`codeToAst()`. - -The `RouteChunkCache` (`Map` keyed by `cacheKey::suffix`, versioned by the raw -code string) memoizes within a single build, so the _first_ call per -`(module, op)` pays the parse/traverse and subsequent calls hit the cache. -**However** `codeToAst()` runs `structuredClone(...)` on **every** access, -including cache hits (`route-chunks.ts:93`), which is O(AST size). There are -also up to 5 `?route-chunk=` queries per splittable route (`main` + 4 client -exports), each a separate lazy entry point. - -### Proposed behavior (precomputed `RouteChunkAnalysis`) - -Parse **once**, traverse **once**, and in a single coordinated pass per route -module compute: - -1. which of the 4 client exports are independently chunkable, and -2. the generated code string for every chunk (`main`, `clientAction`, - `clientLoader`, `clientMiddleware`, `HydrateFallback`) that is actually - present. - -The result is a single `RouteChunkAnalysis` object cached once per module; all -downstream call sites (`route:client-entry`, `route:split-exports`, manifest, -and each `?route-chunk=` query) read from it instead of re-entering the Babel -pipeline. This eliminates the repeated `structuredClone` and the redundant -`getExportDependencies` traversals across call sites. - -> The implementation lives behind a toggle so both halves can be measured on -> the same commit (see §3). - ---- - -## 2. Representative route modules (fixtures) - -Use the existing synthetic fixture generator (`scripts/benchmark/fixture.mjs`). -It produces deterministic route modules across a fixed export profile cycle: - -``` -['plain', 'ssr-data', 'split-client', 'split-client', 'ssr-data', 'client-server-imports'] -``` - -Only `split-client` and `client-server-imports` profiles emit client exports -(`clientAction`, `clientLoader`, `clientMiddleware`, `HydrateFallback`) — i.e. -**4 of every 6 routes (~67%) are splittable**. `plain` and `ssr-data` routes -exercise the early-exit fast path (`code.includes(exportName)` guard at -`route-chunks.ts:863`). This mix already represents the realistic distribution. - -**Why this is representative:** - -- `split-client`: all 4 client exports + a `.client` import — the worst case for - `generate()` (5 queries: main + 4 chunks). -- `client-server-imports`: mixed `.client`/`.server` imports — exercises import - specifier filtering in `omitChunkedExports`/`getChunkedExport`. -- `plain`/`ssr-data`: non-splittable, measuring the fast-path / early-exit cost - the precompute must not regress. - -The only variant that exercises the route-chunk code path is **`ssr-esm-split`** -(`v8_splitRouteModules: true`, web/client environment). The non-split `ssr-esm` -variant is the **control** — it must show no measurable difference between -baseline and precompute, confirming the toggle is inert when splitting is off. - -### Route counts - -| Count | Purpose | -| ----- | ----------------------------------------------------- | -| 48 | smoke / correctness | -| 256 | primary comparison (default profile scale) | -| 1024 | stress / scaling (does precompute win grow linearly?) | - ---- - -## 3. Toggle for A/B comparison - -The precompute implementation **must** be gated behind an opt-in so the same -commit can produce both halves of the comparison. Two acceptable shapes: - -- **Env var** (simplest, no public API surface): - `ROUTE_CHUNK_PRECOMPUTE=1` → precompute path; unset/`0` → current lazy path. -- **Future flag** under `pluginReactRouter({ future: { v8_routeChunkPrecompute } })`. - -The fixture generator's `rsbuild.config.mjs` and the bench harness pass this -through via the build environment. The methodology commands below assume the -**env var** shape; if a future flag is used instead, substitute the config -knob. - ---- - -## 4. Exact commands - -All commands run from the repo root -(`/home/zack/projects/rsbuild-plugin-react-router`). GNU `time` (`/usr/bin/time --v`) is present and is auto-detected by the harness. - -### 4.1 Pre-flight (once per session) - -```sh -git status --short # confirm clean tree -pnpm install # ensure node_modules -pnpm build # build dist/ (harness builds it once anyway) -node --version # record Node version (v22.x here) -``` - -### 4.2 End-to-end build benchmark (primary comparison) - -This exercises the **full plugin** under a real Rsbuild production build — the -ground-truth measurement. It reuses `scripts/bench-builds.mjs` and the -`--filter` flag to isolate the split variant. - -Run the **full `default` profile** for each toggle value. The emitted JSON -contains all four variants in one file, so you compare the -`synthetic-256-ssr-esm-split` row (the code path that changes) **and** the -`synthetic-256-ssr-esm` row (the non-split control) from the same run — no -filtering needed. Avoid `--filter` for the control: the harness uses substring -matching (`benchmark.id.includes(filter)`), so `"synthetic-256-ssr-esm"` also -matches the `-split` variant. - -**Baseline (current lazy behavior):** - -```sh -ROUTE_CHUNK_PRECOMPUTE=0 pnpm bench:baseline \ - --profile default \ - --iterations 8 --warmup 2 \ - --clean build \ - --format both \ - --out .benchmark/results/lazy -``` - -**Precompute:** - -```sh -ROUTE_CHUNK_PRECOMPUTE=1 pnpm bench:baseline \ - --profile default \ - --iterations 8 --warmup 2 \ - --clean build \ - --format both \ - --out .benchmark/results/precompute -``` - -To save time when iterating, you may scope a single run to the split variant -with `--filter split` (matches only `synthetic-256-ssr-esm-split`), but the -definitive comparison uses the full profile so the control is captured -alongside. - -### 4.3 Scaling sweep (does the win grow with route count?) - -Use the `full` profile filtered to the split variant, which adds the 1024-route -fixture: - -```sh -for PRECOMPUTE in 0 1; do - ROUTE_CHUNK_PRECOMPUTE=$PRECOMPUTE pnpm bench:full \ - --profile full --filter split \ - --iterations 5 --warmup 1 \ - --clean build \ - --out .benchmark/results/scale-precompute-$PRECOMPUTE -done -``` - -### 4.4 Isolated micro-benchmark (parse/traverse/generate counts) - -The end-to-end build bundles the route-chunk Babel work inside the -`route:client-entry`, `route:chunk`, and `route:split-exports` operation -buckets. To attribute cost **directly** to the analysis (independent of Rspack -overhead), add a standalone micro-benchmark that imports the analysis -functions and runs them over generated route modules in-process. - -Proposed script: `scripts/bench-chunk-analysis.mjs` (to be created by the -benchmark-implementation task). It imports from the built package: - -```js -import { generateSyntheticFixture } from './benchmark/fixture.mjs'; -// route-chunks internals are not part of the public API; import the public -// entrypoints detectRouteChunksIfEnabled / getRouteChunkIfEnabled from dist, -// OR export a bench-only analyzeRouteModule() from src for direct timing. -``` - -Run shape: - -```sh -node scripts/bench-chunk-analysis.mjs \ - --routes 256 --variant ssr-esm-split \ - --iterations 50 --warmup 5 \ - --mode lazy \ - --out .benchmark/results/micro-lazy.json - -node scripts/bench-chunk-analysis.mjs \ - --routes 256 --variant ssr-esm-split \ - --iterations 50 --warmup 5 \ - --mode precompute \ - --out .benchmark/results/micro-precompute.json -``` - -High iteration count (50) is appropriate here because each iteration is a pure -in-memory function call (no process spawn), so variance is low and 50 samples -give a tight p95. - ---- - -## 5. Metrics to capture - -### 5.1 From the end-to-end harness (already wired) - -The harness writes `baseline.json` + `baseline.md` containing: - -| Metric | Source | What it tells us | -| ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------ | -| `wallMs` (min/median/mean/p95/stdev) | `performance.now()` | total build time | -| `userMs` | `/usr/bin/time -v` "User time" | CPU time in user mode | -| `sysMs` | `/usr/bin/time -v` "System time" | CPU time in kernel | -| `maxRssKb` | `/usr/bin/time -v` "Maximum resident set size" | peak memory | -| `pluginOperations[].count` | `[react-router:performance]` reports | **parse/traverse invocation counts** (operation granularity) | -| `pluginOperations[].totalMs` | same | cumulative time per operation | -| `pluginOperations[].maxMs` | same | slowest single invocation | - -**CPU time** = `userMs + sysMs` (summarized independently, then added for the -comparison). This isolates plugin work from I/O / Rspack scheduling. - -**Parse/traverse counts**: the relevant operation buckets are `route:chunk`, -`route:client-entry`, and `route:split-exports`. Their `.count` fields, -summed, are the proxy for "how many times the Babel pipeline was entered per -route." The precompute path should reduce `route:chunk` and -`route:split-exports` totalMs without changing `.count` semantics (count stays -≈ routes, but totalMs drops), **unless** the implementation also adds a -dedicated `route:chunk-analyze` operation to expose the precompute pass -explicitly — then compare that new bucket's single-pass cost against the sum -of the old buckets. - -**Generated-code cost**: the `route:chunk` operation's `totalMs` is dominated -by `generate()` plus the AST surgery in `getChunkedExport`/`omitChunkedExports`. -Compare `route:chunk.totalMs` between lazy and precompute directly. - -### 5.2 From the micro-benchmark - -| Metric | How | -| ----------------------- | ------------------------------------------------------------ | -| `parse` calls | counter incremented in the `codeToAst` path | -| `traverse` calls | counter in `getExportDependencies` | -| `generate` calls | counter in `getChunkedExport`/`omitChunkedExports` | -| `structuredClone` calls | counter in `codeToAst` (the per-access clone) | -| analysis `totalMs` | `performance.now()` around the full analyze-all-modules loop | -| per-route `meanMs` | `totalMs / routeCount` | -| heap delta | `process.memoryUsage().heapUsed` before/after the loop | - -These direct counters are the cleanest evidence that precompute collapses N -parses into 1 and removes the repeated `structuredClone`. - -### 5.3 Memory impact - -Two views: - -- **Peak RSS** from the end-to-end harness (`maxRssKb.p95`) — includes Rspack, - so expect a small relative delta; use this for the user-facing "did peak - memory get worse" question. -- **Heap delta** from the micro-benchmark — isolates the analysis's own - retained memory (the precomputed `RouteChunkAnalysis` objects are held for - the build lifetime; quantify their size vs the lazy cache's transient - entries). - ---- - -## 6. Iterations and warmup - -| Benchmark | Warmup | Measured | Rationale | -| ------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------- | -| End-to-end (`bench:baseline`) | 2 | 8 | process spawn + Rspack JIT warmup dominate; 2 warmups stabilize, 8 samples give a usable p95/stdev | -| Scaling (`bench:full`) | 1 | 5 | 1024-route builds are slow; 5 samples balance time vs signal | -| Micro (`bench-chunk-analysis`) | 5 | 50 | in-memory, low variance; tight statistics needed to see sub-millisecond wins | - -Always use `--clean build` for end-to-end runs (removes `build/` and -`.react-router/` between iterations) so each iteration is a cold plugin pass, -not a cache-rebuild. Do **not** use `--clean cold` (deletes `node_modules`) for -performance runs — it measures `pnpm install`, not the plugin. - -Run both halves (lazy + precompute) **back-to-back on the same machine with no -other load**, and pin the same Node version. Record `git rev-parse HEAD` (the -harness embeds `commit` in the JSON output automatically). - ---- - -## 7. Comparison procedure - -### 7.1 End-to-end - -1. Load `.benchmark/results/lazy/baseline.json` and - `.benchmark/results/precompute/baseline.json`. -2. For the `synthetic-256-ssr-esm-split` benchmark, compare: - - `summary.userMs.median` + `summary.sysMs.median` → **CPU time delta** - - `summary.wallMs.median` → total build delta - - `summary.maxRssKb.p95` → memory delta - - `pluginOperations` where `operation ∈ {route:chunk, route:client-entry, -route:split-exports}`: `totalMs` and `maxMs` deltas. -3. Repeat for the 1024-route split fixture from the scaling run. -4. Confirm the **non-split control** (`ssr-esm`, no split) shows no statistically - meaningful difference (medians within ~1 stdev). If it diverges, the toggle - is leaking into the non-split path — that's a bug, not a result. - -### 7.2 Micro - -1. Load the two micro JSON files. -2. Compare absolute counters: `parse`, `traverse`, `generate`, - `structuredClone` call counts per route. Expected: precompute shows - `parse = routeCount` (1 per module) vs lazy's `parse ≤ 5×routeCount` and - `structuredClone` ≈ 0 (precompute keeps one AST, not re-cloning). -3. Compare `per-route meanMs` and `heap delta`. - -### 7.3 Reporting - -Produce a single comparison table: - -``` -| Metric (256 routes, split) | Lazy | Precompute | Δ | -|-----------------------------------|-----------|------------|----------| -| CPU time median (s) | ... | ... | ...% | -| Wall median (s) | ... | ... | ...% | -| Peak RSS p95 (MB) | ... | ... | ...% | -| route:chunk totalMs | ... | ... | ...% | -| route:split-exports totalMs | ... | ... | ...% | -| micro: parse calls / route | ... | ... | ...% | -| micro: traverse calls / route | ... | ... | ...% | -| micro: generate calls / route | ... | ... | ...% | -| micro: structuredClone / route | ... | ... | ...% | -| micro: analyze mean ms / route | ... | ... | ...% | -| micro: heap delta (MB) | ... | ... | ...% | -``` - -Fill from real runs. A result is a **win** if CPU time and `route:chunk` -totalMs drop with no peak-RSS regression beyond the retained -`RouteChunkAnalysis` heap cost (quantified separately). - ---- - -## 8. Hygiene - -- Benchmark output lives under gitignored `.benchmark/`. Never commit results. -- Clean generated data with `rm -rf .benchmark/` — **not** `git clean -fdX`, - which also deletes `node_modules/` and `.tracedecay/` indexes. -- Start and end every comparison session with `git status --short`. -- Keep the fixture generator deterministic (no `Date.now()` / `Math.random()` - in route content) so lazy vs precompute run against byte-identical inputs. diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md index 86233eb..6535646 100644 --- a/benchmarks/manifest-performance-methodology.md +++ b/benchmarks/manifest-performance-methodology.md @@ -1,9 +1,5 @@ # Manifest-generation performance benchmark recipe -Task: `t_6008a898` -Repo: `/home/zack/projects/rsbuild-plugin-react-router` -Head measured: `c2452de1393264c2b01ef8aa03908077bce025db` - This document defines the reproducible commands and metric checklist for measuring manifest-generation performance before and after the route-analysis / manifest cache deduplication work. @@ -13,18 +9,14 @@ manifest cache deduplication work. Use the same machine, branch, package manager, and Node version for both halves of an A/B comparison. -Measured head environment: +Record environment details for each run: -- Branch: `perf/bundling-performance` -- Commit: `c2452de1393264c2b01ef8aa03908077bce025db` -- Node: `v22.22.3` -- pnpm: `9.15.3` -- Platform: `linux 6.8.0-124-generic x64` -- Rsbuild: `@rsbuild/core@2.0.15` -- Rspack: `@rspack/core@2.0.8` -- React Router packages: `7.13.0` -- Benchmark fixture size used for the baseline below: 256 routes plus the root - route, so route-level transforms report 257 calls per compiler environment. +- Branch and commit +- Node and pnpm versions +- Platform +- Rsbuild and Rspack versions +- React Router package versions +- Benchmark fixture size Fixture export-shape cycle from `scripts/benchmark/fixture.mjs`: @@ -74,8 +66,6 @@ truth. If low-level Rspack stats are needed later, add them through fixture Run from the repo root: ```sh -cd /home/zack/projects/rsbuild-plugin-react-router - git status --short git rev-parse HEAD node --version @@ -164,7 +154,7 @@ The harness command for each fixture build is: cd .benchmark/fixtures/synthetic-256-ssr-esm-split REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1 NODE_ENV=production \ /usr/bin/time -v \ - node /home/zack/projects/rsbuild-plugin-react-router/node_modules/@rsbuild/core/bin/rsbuild.js \ + node node_modules/@rsbuild/core/bin/rsbuild.js \ build --config rsbuild.config.mjs --log-level info ``` @@ -229,64 +219,9 @@ transform invocation counts for the same fixture. If `pluginOperations[].count` changes, explain why the module graph changed; otherwise compare `totalMs`, `maxMs`, and direct counters. -## Head baseline recorded on `c2452de` - -Command used: - -```sh -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 5 \ - --warmup 1 \ - --clean build \ - --format both \ - --out .benchmark/results/manifest-head-baseline -``` - -Output files: - -- `.benchmark/results/manifest-head-baseline/baseline.json` -- `.benchmark/results/manifest-head-baseline/baseline.md` - -Top-level summary: - -| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | p95 RSS | -| --------------------------- | -----: | ------------- | ----------: | --------: | -------: | ------: | -| synthetic-256-ssr-esm | 256 | ssr-esm | 1.56s | 1.58s | 1.67s | 485 MB | -| synthetic-256-ssr-esm-split | 256 | ssr-esm-split | 2.07s | 2.10s | 2.16s | 704 MB | -| synthetic-256-spa | 256 | spa | 6.53s | 6.56s | 6.62s | 476 MB | -| synthetic-256-sourcemaps | 256 | ssr-esm | 1.62s | 1.63s | 1.69s | 529 MB | - -Compiler lifecycle medians from the plugin reports: - -| Benchmark | web median | node median | -| --------------------------- | ---------: | ----------: | -| synthetic-256-ssr-esm | 1124.6ms | 1308.3ms | -| synthetic-256-ssr-esm-split | 1591.5ms | 1770.3ms | -| synthetic-256-spa | 1082.0ms | 1246.4ms | -| synthetic-256-sourcemaps | 1154.4ms | 1348.0ms | - -### Operation counts: `synthetic-256-ssr-esm-split` - -This is the primary manifest/cache-dedup comparison fixture because it enables -`future.v8_splitRouteModules`. - -| Environment | Operation | Total count (5 runs) | Per build | Total time | Max single | -| ----------- | -------------------------- | -------------------: | --------: | ---------: | ---------: | -| web | `route:chunk` | 1930 | 386.0 | 409899.2ms | 445.2ms | -| web | `route:client-entry` | 1285 | 257.0 | 363767.2ms | 445.9ms | -| web | `route:module` | 1285 | 257.0 | 1059.3ms | 7.8ms | -| node | `route:module` | 1285 | 257.0 | 453.6ms | 7.3ms | -| node | `manifest:transform` | 5 | 1.0 | 32.5ms | 7.3ms | -| node | `module:client-only-stub` | 5 | 1.0 | 21.4ms | 6.9ms | -| web | `route:split-exports` | 4595 | 919.0 | 0.8ms | 0.1ms | -| web | `module:client-only-stub` | 15 | 3.0 | 0.5ms | 0.1ms | -| node | `module:server-only-guard` | 10 | 2.0 | 0.0ms | 0.0ms | -| node | `route:split-exports` | 1390 | 278.0 | 0.0ms | 0.0ms | -| web | `manifest:stage` | 5 | 1.0 | 0.0ms | 0.0ms | -| web | `manifest:transform` | 5 | 1.0 | 0.0ms | 0.0ms | +## Baseline expectations -Baseline expectations for the same fixture after cache dedup: +For the split fixture after cache dedup: - `route:client-entry`, `route:module`, `route:split-exports`, and `route:chunk` invocation counts should remain approximately the same because @@ -302,22 +237,8 @@ Baseline expectations for the same fixture after cache dedup: - Direct `route-chunk:structured-clone` should fall materially if the refactor removes per-query AST cloning. -### Control operation counts: `synthetic-256-ssr-esm` - -Use this as the non-split control. It should not materially change when the -split-route cache path changes. - -| Environment | Operation | Total count (5 runs) | Per build | Total time | Max single | -| ----------- | ------------------------- | -------------------: | --------: | ---------: | ---------: | -| web | `route:client-entry` | 1285 | 257.0 | 164444.8ms | 260.4ms | -| web | `route:module` | 1285 | 257.0 | 1076.2ms | 13.3ms | -| node | `route:module` | 1285 | 257.0 | 451.0ms | 7.7ms | -| node | `manifest:transform` | 5 | 1.0 | 28.4ms | 8.2ms | -| node | `module:client-only-stub` | 5 | 1.0 | 21.6ms | 7.9ms | -| node | `route:split-exports` | 1390 | 278.0 | 3.6ms | 3.6ms | -| web | `route:split-exports` | 2665 | 533.0 | 0.2ms | 0.1ms | -| web | `manifest:stage` | 5 | 1.0 | 0.0ms | 0.0ms | -| web | `manifest:transform` | 5 | 1.0 | 0.0ms | 0.0ms | +Use `synthetic-256-ssr-esm` as the non-split control. It should not materially +change when the split-route cache path changes. ## Comparison procedure diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts index 6b32a51..d3c3690 100644 --- a/examples/default-template/playwright.config.ts +++ b/examples/default-template/playwright.config.ts @@ -7,11 +7,9 @@ export default defineConfig({ expect: { timeout: 5000 }, - // Run tests in files in parallel + // Keep this example serial because dev-route-watch mutates routes.ts and + // restarts the shared dev server. fullyParallel: false, - // This suite includes dev-route-watch, which mutates routes.ts and restarts - // the shared dev server. Keep this example serial so other tests do not race - // the intentional restart. workers: 1, // Fail the build on CI if you accidentally left test.only in the source code forbidOnly: !!process.env.CI, diff --git a/package.json b/package.json index 66105e3..9445b0d 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@react-router/dev": "^7.13.0", "@rsbuild/config": "workspace:*", "@rsbuild/core": "2.0.15", + "@rsbuild/plugin-react": "2.0.1", "@rslib/core": "^0.22.1", "@rspack/core": "2.0.8", "@rstest/core": "^0.8.1", diff --git a/performance-timing-semantics-analysis.md b/performance-timing-semantics-analysis.md deleted file mode 100644 index f631721..0000000 --- a/performance-timing-semantics-analysis.md +++ /dev/null @@ -1,149 +0,0 @@ -# Profiler Timing Semantics & Concurrency Overcount Analysis - -**Task:** t_f5a0df72 — Decide profiler operation timing semantics and overcount risk -**Scope:** `src/performance.ts` and its 8 call sites in `src/index.ts`. Analysis only — no code changes. -**Branch:** perf/bundling-performance @ c2452de - ---- - -## 1. What the profiler measures today - -`createReactRouterPerformanceProfiler` exposes three methods: - -| Method | Clock | Wraps | Suspends? | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------- | -| `record(env, op, resource, () => Promise)` | `performance.now()` wall-clock: `start` before callback, delta captured in `.finally()` | an async callback | **Yes** — the callback `await`s off-thread work | -| `recordSync(env, op, resource, () => T)` | `performance.now()` wall-clock: `start` before, delta in `finally` | a sync callback | No | -| `flush(env, { compilerLifecycleMs })` | — | emits one JSON report per environment | — | - -Every measurement is a **wall-clock delta** (`performance.now()`). Nothing attempts CPU-exclusive accounting. `record` measures start→settle; `recordSync` measures start→return. - -`compilerLifecycleMs` (set in `index.ts:481-484`) is a single wall-clock span from `setupStartMs` (`performance.now()` at plugin setup, `index.ts:132`) to `onAfterEnvironmentCompile`. It is the **one authoritative end-to-end wall time** and is never summed, so it carries no internal double-count. - -### The 8 call sites (all in `src/index.ts`) - -| # | Op name | Method | Line | Hook trigger | Async waits in body | -| --- | -------------------------- | ------------ | ---- | --------------------------------------------------------------- | ------------------------------------------------------------------- | -| 1 | `manifest:stage` | `recordSync` | 1263 | `onManifest` callback (sync) | none (sync) | -| 2 | `manifest:transform` | `record` | 1329 | `api.transform` test: virtual manifest | `getReactRouterManifestForDev` (I/O) | -| 3 | `route:client-entry` | `record` | 1372 | `api.transform` resourceQuery: build-client-route | `transformToEsm`, `getExportNames`, `detectRouteChunksIfEnabled` | -| 4 | `route:chunk` | `record` | 1419 | `api.transform` resourceQuery: route-chunk= | `transformToEsm`, `parse` | -| 5 | `route:split-exports` | `record` | 1481 | `api.transform` test: `/.[cm]?[jt]sx?$/` (**every JS/TS file**) | `transformToEsm`, `detectRouteChunksIfEnabled`, `getExportNames` | -| 6 | `module:server-only-guard` | `record` | 1557 | `api.transform` test: `.server` files | none real — body throws/returns synchronously | -| 7 | `module:client-only-stub` | `record` | 1579 | `api.transform` test: `.client` files | `transformToEsm`, `getExportNamesAndExportAll`, recursive `resolve` | -| 8 | `route:module` | `record` | 1742 | `api.transform` resourceQuery: `?react-router-route` | `transformToEsm`, `getExportNames` | - -The async helpers (in `src/export-utils.ts`) are the suspension points: - -- `transformToEsm` → `esbuild.transform()` — **off-thread** (esbuild runs in a child thread/process); a genuine wait that yields the event loop. -- `getExportNames` → `es-module-lexer` `init` (WASM, async first call) + `parseExports` (sync). Yields at least one microtask. -- `getReactRouterManifestForDev`, `detectRouteChunksIfEnabled` → async I/O / cached analysis. - ---- - -## 2. The concurrency overcount mechanism - -All 7 `record()` sites are `api.transform()` hooks = **per-module** transforms. Rsbuild/Rspack processes the module graph with many modules in flight; the JS transform callbacks share the single Node.js event loop and **interleave at `await` points**. - -When module A's transform `await`s `esbuild.transform()` (off-thread), control returns to the event loop and module B's transform starts and runs. Both A's and B's `performance.now()` spans are "ticking" simultaneously: - -``` -event loop timeline ─────────────────────────────────────────► -A span: [████ await(esbuild A) ░░░░ run B's sync ░░░ ████ resume A ████] -B span: [██ run sync ░░░ await(esbuild B) ░░░ resume B ██] - ▲ overlap region ▲ -``` - -Each span's wall delta includes the **overlap region**. Effects on the aggregate fields in `OperationTiming`: - -- **`totalMs`** (sum of per-resource wall deltas) **overcounts.** Summing overlapping intervals bills the overlap to both operations. With N route modules transformed concurrently, `totalMs` for `route:module` can approach `N × (per-module wall)` instead of the true serial cost; in the worst case `Σ totalMs` across all operations **exceeds `compilerLifecycleMs`**, which is a physical impossibility for non-overlapping work — the giveaway that double-counting occurred. -- **`maxMs` and `slowest[]`** are **accurate per-resource** — they are single end-to-end wall deltas for one resource, never summed, so they carry no internal double-count. They remain valid for "which single resource is slowest." -- **`count`** is **accurate** — it is incremented once per invocation regardless of overlap. - -No `record()` callback contains an internal `Promise.all` over multiple modules (verified: the only `Promise.all` call sites are in `build-manifest.ts`, `manifest.ts`, `react-router-config.ts`, and `index.ts:977` — none inside a transform hook body). So the overlap is **sibling (peer) overlap between different modules**, not parent/child nesting within one span. - ---- - -## 3. Recommendation — what to report - -**Report BOTH wall-clock and a concurrency-aware "exclusive" aggregate, each clearly labeled, and make `compilerLifecycleMs` the headline total.** They answer different questions and neither alone is sufficient: - -| Metric | Question it answers | Verdict | -| ------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `compilerLifecycleMs` (wall, single span) | "How long did the user wait for this build?" | **Keep — authoritative total.** Promote it as the headline number. | -| `maxMs` / `slowest[]` (wall, per-resource) | "Which individual module is the worst offender?" | **Keep as-is — accurate, no double-count.** This is the most actionable field. | -| `count` | "How many modules hit this transform?" | **Keep — accurate.** | -| `totalMs` (sum of wall spans) | "What is this operation class's total cost?" | **Misleading as written** — overcounts under concurrency. Either rename to `totalWallMs` with an explicit caveat, or replace with an interval-aware aggregate (see below). | -| **NEW: `exclusiveMs` / `wallMs`** | "How much real serial time did this operation consume, deduped against overlap?" | **Add** — gives a cost number you can actually sum and compare. | - -**Why not "exclusive CPU only"?** Most of the wall time in these spans is **wait** on esbuild/Rspack threads (off-process), not synchronous JS CPU. An "exclusive CPU" metric would systematically understate the operations that actually dominate build time (the esbuild transforms), giving a false picture. The useful split is _wall-clock-per-resource_ (already correct) vs _concurrency-deduped aggregate_ (missing), not _CPU-vs-wall_. - ---- - -## 4. Practical approach for the concurrency-aware aggregate - -Ranked by practicality for this plugin. - -### Recommended: interval-union accounting in `flush()` (Option D) - -Store each `record()` span as a `[start, end]` interval keyed by `(environment, operation)`. At `flush()`, run a sweep-line: - -1. Sort the intervals for each operation by start. -2. Merge overlapping intervals into disjoint ranges; sum their lengths → **`wallMs`** = distinct wall time this operation occupied (deduped against its _own_ overlapping resources). -3. Optionally, for a cross-operation view, do the same sweep over **all** operations' intervals together and compare the union length to `compilerLifecycleMs` to report an **overcount ratio** (`Σ totalMs / unionWallMs`). - -Why this fits: all needed data (start/end per resource) is **already captured** — `record` already calls `performance.now()` twice. The change is to persist the interval instead of immediately collapsing to a scalar in `recordDuration`, then compute the union once at flush. Memory cost is O(total module × operation invocations), bounded and fine for builds with a few thousand modules. No per-`await` instrumentation needed; the 7 call sites stay untouched. - -``` -// sketch (not applied — analysis only) -type Interval = [start: number, end: number]; -// store intervalsByEnv: Map> -// in flush: sort + merge + sum → wallMs; report overcount = totalMs / wallMs -``` - -### Fallback: span-tree self-time subtraction (Option C) - -Use `AsyncLocalStorage` to maintain a stack of active spans; when a child span starts under an active parent, subtract the child's duration from the parent's "self" time (standard OpenTelemetry self-time). **Caveat:** this only fixes _parent/child nesting_; it does **not** fix sibling overlap, and here the dominant overcount is sibling overlap (two independent modules). So Option C alone is insufficient for this plugin. Use it only if you also want per-span self attribution alongside Option D. - -### Not recommended: `process.cpuUsage()` deltas (Option A) - -`process.cpuUsage()` is process-global and sampled per-span, but on a single-threaded event loop the CPU time between a span's start and end includes CPU time spent on _other_ interleaved spans' synchronous code — it attributes no better than `performance.now()` for overlapping spans. Worse, it would **undercount** the real cost drivers (esbuild/Rspack run in separate threads/processes, so their CPU time is invisible to the JS process's `cpuUsage`). It is useful for exactly one thing: a **process-level CPU-utilization sanity check** (`cpuUsage total / compilerLifecycleMs`) to show how much of the build wall time was JS-process CPU vs waiting. Use it for that ratio only, never for per-span attribution. - -### Not recommended: bracket every `await` (Option B) - -Manually accumulate on-CPU time across sync segments, stopping at each `await` suspension. Requires instrumenting multiple await points across 7 call sites — invasive, fragile, high maintenance. Skip. - ---- - -## 5. Documentation paragraph (ready to paste) - -> **Timing semantics — concurrency overcount caveat.** -> Operation timings reported by this profiler are measured with `performance.now()` wall-clock deltas: each `record()` call captures the interval from when an async transform callback starts to when its returned promise settles. Because Rsbuild/Rspack processes many modules concurrently and the per-module transform callbacks interleave on the Node.js event loop at `await` points (notably `esbuild.transform()` and `es-module-lexer` parsing), the wall-clock spans of different modules **overlap in time**. As a result, `totalMs` — the sum of per-resource wall deltas for an operation — **double-counts overlapping wait time** and can exceed the actual serial cost of that operation; summed across all operations it can even exceed `compilerLifecycleMs`, the single authoritative end-to-end build wall time. Treat `totalMs` as an upper bound on cost, not a precise attribution. The fields that remain accurate regardless of concurrency are `count` (invocations), `maxMs` (worst single resource), and `slowest[]` (per-resource wall deltas), because these are never summed across resources. `compilerLifecycleMs` is the ground-truth total wall time. When you need a concurrency-safe cost number that can be summed across operations, use the interval-union `wallMs` aggregate instead of `totalMs`. - ---- - -## 6. High-risk operations for overcount - -Risk = (resource count, i.e. how many modules trigger it) × (number/depth of genuine async suspension points, i.e. how much wall time is interleavable wait). - -| Op name | Risk | Why | -| ------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `route:split-exports` | **Very high** | Triggered by `test: /\.[cm]?[jt]sx?$/` — matches **every** JS/TS/JSX/TSX file in the build, not just routes. Highest `count` of any op. Body has 3 sequential awaits (`transformToEsm` → `detectRouteChunksIfEnabled` → `getExportNames`), each a suspension point. Maximum modules × maximum awaits = maximum overlap, so `totalMs` inflates the most here. | -| `route:module` | **High** | One per route module (`?react-router-route` query). Awaits `transformToEsm` (off-thread esbuild) + `getExportNames`. Many route modules transformed concurrently → many overlapping spans. | -| `route:client-entry` | **High** | One per client route module. Three awaits including off-thread `transformToEsm`. Same inter-module overlap pattern as `route:module`. | -| `route:chunk` | **Medium-high** | One per route-chunk export. Awaits `transformToEsm` + `parse`. Fewer resources than `route:module` (only when `splitRouteModules` is on), but still per-chunk concurrency. | -| `module:client-only-stub` | **Medium** | Few resources (`.client` modules are rare), but each span is long with many awaits (`transformToEsm`, `getExportNamesAndExportAll`, recursive synchronous `resolve` with `statSync`/`existsSync` bursts). Per-span wall is large, so even modest overlap distorts `totalMs`. | -| `manifest:transform` | **Medium-low** | Matches only virtual manifest resources (browser + per-bundle server) → very low `count`, so little _intra-operation_ overlap. But its `getReactRouterManifestForDev` await (I/O) overlaps with route transforms, so it contributes to _cross-operation_ overcount when sums are compared. | -| `module:server-only-guard` | **Low** | Callback body is effectively synchronous — it either throws immediately (web) or returns synchronously (node). No real `await` suspension, so spans are ~0 ms and do not meaningfully overlap. | -| `manifest:stage` (`recordSync`) | **None** | Synchronous by construction (`recordSync`). Wall-clock ≈ CPU; no concurrency, no overcount. | - -**Bottom line:** the three broad-trigger per-module transforms — `route:split-exports`, `route:module`, and `route:client-entry` — are where `totalMs` diverges most from real cost, because they combine high invocation counts with multiple off-thread await points. These are the operations that most need the interval-union `wallMs` treatment (Section 4) and whose `totalMs` should carry the explicit caveat in any report. - ---- - -## 7. Summary of deliverables - -1. **Recommendation:** Report both — keep wall-clock per-resource diagnostics (`maxMs`, `slowest`, `count`) and the authoritative `compilerLifecycleMs` total; add a concurrency-aware aggregate (`wallMs` via interval-union) to replace the misleading `totalMs` for any cross-operation or cost-summing use. Do **not** pursue CPU-exclusive-only measurement (it would hide the esbuild/Rspack wait that actually dominates build time). -2. **Exclusive-ish approach:** Interval-union accounting computed in `flush()` from already-captured `[start,end]` spans (Option D) — accurate, no await instrumentation, 7 call sites untouched. `process.cpuUsage()` only for an optional process-level CPU-utilization ratio, never per-span. -3. **Documentation paragraph:** Section 5 above, ready to paste as a code comment in `performance.ts` or a README section. -4. **High-risk ops:** `route:split-exports` (very high), `route:module` (high), `route:client-entry` (high), `route:chunk` (medium-high), `module:client-only-stub` (medium); `manifest:transform` (medium-low, cross-op only); `module:server-only-guard` (low); `manifest:stage` (none, sync). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66c0ee9..1b80edc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 0.13.0 '@rspack/plugin-react-refresh': specifier: ^2.0.2 - version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0) + version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0) execa: specifier: ^9.6.1 version: 9.6.1 @@ -63,9 +63,12 @@ importers: '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + '@rsbuild/plugin-react': + specifier: 2.0.1 + version: 2.0.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.0.8 version: 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -3643,96 +3646,112 @@ packages: '@react-email/body@0.2.1': resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.1': resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.1': resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.6': resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.14': resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@1.0.6': resolution: {integrity: sha512-3GwOeq+5yyiAcwSf7TnHi/HWKn22lXbwxQmkkAviSwZLlhsRVxvmWqRxvUVfQk/HclDUG+62+sGz9qjfb2Uxjw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.16': resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.10': resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.13': resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.16': resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.12': resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.12': resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.12': resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.13': resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.18': resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.14': resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3746,18 +3765,21 @@ packages: '@react-email/row@0.0.13': resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.17': resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@2.0.3': resolution: {integrity: sha512-URXb/T2WS4RlNGM5QwekYnivuiVUcU87H0y5sqLl6/Oi3bMmgL0Bmw/W9GeJylC+876Vw+E6NkE0uRiUFIQwGg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@react-email/body': 0.2.1 '@react-email/button': 0.2.1 @@ -3796,6 +3818,7 @@ packages: '@react-email/text@0.1.6': resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -6637,6 +6660,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -7922,6 +7946,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -9271,6 +9296,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.2.0: @@ -12257,17 +12283,6 @@ snapshots: - '@rspack/core' - webpack - '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': - dependencies: - '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - '@typescript/native-preview' - - core-js - '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -12394,7 +12409,7 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: @@ -16740,13 +16755,6 @@ snapshots: transitivePeerDependencies: - supports-color - rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): - dependencies: - '@ast-grep/napi': 0.37.0 - '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - optionalDependencies: - typescript: 5.9.3 - rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 diff --git a/route-analysis-duplication-audit.md b/route-analysis-duplication-audit.md deleted file mode 100644 index 0865014..0000000 --- a/route-analysis-duplication-audit.md +++ /dev/null @@ -1,356 +0,0 @@ -# Route Analysis Duplication Audit - -Branch: `perf/bundling-performance` @ `c2452de` -Scope: every place a **route module file** is read from disk, parsed/transformed, -or mined for exports/metadata across the dev + build pipeline. -Companion to `route-chunk-parse-traverse-analysis.md` (which covers -`src/route-chunks.ts` internals in depth). - ---- - -## 1. Method & scope - -Cross-referenced four target files plus their shared helpers: - -| File | Role | -| -------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `src/export-utils.ts` | The only module that reads route files from disk; owns the transform + export-extraction caches. | -| `src/route-chunks.ts` | Babel parse/traverse/generate for route-chunk splitting (see companion doc). | -| `src/manifest.ts` | `getReactRouterManifestForDev` — per-route export analysis + chunk-metadata mapping. | -| `src/modify-browser-manifest.ts` | Rspack `emit` hook that (re)runs manifest generation + computes SRI over **built assets**. | -| `src/build-manifest.ts` | Server-bundle routing. **Does NOT read route files** — only path/id strings. | -| `src/index.ts` | Bundler `api.transform` hooks (the in-memory code path) + prerender validation + SRI/manifest staging. | - -Two fundamentally different code sources feed the same analysis primitives: - -- **Pipeline A — disk-read path** (`getRouteModuleAnalysis`): `stat → readFile(path) → transformToEsm(source) → getExportNames(code)`. Used by manifest generation and prerender validation. -- **Pipeline B — bundler-transform path** (`api.transform` hooks): receives `args.code` from the bundler (in-memory), calls `transformToEsm(args.code)` + `getExportNames(code)` + `detectRouteChunksIfEnabled`/`getRouteChunkIfEnabled` directly. - ---- - -## 2. Cache layers (the deduplication substrate) - -There are **four** independent caches. Understanding them is prerequisite to judging -what is actually duplicated vs. already-shared. - -### 2a. `export-utils.ts` — module-level, process-wide, shared across A and B - -| Cache | Location | Key | Version / invalidation | Bound | -| -------------------------- | ----------------------- | --------------------------------- | ---------------------------------------------- | ------------ | -| `transformCache` | `export-utils.ts:24` | `resourcePath` | input `code` string (`cached.source === code`) | 2048 (`:30`) | -| `exportNamesCache` | `export-utils.ts:25` | `code` string (content-addressed) | n/a (key IS the content) | 2048 | -| `routeModuleAnalysisCache` | `export-utils.ts:26-29` | `resourcePath` | `mtimeMs` + `size` from `stat()` | 2048 | - -`routeModuleAnalysisCache` wraps `transformToEsm` + `getExportNames` + the raw -`readFile`/`source`. It is the **only** consumer that pays `stat()` + `readFile()`. -The bundler path (Pipeline B) bypasses it entirely and hits `transformCache` + -`exportNamesCache` directly. - -### 2b. `route-chunks.ts` — per-build, passed by reference (`routeChunkCache`) - -Declared once per plugin invocation at `index.ts:403` -(`const routeChunkCache: RouteChunkCache = new Map()`), threaded into -`routeChunkOptions.cache` (`index.ts:408`) and every `*IfEnabled` call. -Keyed by `normalizeRelativeFilePath(id)` (`route-chunks.ts:826`, query string -stripped) + sub-key discriminator; versioned by the exact `code` string. -See companion doc §2/§5 for the full sub-key table. - -**Cross-cache consequence:** Pipeline A and Pipeline B share the _lower_ caches -(`transformCache`, `exportNamesCache`) but Pipeline A additionally owns -`routeModuleAnalysisCache`. For a route-chunk cache _hit_ to occur across the two -pipelines, the `code` they feed to `detectRouteChunksIfEnabled` must be byte-identical -(see §6, finding F-3). - ---- - -## 3. Per-code-path inventory: route-file → operations → call-sites - -Notation: R = read from disk, T = esbuild transform, L = lexer export extract, -B = Babel parse/traverse/generate (route-chunks), X = other extract. - -### 3a. Manifest generation — `getReactRouterManifestForDev` (`manifest.ts:110`) - -Per route, inside `Promise.all` over `routes` (`manifest.ts:163`): - -| Step | Line | Op | Primitive | -| ---------------------------- | ---------- | ------------------------- | ---------------------------------------------------- | -| resolve route file path | `:170` | — | `resolve(context, route.file)` | -| read + transform + extract | `:190` | R, T, L | `getRouteModuleAnalysis(routeFilePath)` | -| dev CSS fallback | `:191-199` | X (regex on raw `source`) | `/\.css.../ .test(source)` | -| chunk detection (build only) | `:204` | B | `detectRouteChunksIfEnabled(cache, cfg, path, code)` | -| chunk module-path mapping | `:249-272` | — | `getModulePathForChunk(getRouteChunkEntryName(...))` | - -**Needs from the file:** `source` (raw, for dev CSS regex), `code` (transformed, -for chunk detection), `exports` (full list → `hasAction`/`hasLoader`/`hasClient*`/ -`hasDefault`/`hasErrorBoundary` booleans), and chunk booleans → asset paths. - -Called from **3** sites (each iterates ALL routes): - -- `index.ts:869` — prerender block (`if (isPrenderEnabled)`) -- `index.ts:1352` — virtual server-manifest transform fallback (when `latestServerManifest` is null) -- `modify-browser-manifest.ts:39` — Rspack `emit` hook (web compilation) - -### 3b. Prerender export validation — `validateSsrFalsePrerenderExports` (`index.ts:733`) - -| Step | Line | Op | Primitive | -| ------------------ | ------ | ------- | ------------------------------------------------------------ | -| read route exports | `:761` | R, T, L | `getRouteModuleExports(filePath)` → `getRouteModuleAnalysis` | - -**Needs:** the **full export-name list** per route (`exports.includes('headers'|'action'|'loader')`, -`index.ts:769-782`). This runs _inside_ the prerender flow that already called -`getReactRouterManifestForDev` at `:869` — so the same route files are analyzed -twice in one prerender pass (second call is a `routeModuleAnalysisCache` hit, but -still pays `stat()` per route). - -### 3c. Client-entry transform — `?__react-router-build-client-route` (`index.ts:1367`) - -| Step | Line | Op | Primitive | -| --------------------------------- | ------- | --- | --------------------------------------------------------------------------- | -| transform | `:1377` | T | `transformToEsm(args.code, args.resourcePath)` | -| export extract | `:1378` | L | `getExportNames(code)` | -| chunk detection (build, web only) | `:1383` | B | `detectRouteChunksIfEnabled(routeChunkCache, cfg, args.resourcePath, code)` | - -**Needs:** export names to filter `CLIENT_ROUTE_EXPORTS`/`SERVER_ONLY_ROUTE_EXPORTS` -reexports (`:1392-1403`); `chunkedExports` to drop chunked names from reexports. - -### 3d. Route-chunk transform — `?route-chunk=` (`index.ts:1414`) - -| Step | Line | Op | Primitive | -| ------------------------------- | ------- | --- | ----------------------------------------------------------------------------------------- | -| transform | `:1442` | T | `transformToEsm(args.code, args.resourcePath)` | -| chunk generate | `:1446` | B | `getRouteChunkIfEnabled(routeChunkCache, cfg, args.resourcePath, chunkName, transformed)` | -| enforce validation (main chunk) | `:1455` | L | `getExportNames(chunk)` — over **generated** chunk code | - -**Needs:** the generated chunk body (`chunk`) to emit as module source; export names -of the _generated_ main chunk to validate enforce-split invariants (`:1454-1466`). -Fires once per chunk (main + N named) per route module. - -### 3e. Split-exports transform — `test /\.[cm]?[jt]sx?$/` (`index.ts:1476`) - -| Step | Line | Op | Primitive | -| --------------- | ------- | --- | ---------------------------------------------- | -| transform | `:1504` | T | `transformToEsm(args.code, args.resourcePath)` | -| chunk detection | `:1509` | B | `detectRouteChunksIfEnabled(...)` | -| export extract | `:1519` | L | `getExportNames(transformed)` | - -**Needs:** `hasRouteChunks` + `chunkedExports` to decide whether to rewrite the module -into reexports (`:1515-1547`); full export list to split main vs. chunked reexports. - -### 3f. `.client` stub transform — `test /\.client/` (`index.ts:1574`, node env only) - -| Step | Line | Op | Primitive | -| --------------------------- | ------- | ------- | ----------------------------------------------------------------------------------- | -| transform | `:1588` | T | `transformToEsm(args.code, args.resourcePath)` | -| export + export-all extract | `:1590` | L | `getExportNamesAndExportAll(code)` | -| recursive re-export walk | `:1677` | R, T, L | `readFile` + `transformToEsm` + `getExportNamesAndExportAll` per re-exported module | - -**Scope note:** operates on `.client` modules, **not route modules**. Included for -completeness because it is the only other place that does `readFile` + -`transformToEsm` + export extraction. The recursive `readFile` walk (`:1670-1699`) -is unique to this path and re-reads arbitrary dependency files. - -### 3g. SRI computation — `createModifyBrowserManifestPlugin` (`modify-browser-manifest.ts:103-124`) - -| Step | Line | Op | -| -------------------- | ---------- | --------------------------------------------- | -| hash built JS assets | `:116-122` | `createHash('sha384').update(asset.source())` | - -**Scope note:** reads **built bundle assets** (`compilation.assets`), NOT route source -files. Not a route-analysis duplication. The `onManifest(manifest, sri)` staging -callback (`index.ts:1262-1295`) just attaches `sri` to the already-computed manifest -and shards it per server bundle — no file reads. - -### 3h. `build-manifest.ts` — `getBuildManifest` (`:60`) / `getRoutesByServerBundleId` (`:149`) - -**No route-file reads, transforms, or export extraction.** Pure path/id manipulation: -resolves `route.file` (`:89`, `:112`), normalizes to root-relative (`:92`), and calls -the user-supplied `serverBundles({ branch })` function (`:108`). Routes are carried as -string metadata only. Listed here to **exclude** it from the duplication set. - ---- - -## 4. Route-file → operations → call-sites (consolidated table) - -For a single route module `R.tsx` with main + 2 chunkable exports, one production -build (splitRouteModules enabled, prerender enabled), the operations on `R.tsx`: - -| # | Call-site (file:line) | Pipeline | R | T | L | B-parse | B-traverse | B-generate | What it needs | -| --- | ------------------------------------------- | -------- | --- | --- | --- | ------- | ---------- | ---------- | ---------------------------------------- | -| 1 | `manifest.ts:190` (manifest gen ×3 callers) | A | ✓ | ✓ | ✓ | — | — | — | source (CSS), code, exports, chunk bools | -| 2 | `index.ts:761` (prerender validation) | A | ✓\* | ✓\* | ✓\* | — | — | — | full export list | -| 3 | `index.ts:1504` split-exports transform | B | — | ✓ | — | ✓ | ✓ | — | hasRouteChunks, chunkedExports, exports | -| 4 | `index.ts:1377` client-entry transform | B | — | ✓ | ✓ | ✓ | ✓ | — | chunkedExports, exports | -| 5 | `index.ts:1442` route-chunk `main` | B | — | ✓ | — | ✓ | ✓ | ✓ | generated main chunk body | -| 6 | `index.ts:1442` route-chunk `clientAction` | B | — | ✓ | — | ✓ | — | ✓ | generated named chunk body | -| 7 | `index.ts:1442` route-chunk `clientLoader` | B | — | ✓ | — | ✓ | — | ✓ | generated named chunk body | - -`*` = served from `routeModuleAnalysisCache` (mtime+size hit) — no actual `readFile`, -but `stat()` still runs. - -**Effective cost per cold route module (main + 2 chunks), thanks to caching:** - -- `readFile`: 1× (Pipeline A, cached thereafter) -- esbuild `transform`: 1× (`transformCache`, path+source keyed — shared across A & B - **iff** disk source === bundler `args.code`) -- lexer export extract: 1× (`exportNamesCache`, content-keyed) -- Babel `parse`: 1× (route-chunks `codeToAst`) -- Babel `traverse`: 1× (`getExportDependencies`) -- Babel `generate`: 3× (one per chunk — inherently per-chunk, see companion doc §4) -- `structuredClone`: 4× (companion doc §3a/§4 — the known redundant hot spot) - ---- - -## 5. Duplication findings - -Each finding: what is duplicated, the consumers, and whether it is safe to -consolidate or genuinely diverges. - -### F-1 — Export-name list extracted redundantly; manifest keeps only booleans - -**Sites:** `manifest.ts:190` (→ booleans), `index.ts:761` (→ full list), `index.ts:1378`, -`index.ts:1519`, `index.ts:1455` (generated chunk). -**Duplication:** the full export-name set for a route is computed by -`getExportNames`/`getRouteModuleAnalysis` in 4 separate call-sites for the _same_ -module source. The `exportNamesCache` (content-keyed) makes the lexer parse itself -run once, but each site issues the async call and pays a `Map` lookup. -**Divergence:** `manifest.ts` **discards** the list, storing only -`hasAction`/`hasLoader`/`hasClient*`/`hasDefault`/`hasErrorBoundary` booleans -(`manifest.ts:216-279`). The prerender validator (`index.ts:769-782`) needs names the -manifest does not carry (`headers`, raw `loader`), forcing a **second full pass** over -all route files (`index.ts:758-762`) that runs right after manifest generation -(`index.ts:869`). -**Consolidation:** SAFE to thread the full export-name list (or the `RouteModuleAnalysis`) -out of `getReactRouterManifestForDev` so `validateSsrFalsePrerenderExports` reuses it -instead of re-calling `getRouteModuleExports`. Eliminates the `:758-762` pass entirely. - -### F-2 — Manifest generation runs up to 3× per build, each iterating all routes - -**Sites:** `index.ts:869` (prerender), `index.ts:1352` (server-manifest transform -fallback), `modify-browser-manifest.ts:39` (emit hook). -**Duplication:** each invocation iterates `Object.entries(routes)` and calls -`getRouteModuleAnalysis` per route (`manifest.ts:163-190`). `routeModuleAnalysisCache` -(mtime+size keyed) absorbs the redundant `readFile`/`transform`/`extract` on the 2nd -and 3rd runs, but every route still pays `stat()` (`export-utils.ts:133`) per call, and -the whole `Promise.all` + chunk-detection + jsesc serialization repeats. -**Consolidation:** PARTIALLY SAFE. The emit-hook result (`modify-browser-manifest.ts:39`) -is already staged into `latestServerManifest` via `onManifest` (`index.ts:1262-1295`). -The server-manifest transform (`index.ts:1352`) already prefers that staged value and -only falls back to re-generation when it is absent. The prerender call (`index.ts:869`) -runs in `onAfterBuild` **before** the web `emit` hook has necessarily staged the -manifest, so it currently cannot reuse it. Ordering the prerender validation after the -manifest is staged (or capturing the manifest once and passing it down) would remove -one full generation. Investigate build-phase ordering before changing. - -### F-3 — Two code sources for the same route file (disk vs bundler) - -**Sites:** Pipeline A feeds `code = readFile(path)` (`export-utils.ts:140`); -Pipeline B feeds `code = args.code` (bundler-supplied, e.g. `index.ts:1377,1442,1504`). -**Duplication:** `transformToEsm` is invoked from both pipelines for the same path. -The `transformCache` is keyed by `resourcePath` and versioned by the input `code` -string (`export-utils.ts:56-59`), so: - -- if `args.code === diskSource` → cache **hit**, esbuild runs once (good); -- if they differ (preceding loader normalization, source-map injection, line-ending - changes) → cache **miss** that **overwrites** the entry, and the route-chunks cache - (versioned by `code`, `route-chunks.ts`) silently re-parses/re-traverses. - **Divergence:** correctness-relevant, not just performance. The equality of the two - code strings is **assumed, never asserted** (companion doc §5). Pipeline A also needs - the **raw `source`** for the dev CSS fallback (`manifest.ts:191-199`), which Pipeline B - does not have and does not replicate. - **Consolidation:** DO NOT collapse blindly. Safe hardening: have Pipeline A accept the - already-transformed `code` from the bundler when available (avoiding the separate - disk read), and make the code-source contract explicit. The raw-`source` dependency - (dev CSS regex) must be preserved or replaced with a transformed-code check. - -### F-4 — Dev CSS fallback uses raw source; nothing else does - -**Site:** `manifest.ts:191-199`. -**What it needs:** the **raw `source`** string to regex-test for `.css/.less/.sass/.scss` -import literals and synthesize a fallback asset path in dev (when `cssAssets` is empty). -**Divergence:** this is the **only** consumer of `RouteModuleAnalysis.source`. Every -other consumer uses `code` or `exports`. If Pipeline A were rewritten to skip the disk -read (F-3), this fallback would lose its input unless the CSS check is moved onto the -transformed `code` (esbuild preserves `import './x.css'` statements in ESM output, so a -transformed-code regex would work and remove the raw-source dependency entirely). -**Consolidation:** SAFE to migrate the regex onto `code` (transformed ESM), which then -unblocks dropping the raw `source` from the analysis shape. - -### F-5 — `transformToEsm` called in every transform hook (deduped, but noisy) - -**Sites:** `index.ts:1377, 1442, 1504, 1588`. -**Duplication:** each of the 4 transform hooks independently calls -`transformToEsm(args.code, args.resourcePath)`. All hit the same `transformCache` -(path+source keyed), so esbuild runs at most once per unique source per path. Not a -runtime duplicate, but a **call-site** duplicate: 4 places to maintain the same -"transform then analyze" prelude. -**Consolidation:** SAFE (refactor-only, no behavior change) to extract a shared -"analyze route module from bundler args" helper returning `{code, exports, -chunkInfo}`. Low priority — purely structural. - -### F-6 — `detectRouteChunksIfEnabled` called from 3 sites (fully deduped) - -**Sites:** `manifest.ts:204`, `index.ts:1383`, `index.ts:1509`. -**Duplication:** none at runtime — `routeChunkCache` (path+code keyed) makes the first -call cold and the rest warm (companion doc §4, sites #2/#3 are cheap warm reads). -**Consolidation:** NOT NEEDED. Already optimal; documented for completeness. - -### F-7 — `.client` stub transform re-reads dependency modules from disk - -**Site:** `index.ts:1670-1699` (recursive `collectExportNamesFromModule`). -**Duplication:** `readFile` + `transformToEsm` + `getExportNamesAndExportAll` per -re-exported module. The top-level `.client` module's transform/extract are deduped by -`transformCache`/`exportNamesCache`, but the **recursive walk** over `export *` -targets (`:1677`) reads each dependency fresh with no `routeModuleAnalysisCache`-style -mtime cache — every build re-stats and re-reads every transitively re-exported file. -**Scope:** `.client` modules, not route modules. **Consolidation:** SAFE (orthogonal -optimization) to add an mtime+size cache mirroring `routeModuleAnalysisCache` for the -recursive walk, or to reuse `getRouteModuleAnalysis` for the leaf reads. Separate from -the route-file duplication set but the highest-uncached I/O in the neighborhood. - ---- - -## 6. Summary: safe-to-consolidate vs. diverges - -| Finding | Duplicate? | Safe to consolidate? | Notes | -| ---------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------- | -| F-1 export list (manifest keeps booleans only) | Yes (call) | **YES** — thread the list/analysis out of manifest gen to prerender validator | Removes the `index.ts:758-762` pass | -| F-2 manifest gen ×3 | Yes (stat + serialize) | **PARTIAL** — depends on build-phase ordering; emit hook already staged via `onManifest` | Prerender call (`:869`) is the hard one | -| F-3 dual code source (disk vs bundler) | Conditional | **NO (blindly)** — make the contract explicit; raw-source dependency (F-4) blocks it | Correctness risk: silent cache misses | -| F-4 dev CSS fallback on raw `source` | Diverges | **YES** — move regex onto transformed `code` | Unblocks F-3 | -| F-5 `transformToEsm` in 4 hooks | Call-site only | **YES** (refactor) — structural, no perf gain | Low priority | -| F-6 `detectRouteChunksIfEnabled` ×3 | No (cached) | **NO** — already optimal | — | -| F-7 `.client` recursive re-reads | Yes (no mtime cache) | **YES** — orthogonal; add mtime cache or reuse `getRouteModuleAnalysis` | Not route files | - -**Recommended consolidation order** (each unblocks the next): - -1. **F-4** — migrate the dev CSS regex from raw `source` to transformed `code`. Removes - the only consumer of `RouteModuleAnalysis.source`. -2. **F-1** — expose the full export list from `getReactRouterManifestForDev` (or return - the per-route `RouteModuleAnalysis`) so prerender validation reuses it. Deletes the - `index.ts:758-762` re-extraction pass. -3. **F-3** — with F-4 done, Pipeline A can accept transformed `code` from the bundler - and drop the separate disk read, making the route-chunks cache version match - deterministically. Assert `args.code === diskSource` in dev as a guard. -4. **F-2** — investigate whether the prerender manifest call (`index.ts:869`) can reuse - the staged `latestServerManifest` instead of regenerating; requires confirming - `onAfterBuild`/`emit` ordering. -5. **F-7** (orthogonal) — add an mtime cache to the `.client` recursive walk. - ---- - -## 7. Correctness caveats (must-preserve invariants) - -1. **Raw `source` is load-bearing for dev CSS fallback** (`manifest.ts:191-199`). - Any consolidation that drops the disk read must relocate this check (F-4) or - preserve access to the raw source. -2. **Code-source equality is assumed, not enforced** (companion doc §5). Pipeline A's - `code` and Pipeline B's `args.code` must agree for the route-chunks cache to hit - across pipelines; a divergence silently re-parses rather than erroring. -3. **`structuredClone` in `codeToAst` is a correctness guard**, not a redundant cost — - each chunk consumer mutates `ast.program.body` in place (companion doc §6.1). -4. **Manifest stores booleans, not export lists** (`manifest.ts:216-279`). Downstream - consumers needing raw names (`headers`, raw `loader`) currently re-extract (F-1); - do not assume the manifest carries the full list. -5. **`getBuildManifest` and SRI do not touch route source files** (§3g/§3h) — they - operate on path/id metadata and built assets respectively. Excluded from the - duplication set. diff --git a/route-chunk-parse-traverse-analysis.md b/route-chunk-parse-traverse-analysis.md deleted file mode 100644 index 6886d82..0000000 --- a/route-chunk-parse-traverse-analysis.md +++ /dev/null @@ -1,244 +0,0 @@ -# Route Chunk Parse / Traverse / Generate Behavior — Current State - -Branch: `perf/bundling-performance` @ `c2452de` -Scope: `src/route-chunks.ts` + callers in `src/index.ts` and `src/manifest.ts` - ---- - -## 1. Public entry points and their dispatch - -All three public functions funnel into a layered set of private helpers, each -of which is memoized through `getOrSetFromCache`. The `*IfEnabled` wrappers are -the only entry points called from outside the module. - -| Public fn (src/route-chunks.ts) | Line | Delegates to | Cache key prefix | -| ------------------------------------------------------------ | ---- | -------------------------------------------------------- | ------------------------------- | -| `detectRouteChunksIfEnabled(cache, config, id, code)` | 834 | `detectRouteChunks` | `normalizeRelativeFilePath(id)` | -| `getRouteChunkIfEnabled(cache, config, id, chunkName, code)` | 870 | `getRouteChunkCode` | `normalizeRelativeFilePath(id)` | -| `getRouteChunkCode(code, chunkName, cache, cacheKey)` | 782 | `omitChunkedExports` (main) / `getChunkedExport` (named) | per-call | - -Both `*IfEnabled` wrappers compute `cacheKey = normalizeRelativeFilePath(id, config.appDirectory)` -(`relative` → `normalize` → `.split('?')[0]`), so **query strings are stripped** -before keying. A module reached as `foo.tsx`, `foo.tsx?route-chunk=main`, or -`foo.tsx?__react-router-build-client-route` all collide onto the **same cache key**. - ---- - -## 2. Cache structure and versioning - -```ts -type RouteChunkCacheEntry = { value: T; version: string }; -type RouteChunkCache = Map>; -``` - -`getOrSetFromCache(cache, key, version, getValue)` (line 69): - -- **Hit** only when an entry exists for `key` **and** `entry.version === version`. -- The `version` argument is **always the `code` string itself** at every call site. -- Therefore: cache reuse is keyed by `(normalized file path, full source code)`. - A different `code` string for the same path = full recompute. - -There is exactly **one** cache instance for the whole build: -`const routeChunkCache: RouteChunkCache = new Map();` (index.ts:403), -created once per plugin invocation and passed by reference to every consumer — -the manifest path (`routeChunkOptions.cache` → manifest.ts:205) and all three -Rspack transform hooks share it. - ---- - -## 3. Each parse / traverse / generate site - -### 3a. `codeToAst` — parse + clone (lines 87-95) - -```ts -const codeToAst = (code, cache, cacheKey) => { - return structuredClone( - getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => - parse(code, { sourceType: 'module' }) - ) - ); -}; -``` - -- **Parse** (`babel.parse`) runs only on a cache MISS — once per `(path, code)`. -- **`structuredClone` runs UNCONDITIONALLY on every call**, cache hit or miss. - This is the dominant redundant cost: a deep clone of the entire AST File - node happens every time `codeToAst` is invoked, even when the parse itself - was served from cache. -- Rationale for the clone: every consumer **mutates** `ast.program.body` in - place (filter + map + assign), so sharing one AST node would corrupt later - reads. The clone is a correctness guard, not an optimization. - -`codeToAst` is called from exactly three sites, each inside a -`getOrSetFromCache` miss-callback (so each fires at most once per distinct key -per build): - -| Caller | Line | Cache key | What it does with the AST | -| ----------------------- | ---- | --------------------------------------------- | --------------------------------------- | -| `getExportDependencies` | 170 | `${ck}::getExportDependencies` | `traverse(ast, { ExportDeclaration })` | -| `getChunkedExport` | 547 | `${ck}::getChunkedExport::${name}::{opts}` | filter `ast.program.body`, `generate()` | -| `omitChunkedExports` | 663 | `${ck}::omitChunkedExports::${names}::{opts}` | filter `ast.program.body`, `generate()` | - -### 3b. `getExportDependencies` — traverse (lines 158-315) - -- Cached at `${ck}::getExportDependencies`, version = `code`. -- On miss: calls `codeToAst` (→ clone), then runs **one** `traverse()` over the - AST visiting `ExportDeclaration`. Builds a `Map` - mapping each export name → `{ topLevelStatements, topLevelNonModuleStatements, -importedIdentifierNames, exportedVariableDeclarators }`. -- Helper `getDependentIdentifiersForPath` (317) walks scope to find all - identifier dependencies of an export; `getTopLevelStatementsForPaths` (385) - lifts those to their top-level owning statement. -- This is the single traversal pass; its result is reused by every chunkability - check and every chunk-extraction. - -### 3c. `hasChunkableExport` — dependency-overlap check (lines 460-516) - -- Cached at `${ck}::hasChunkableExport::${exportName}`, version = `code`. -- On miss: calls `getExportDependencies` (cache hit if already computed), then - checks that the export's top-level non-module statements don't overlap with - any other export's (using `setsIntersect`), and that it doesn't share a - variable declarator with siblings. Returns `false` if any overlap → that - export cannot be cleanly split out. -- Called 4× per `detectRouteChunks` (one per `routeChunkExportName`). - -### 3d. `getChunkedExport` — generate a single export chunk (lines 518-617) - -- Cached at `${ck}::getChunkedExport::${exportName}::${JSON.stringify(generateOptions)}`, - version = `code`. -- On miss: calls `hasChunkableExport` (hit), `getExportDependencies` (hit), - `codeToAst` (**clone**), then filters `ast.program.body` keeping only the - dependency statements, prunes import specifiers and export declarations, - and calls **`generate(ast, generateOptions)`**. - -### 3e. `omitChunkedExports` — generate the "main" chunk (lines 619-758) - -- Cached at `${ck}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify(generateOptions)}`, - version = `code`. -- On miss: calls `hasChunkableExport` for every export name (to classify - omit vs retain), `getExportDependencies` (hit), `codeToAst` (**clone**), - filters out omitted statements/declarators/specifiers, then **`generate()`**. -- Returns `undefined` if nothing remains (the caller substitutes a no-op - snippet). - ---- - -## 4. Who calls what — the per-module call sequence during a build - -The cache is shared, so for a given route module file the operations compose. -For a module that splits into **main + 2 chunkable exports** (e.g. -clientAction, clientLoader), across one build the code paths execute: - -| # | Caller site | Fns invoked (cold) | Redundant on warm | -| --- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | -| 1 | split-exports transform (index.ts:1509) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` → `getExportDependencies`(miss: parse+**clone**+traverse) | — | -| 2 | client-entry transform (index.ts:1383) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` (**hits**) | clones avoided (hasChunkableExport hit short-circuits before codeToAst) | -| 3 | manifest generation (manifest.ts:204) | `detectRouteChunksIfEnabled` → 4× `hasChunkableExport` (**hits**) | — | -| 4 | route-chunk transform `main` (index.ts:1446) | `getRouteChunkIfEnabled` → `omitChunkedExports`(miss) → `hasChunkableExport`(hits), `getExportDependencies`(hit), `codeToAst`(**clone**), `generate()` | — | -| 5 | route-chunk transform `clientAction` (index.ts:1446) | `getRouteChunkIfEnabled` → `getChunkedExport`(miss) → `codeToAst`(**clone**), `generate()` | — | -| 6 | route-chunk transform `clientLoader` (index.ts:1446) | `getRouteChunkIfEnabled` → `getChunkedExport`(miss) → `codeToAst`(**clone**), `generate()` | — | - -**Net per cold module (main + 2 chunks):** - -- `parse()`: **1×** (cached at codeToAst). -- `structuredClone()`: **4×** — once in `getExportDependencies` (#1), once each - in `omitChunkedExports` (#4), `getChunkedExport` (#5, #6). Every clone is a - full deep copy of the AST, paid even though the _parse_ was cached. -- `traverse()`: **1×** (in `getExportDependencies`). -- `generate()`: **3×** — one per chunk (main + 2 named). Each operates on its - own cloned, filtered AST; cannot be shared because the program bodies differ. - -Sites #2 and #3 (client-entry, manifest) are cheap warm reads: `hasChunkableExport` -hits short-circuit before any `codeToAst`/clone. They add zero parse/clone/generate -cost on the second invocation. - ---- - -## 5. Input keys that determine reuse vs cache miss - -- **Identity key** = `normalizeRelativeFilePath(id)` → file path relative to - `appDirectory`, normalized, query string stripped. Two resources with the - same path stem (differing only by `?route-chunk=` / `?react-router-route` / - `?__react-router-build-client-route`) share **all** chunk-cache entries. -- **Version** = the exact `code` string. Any byte-level difference in the - transformed ESM string invalidates **every** entry for that path (re-parse, - re-traverse, re-generate), because all sites pass `code` as the version. -- **Sub-key discriminators** (appended after the path prefix): - - `::codeToAst` — parse result. - - `::getExportDependencies` — dependency map. - - `::hasChunkableExport::${name}` — per-export chunkability boolean. - - `::getChunkedExport::${name}::${JSON.stringify(generateOptions)}` — per-export generated code. - - `::omitChunkedExports::${names.join(',')}::${JSON.stringify(generateOptions)}` — main-chunk generated code. - All callers currently pass `generateOptions = {}`, so the JSON suffix is - constant `"{}"`. - -### Cache-miss triggers (correctness-relevant) - -- **Code-source divergence**: the transform path derives `code` via - `transformToEsm(args.code, args.resourcePath)` (bundler-supplied source), - while the manifest path derives it via `getRouteModuleAnalysis` → - `readFile(resourcePath)` → `transformToEsm(source, resourcePath)` (disk read). - If the bundler's `args.code` ever differs from the disk file content (e.g. - different source after a preceding loader, or normalization differences), - the `version` strings differ and the manifest path silently re-parses / - re-traverses instead of hitting the cache. In a clean build they coincide, - but the equality is **assumed, not enforced**. - ---- - -## 6. Correctness assumptions embedded in the flow - -1. **AST mutation requires isolation** — `structuredClone` in `codeToAst` - exists because `getChunkedExport` and `omitChunkedExports` rewrite - `ast.program.body` in place. Removing the clone without another isolation - strategy (e.g. per-consumer filtered views, or re-parsing) would corrupt - shared state across the main/named chunks of the same module. - -2. **`getExportDependencies` maps export name → dependency sets for ALL exports**, - and chunkability is defined by _pairwise non-overlap_ of top-level - statements and variable declarators. An export is only chunkable if its - statements/declarators are disjoint from every sibling's. `omitChunkedExports` - relies on the same map to know exactly which statements to remove for "main". - -3. **`t.isNodesEquivalent` is used for structural identity** when filtering - `ast.program.body` against the dependency sets (getChunkedExport:556, - omitChunkedExports:684,713). Because the dependency sets were built from a - _different_ AST clone than the one being filtered, node identity (`===`) - would fail; structural (deep) equivalence is required and is assumed to be - sound for the statement shapes Babel produces. - -4. **Chunkability is all-or-nothing per export** — if an export shares a - top-level statement with any sibling, it is reported as non-chunkable - (`hasChunkableExport` returns `false`) and stays in the main chunk. There is - no partial-split mode. - -5. **`generateOptions` is part of the cache key** (JSON-serialized) but always - `{}` at present, so the discriminator is inert. If a caller ever passed - non-default options (e.g. source maps), it would create a separate cache - entry and re-generate independently. - -6. **Root route module is always excluded** — `detectRouteChunksIfEnabled` - returns a no-chunks result for `isRootRouteModuleId` before any parse, so - `root.tsx` never enters the parse/clone/traverse pipeline. - -7. **Cheap pre-filter**: `detectRouteChunksIfEnabled` bails early if - `!code.includes(exportName)` for any of the 4 export names, skipping the - entire parse/traverse for modules with no chunkable exports. This is a - substring test, not a parse — fast but coarse. - ---- - -## 7. Summary of optimization-relevant findings - -- The **parse** is already well-cached (1 per module per build). -- The **traverse** is already well-cached (1 per module per build). -- **`structuredClone` is the redundant hot spot**: it runs once per chunk - (1 + N clones for a module with N chunkable exports), each cloning the full - AST. Since each chunk needs a _differently filtered_ AST, the clones aren't - avoidable in the current "clone-then-filter-then-generate" design — but the - clone cost scales with AST size × chunk count. -- **`generate`** runs once per chunk (main + N named) and is inherently - per-chunk (different program bodies). This is the floor of work. -- **Cross-caller reuse works correctly** for the dependency analysis - (`getExportDependencies`, `hasChunkableExport`) because those are pure reads - that don't mutate the AST — only the chunk _generation_ steps clone+mutate. diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index dca6416..0c444e1 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -1,5 +1,13 @@ #!/usr/bin/env node -import { access, mkdir, rm, writeFile } from 'node:fs/promises'; +import { + access, + cp, + mkdir, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; @@ -76,13 +84,18 @@ const parseArgs = argv => { clean: { type: 'string', default: 'build' }, filter: { type: 'string' }, 'parallel-transforms': { type: 'string' }, + 'rspack-profile': { type: 'string' }, + 'rspack-trace-output': { type: 'string' }, 'fail-fast': { type: 'boolean', default: false }, 'skip-root-build': { type: 'boolean', default: false }, }, }); const parseParallelTransforms = value => { - if (value === undefined || value === 'false' || value === '0') { + if (value === undefined) { + return undefined; + } + if (value === 'false' || value === '0') { return false; } if (value === 'true' || value === '1' || value === 'auto') { @@ -106,6 +119,8 @@ const parseArgs = argv => { clean: values.clean, filter: values.filter ?? null, parallelTransforms: parseParallelTransforms(values['parallel-transforms']), + rspackProfile: values['rspack-profile'] ?? null, + rspackTraceOutput: values['rspack-trace-output'] ?? null, failFast: values['fail-fast'], skipRootBuild: values['skip-root-build'], }; @@ -127,6 +142,15 @@ const parseArgs = argv => { if (!['none', 'build', 'cold'].includes(args.clean)) { throw new Error('--clean must be none, build, or cold.'); } + if (args.rspackProfile !== null && args.rspackProfile.trim() === '') { + throw new Error('--rspack-profile must not be empty.'); + } + if ( + args.rspackTraceOutput !== null && + args.rspackTraceOutput.trim() === '' + ) { + throw new Error('--rspack-trace-output must not be empty.'); + } return args; }; @@ -289,6 +313,10 @@ const renderMarkdown = result => { `- Iterations: ${result.iterations}`, `- Warmup: ${result.warmup}`, `- Parallel transforms: ${formatParallelTransforms(result.parallelTransforms)}`, + `- Rspack profile: ${result.rspackProfile ?? 'false'}`, + ...(result.rspackTraceOutput + ? [`- Rspack trace output: ${result.rspackTraceOutput}`] + : []), '', '| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | Max RSS | Plugin reports |', '|---|---:|---|---:|---:|---:|---:|---:|', @@ -347,31 +375,59 @@ const renderMarkdown = result => { return `${lines.join('\n')}\n`; }; -const writeOutputs = async (result, args) => { +const resolveOutputPaths = args => { const outPath = path.resolve(rootDir, args.out); const format = args.format === 'markdown' ? 'md' : args.format; const writeJson = format === 'json' || format === 'both'; const writeMd = format === 'md' || format === 'both'; + if (writeJson && writeMd) { + return { + artifactRoot: outPath, + jsonPath: path.join(outPath, 'baseline.json'), + mdPath: path.join(outPath, 'baseline.md'), + outPath, + writeJson, + writeMd, + }; + } + + const artifactRoot = path.extname(outPath) + ? path.join(path.dirname(outPath), `${path.basename(outPath)}.artifacts`) + : `${outPath}.artifacts`; + + return { + artifactRoot, + jsonPath: writeJson ? outPath : null, + mdPath: writeMd ? outPath : null, + outPath, + writeJson, + writeMd, + }; +}; + +const writeOutputs = async (result, outputPaths) => { + const { jsonPath, mdPath, outPath, writeJson, writeMd } = outputPaths; + if (writeJson && writeMd) { await mkdir(outPath, { recursive: true }); - await writeFile( - path.join(outPath, 'baseline.json'), - `${JSON.stringify(result, null, 2)}\n` - ); - await writeFile(path.join(outPath, 'baseline.md'), renderMarkdown(result)); + await writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`); + await writeFile(mdPath, renderMarkdown(result)); return; } await mkdir(path.dirname(outPath), { recursive: true }); if (writeJson) { - await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`); + await writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`); } else { - await writeFile(outPath, renderMarkdown(result)); + await writeFile(mdPath, renderMarkdown(result)); } }; const formatParallelTransforms = parallelTransforms => { + if (parallelTransforms === undefined) { + return 'default'; + } if (!parallelTransforms) { return 'false'; } @@ -411,9 +467,75 @@ const cleanBuildOutputs = async fixtureRoot => { ]); }; +const listRspackProfileDirs = async cwd => { + const entries = await readdir(cwd, { withFileTypes: true }); + return entries + .filter( + entry => entry.isDirectory() && entry.name.startsWith('.rspack-profile-') + ) + .map(entry => entry.name) + .sort(); +}; + +const moveDirectory = async (source, destination) => { + await rm(destination, { recursive: true, force: true }); + await mkdir(path.dirname(destination), { recursive: true }); + try { + await rename(source, destination); + } catch (error) { + if (error?.code !== 'EXDEV') { + throw error; + } + await cp(source, destination, { recursive: true }); + await rm(source, { recursive: true, force: true }); + } +}; + +const collectRspackProfiles = async ({ + fixtureRoot, + beforeProfiles, + destinationRoot, +}) => { + const before = new Set(beforeProfiles); + const afterProfiles = await listRspackProfileDirs(fixtureRoot); + const createdProfiles = afterProfiles.filter(profile => !before.has(profile)); + const collected = []; + + for (const profile of createdProfiles) { + const source = path.join(fixtureRoot, profile); + const destination = path.join(destinationRoot, profile.slice(1)); + await moveDirectory(source, destination); + collected.push(path.relative(rootDir, destination)); + } + + return collected; +}; + +const isTraceOutputStream = value => value === 'stdout' || value === 'stderr'; + +const resolveRspackTraceOutput = async ({ + traceOutput, + benchmarkId, + runLabel, +}) => { + if (!traceOutput || isTraceOutputStream(traceOutput)) { + return traceOutput; + } + + const tracePath = path.resolve( + rootDir, + traceOutput, + benchmarkId, + `${runLabel}.log` + ); + await mkdir(path.dirname(tracePath), { recursive: true }); + return tracePath; +}; + const main = async () => { const args = parseArgs(process.argv.slice(2)); const useTime = await hasGnuTime(); + const outputPaths = resolveOutputPaths(args); const pluginImportPath = pathToFileURL( path.join(rootDir, 'dist/index.js') ).href; @@ -466,6 +588,18 @@ const main = async () => { console.log( `${measured ? 'Measuring' : 'Warming'} ${benchmark.id} (${index + 1}/${totalRuns})` ); + const rspackProfileEnabled = Boolean(args.rspackProfile); + const beforeRspackProfiles = rspackProfileEnabled + ? await listRspackProfileDirs(fixtureRoot) + : []; + const runLabel = `${measured ? 'run' : 'warmup'}-${ + measured ? index - args.warmup + 1 : index + 1 + }`; + const rspackTraceOutput = await resolveRspackTraceOutput({ + traceOutput: args.rspackTraceOutput, + benchmarkId: benchmark.id, + runLabel, + }); const commandResult = await runCommand({ command: process.execPath, args: [rsbuildBin, 'build', '--config', 'rsbuild.config.mjs'], @@ -473,9 +607,27 @@ const main = async () => { env: { NODE_ENV: 'production', REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE: '1', + ...(args.rspackProfile + ? { RSPACK_PROFILE: args.rspackProfile } + : {}), + ...(rspackTraceOutput + ? { RSPACK_TRACE_OUTPUT: rspackTraceOutput } + : {}), }, useTime, }); + const rspackProfiles = rspackProfileEnabled + ? await collectRspackProfiles({ + fixtureRoot, + beforeProfiles: beforeRspackProfiles, + destinationRoot: path.join( + outputPaths.artifactRoot, + 'rspack-profiles', + benchmark.id, + runLabel + ), + }) + : []; const timeStats = useTime ? parseTimeStats(commandResult.stderr) : {}; const pluginReports = parsePluginReports( `${commandResult.stdout}\n${commandResult.stderr}` @@ -493,6 +645,11 @@ const main = async () => { sysMs: timeStats.sysMs ?? null, maxRssKb: timeStats.maxRssKb ?? null, pluginReports, + rspackProfiles, + rspackTraceOutput: + rspackTraceOutput && !isTraceOutputStream(rspackTraceOutput) + ? path.relative(rootDir, rspackTraceOutput) + : rspackTraceOutput, }); } } @@ -523,14 +680,14 @@ const main = async () => { iterations: args.iterations, warmup: args.warmup, parallelTransforms: args.parallelTransforms, + rspackProfile: args.rspackProfile, + rspackTraceOutput: args.rspackTraceOutput, failed, benchmarks, }; - await writeOutputs(result, args); - console.log( - `Benchmark results written to ${path.resolve(rootDir, args.out)}` - ); + await writeOutputs(result, outputPaths); + console.log(`Benchmark results written to ${outputPaths.outPath}`); if (failed) { console.error('One or more measured benchmark builds failed.'); diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index d3749f5..d3c94e3 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -219,9 +219,12 @@ const createRoutesConfig = routeCount => { }; const renderParallelTransformsOption = parallelTransforms => { - if (!parallelTransforms) { + if (parallelTransforms === undefined) { return []; } + if (parallelTransforms === false) { + return [` parallelTransforms: false,`]; + } if (parallelTransforms === true) { return [` parallelTransforms: true,`]; } @@ -245,10 +248,12 @@ const createRsbuildConfig = ({ return [ `import { defineConfig } from '@rsbuild/core';`, + `import { pluginReact } from '@rsbuild/plugin-react';`, `import { pluginReactRouter } from '${pluginImportPath}';`, '', 'export default defineConfig({', ' plugins: [', + ' pluginReact(),', ' pluginReactRouter({', ...(ssr ? [` serverOutput: 'module',`] : []), ...renderParallelTransformsOption(parallelTransforms), @@ -257,7 +262,9 @@ const createRsbuildConfig = ({ ' }),', ' ],', ' output: {', - ` sourceMap: ${sourceMap ? 'true' : 'false'},`, + ` sourceMap: ${ + sourceMap ? `{ js: 'cheap-module-source-map', css: false }` : 'false' + },`, ' },', '});', '', @@ -355,7 +362,7 @@ export async function generateSyntheticFixture({ sourceMap = false, pluginImportPath = 'rsbuild-plugin-react-router', fixture = 'default', - parallelTransforms = false, + parallelTransforms, }) { if (!stressFixtureNames.has(fixture)) { throw new Error( diff --git a/src/babel.ts b/src/babel.ts index c8559a6..72b11cc 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -4,11 +4,9 @@ import { type ParseOptions, type ParseResult, } from 'yuku-parser'; +import type { Rspack } from '@rsbuild/core'; import { strip } from 'yuku-codegen'; -export type Babel = any; -export type NodePath = T; - export const parse = ( code: string, options: ParseOptions = {} @@ -36,9 +34,9 @@ export const generate = ( filename?: string; sourceFileName?: string; } = {} -): { code: string; map: any } => { +): { code: string; map: Rspack.RawSourceMap | null } => { const result = 'program' in ast ? ast : { program: ast, lineStarts: [] }; - const generated = strip(result.program as any, { + const generated = strip(result.program as Parameters[0], { comments: 'some', sourceMaps: options.sourceMaps ? { @@ -48,7 +46,18 @@ export const generate = ( } : undefined, }); - return { code: generated.code, map: generated.map as any }; + const map = generated.map + ? { + ...generated.map, + file: generated.map.file ?? options.filename ?? '', + sourceRoot: generated.map.sourceRoot ?? undefined, + sourcesContent: + generated.map.sourcesContent?.map(source => source ?? '') ?? + undefined, + } + : null; + + return { code: generated.code, map }; }; export const t = {}; diff --git a/src/export-utils.ts b/src/export-utils.ts index 7178f94..d3047bd 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -156,7 +156,7 @@ const isTypeOnlyExport = (node: AnyNode): boolean => (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'TSInterfaceDeclaration'); -const collectExportNames = (program: AnyNode): string[] => { +export const collectProgramExportNames = (program: AnyNode): string[] => { const exportNames = new Set(); for (const statement of program.body ?? []) { if (isTypeOnlyExport(statement)) { @@ -250,7 +250,7 @@ const getTransformedModule = async ( } return { code: stripped.code, - exportNames: collectExportNames(program), + exportNames: collectProgramExportNames(program), exportAllModules: collectExportAllModules(program), }; })(), @@ -289,11 +289,16 @@ export const getBundlerRouteAnalysis = async ( } const analysis = (async () => { - const transformed = await getTransformedModule(source, resourcePath); + const program = parseProgram(source, resourcePath); + const sourceInfo: TransformedModule = { + code: source, + exportNames: collectProgramExportNames(program), + exportAllModules: collectExportAllModules(program), + }; const routeChunkInfoCache = new Map>(); return { - ...transformed, + ...sourceInfo, getRouteChunkInfo: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig @@ -306,12 +311,7 @@ export const getBundlerRouteAnalysis = async ( let routeChunkInfo: Promise; routeChunkInfo = cachePromiseOnReject( - detectRouteChunksIfEnabled( - cache, - config, - resourcePath, - transformed.code - ), + detectRouteChunksIfEnabled(cache, config, resourcePath, source), () => { if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { routeChunkInfoCache.delete(cacheKey); @@ -352,7 +352,7 @@ export const getExportNamesAndExportAll = async ( const exportInfo = (async () => { const program = parseProgram(code); return { - exportNames: collectExportNames(program), + exportNames: collectProgramExportNames(program), exportAllModules: collectExportAllModules(program), }; })(); diff --git a/src/index.ts b/src/index.ts index c725dc3..7b31d19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,15 +4,17 @@ import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; +import { + rspack, + type RsbuildEntryDescription, + type RsbuildPlugin, + type Rspack, +} from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; import { dirname, relative, resolve } from 'pathe'; -import { - BUILD_CLIENT_ROUTE_QUERY_STRING, - PLUGIN_NAME, -} from './constants.js'; +import { BUILD_CLIENT_ROUTE_QUERY_STRING, PLUGIN_NAME } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { generateWithProps, @@ -38,7 +40,7 @@ import { getReactRouterManifestForDev, getRouteManifestModuleExports, configRoutesToRouteManifest, - REACT_ROUTER_MANIFEST_STATS_OPTIONS, + createReactRouterManifestStats, type ReactRouterManifestStats, } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; @@ -406,6 +408,10 @@ export const pluginReactRouter = ( const isBuild = api.context.action === 'build'; const splitRouteModules = future?.v8_splitRouteModules ?? false; + const isPrerenderEnabled = + prerenderConfig !== undefined && prerenderConfig !== false; + const isSpaMode = !ssr && !isPrerenderEnabled; + const routeCount = Object.keys(routes).length; const routeChunkConfig: RouteChunkConfig = { splitRouteModules, appDirectory, @@ -415,6 +421,8 @@ export const pluginReactRouter = ( const routeTransformExecutor = createRouteTransformExecutor({ parallelTransforms: pluginOptions.parallelTransforms, routeChunkCache, + routeCount, + splitRouteModules: Boolean(splitRouteModules), }); const routeChunkOptions = { splitRouteModules, @@ -478,38 +486,36 @@ export const pluginReactRouter = ( ]) ); + const manifestChunkNames = new Set(['entry.client']); const webRouteEntries = Object.values(routes).reduce( (acc, route) => { const entryName = route.file.slice(0, route.file.lastIndexOf('.')); const routeFilePath = resolve(appDirectory, route.file); + manifestChunkNames.add(entryName); acc[entryName] = { import: `${routeFilePath}${BUILD_CLIENT_ROUTE_QUERY_STRING}`, + html: false, }; if (isBuild && splitRouteModules && route.id !== 'root') { - let source = ''; - try { - source = readFileSync(routeFilePath, 'utf8'); - } catch { - source = ''; - } - if (source) { - for (const exportName of routeChunkExportNames) { - if (!source.includes(exportName)) { - continue; - } - acc[getRouteChunkEntryName(route.id, exportName)] = { - import: getRouteChunkModuleId(routeFilePath, exportName), - }; + const source = readFileSync(routeFilePath, 'utf8'); + for (const exportName of routeChunkExportNames) { + if (!source.includes(exportName)) { + continue; } + const chunkEntryName = getRouteChunkEntryName(route.id, exportName); + manifestChunkNames.add(chunkEntryName); + acc[chunkEntryName] = { + import: getRouteChunkModuleId(routeFilePath, exportName), + html: false, + }; } } return acc; }, - {} as Record + {} as Record ); - const buildManifest = await getBuildManifest({ reactRouterConfig: resolvedConfigWithRoutes, routes, @@ -520,7 +526,10 @@ export const pluginReactRouter = ( let clientStats: ReactRouterManifestStats | undefined; api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { - clientStats = stats?.toJson(REACT_ROUTER_MANIFEST_STATS_OPTIONS); + clientStats = createReactRouterManifestStats( + stats?.compilation, + manifestChunkNames + ); } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -547,10 +556,6 @@ export const pluginReactRouter = ( warn: message => api.logger.warn(message), } ); - const isPrerenderEnabled = - prerenderConfig !== undefined && prerenderConfig !== false; - const isSpaMode = !ssr && !isPrerenderEnabled; - const groupRoutesByParentId = (manifest: Record) => { const grouped: Record = {}; Object.values(manifest).forEach(route => { @@ -1086,10 +1091,7 @@ export const pluginReactRouter = ( const allowedActionOriginsForBuild = allowedActionOrigins === false ? undefined : allowedActionOrigins; - // Create virtual modules for React Router. Rspack's built-in - // VirtualModulesPlugin registers resolvable file paths, so keep public - // requests as bare `virtual/react-router/*` ids and seed matching - // `node_modules/virtual/react-router/*.js` virtual files. + // Public requests stay bare while Rspack resolves seeded virtual files. const createVirtualModulePlugin = (publicPath: string) => { const bundleVirtualModules = Object.fromEntries( Object.entries(routesByServerBundleId).map( @@ -1189,8 +1191,24 @@ export const pluginReactRouter = ( pluginOptions.lazyCompilation === undefined ? {} : { lazyCompilation: pluginOptions.lazyCompilation }; + const shouldCompactFileSizeReport = + isBuild && + routeCount >= 256 && + (config.performance?.printFileSize === undefined || + config.performance.printFileSize === true); return mergeRsbuildConfig(config, { + ...(shouldCompactFileSizeReport + ? { + performance: { + printFileSize: { + total: true, + detail: false, + compressed: false, + }, + }, + } + : {}), output: { assetPrefix: config.output?.assetPrefix || '/', }, @@ -1220,8 +1238,10 @@ export const pluginReactRouter = ( entry: { // no query needed when federation is disabled 'entry.client': finalEntryClientPath, - 'virtual/react-router/browser-manifest': - 'virtual/react-router/browser-manifest', + 'virtual/react-router/browser-manifest': { + import: 'virtual/react-router/browser-manifest', + html: false, + }, ...webRouteEntries, }, }, @@ -1253,6 +1273,7 @@ export const pluginReactRouter = ( module: true, }, optimization: { + avoidEntryIife: true, runtimeChunk: 'single', }, }, @@ -1336,6 +1357,7 @@ export const pluginReactRouter = ( routeChunkOptions, { future, + manifestChunkNames, onManifest: (manifest, sri) => { performanceProfiler.recordSync( 'web', @@ -1496,41 +1518,36 @@ export const pluginReactRouter = ( ) ); - api.transform( - { - test: /\.[cm]?[jt]sx?$/, - environments: ['web'], - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'route:split-exports', - args.resource, - async () => { - if (!isBuild || !splitRouteModules) { - return { code: args.code, map: null }; - } - if ( - args.resource.includes(BUILD_CLIENT_ROUTE_QUERY_STRING) || - args.resource.includes('?react-router-route') || - args.resource.includes('route-chunk=') - ) { - return { code: args.code, map: null }; - } - const route = routeByFilePath.get(args.resourcePath); - if (!route) { - return { code: args.code, map: null }; - } + if (isBuild && splitRouteModules) { + api.transform( + { + test: path => routeByFilePath.has(path), + resourceQuery: { + not: /__react-router-build-client-route|react-router-route|route-chunk=/, + }, + environments: ['web'], + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'route:split-exports', + args.resource, + async () => { + const route = routeByFilePath.get(args.resourcePath); + if (!route) { + return { code: args.code, map: null }; + } - return routeTransformExecutor.run({ - kind: 'splitRouteExports', - code: args.code, - resourcePath: args.resourcePath, - routeChunkConfig, - }); - } - ) - ); + return routeTransformExecutor.run({ + kind: 'splitRouteExports', + code: args.code, + resourcePath: args.resourcePath, + routeChunkConfig, + }); + } + ) + ); + } api.transform( { @@ -1587,6 +1604,7 @@ export const pluginReactRouter = ( resourcePath: args.resourcePath, environmentName: args.environment.name, ssr, + isBuild, isSpaMode, rootRoutePath, }) diff --git a/src/manifest.ts b/src/manifest.ts index 8134de2..72463c9 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -2,7 +2,6 @@ import { createHash } from 'node:crypto'; import { dirname, isAbsolute, relative, resolve } from 'pathe'; import type { Route, PluginOptions, RouteManifestItem } from './types.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import type { Rspack } from '@rsbuild/core'; import { combineURLs, createRouteId } from './plugin-utils.js'; import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js'; import { @@ -10,6 +9,7 @@ import { createEmptyRouteChunkByExportName, detectRouteChunksIfEnabled, getRouteChunkEntryName, + routeChunkExportNames, validateRouteChunks, type RouteChunkCache, type RouteChunkConfig, @@ -79,15 +79,70 @@ export type ReactRouterManifestForDev = { routes: Record; }; -export type ReactRouterManifestStats = Pick< - Rspack.StatsCompilation, - 'assetsByChunkName' ->; +export type ReactRouterManifestStats = { + assetsByChunkName?: Record; +}; + +type ReactRouterManifestStatsChunk = { + files?: Iterable; +}; + +type ReactRouterManifestStatsCompilation = { + namedChunks: Iterable<[string, ReactRouterManifestStatsChunk]>; +}; + +type ReactRouterManifestStatsNamedChunks = + ReactRouterManifestStatsCompilation['namedChunks'] & { + get?: (chunkName: string) => ReactRouterManifestStatsChunk | undefined; + }; -export const REACT_ROUTER_MANIFEST_STATS_OPTIONS = { - all: false, - assets: true, -} as const; +const orderChunkFiles = (chunkName: string, files: string[]): string[] => { + const ownChunkAsset = `${chunkName}.js`; + const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset)); + if (ownFileIndex <= 0) { + return files; + } + + return [ + files[ownFileIndex], + ...files.slice(0, ownFileIndex), + ...files.slice(ownFileIndex + 1), + ]; +}; + +export const createReactRouterManifestStats = ( + compilation: ReactRouterManifestStatsCompilation | undefined, + chunkNames?: ReadonlySet +): ReactRouterManifestStats | undefined => { + if (!compilation) { + return undefined; + } + + const assetsByChunkName: Record = {}; + const namedChunks = + compilation.namedChunks as ReactRouterManifestStatsNamedChunks; + + if (chunkNames && typeof namedChunks.get === 'function') { + for (const chunkName of chunkNames) { + const chunk = namedChunks.get(chunkName); + if (!chunk) { + continue; + } + const files = Array.from(chunk.files ?? []); + assetsByChunkName[chunkName] = orderChunkFiles(chunkName, files); + } + } else { + for (const [chunkName, chunk] of namedChunks) { + if (chunkNames && !chunkNames.has(chunkName)) { + continue; + } + const files = Array.from(chunk.files ?? []); + assetsByChunkName[chunkName] = orderChunkFiles(chunkName, files); + } + } + + return { assetsByChunkName }; +}; export type RouteManifestModuleExports = Record; @@ -144,6 +199,23 @@ const getRouteEntryName = (route: Route): string => { return extensionIndex >= 0 ? route.file.slice(0, extensionIndex) : route.file; }; +export const getReactRouterManifestChunkNames = ( + routes: Record, + splitRouteModules: boolean | 'enforce' = false +): Set => { + const chunkNames = new Set(['entry.client']); + for (const route of Object.values(routes)) { + chunkNames.add(getRouteEntryName(route)); + if (!splitRouteModules || route.id === 'root') { + continue; + } + for (const exportName of routeChunkExportNames) { + chunkNames.add(getRouteChunkEntryName(route.id, exportName)); + } + } + return chunkNames; +}; + export async function getReactRouterManifestForDev( routes: Record, //@ts-ignore @@ -171,11 +243,10 @@ export async function getReactRouterManifestForDev( if (!assets) { return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`]; } - const normalizedAssets = Array.isArray(assets) ? assets : [assets]; - if (!normalizedAssets.some(asset => asset.endsWith('.js'))) { - return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, ...normalizedAssets]; + if (!assets.some(asset => asset.endsWith('.js'))) { + return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, ...assets]; } - return normalizedAssets; + return assets; }; const getModulePathForChunk = (chunkName: string): string | undefined => { @@ -188,12 +259,14 @@ export async function getReactRouterManifestForDev( Object.entries(routes).map(async ([key, route]) => { const routeEntryName = getRouteEntryName(route); const assets = getAssetsForChunk(routeEntryName); - const jsAssets = assets.filter(asset => asset.endsWith('.js')) || []; - let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; + const jsAssets = assets.filter(asset => asset.endsWith('.js')); + let cssAssets = assets.filter(asset => asset.endsWith('.css')); const routeFilePath = resolve(context, route.file); let exports = new Set(); let routeModuleExports: readonly string[] = []; - let hasRouteChunkByExportName = createEmptyRouteChunkByExportName(); + let hasRouteChunkByExportName: ReturnType< + typeof createEmptyRouteChunkByExportName + > | null = null; try { const { code, exports: exportNames } = @@ -231,12 +304,16 @@ export async function getReactRouterManifestForDev( const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); const hasDefaultExport = exports.has('default'); + const routeChunkMap = hasRouteChunkByExportName; if (isBuild && enforceSplitRouteModules && routeChunkConfig) { validateRouteChunks({ config: routeChunkConfig, id: routeFilePath, - valid: buildManifestChunkValidity(exports, hasRouteChunkByExportName), + valid: buildManifestChunkValidity( + exports, + routeChunkMap ?? createEmptyRouteChunkByExportName() + ), }); } @@ -249,30 +326,26 @@ export async function getReactRouterManifestForDev( index: route.index, caseSensitive: route.caseSensitive, module: combineURLs(assetPrefix, jsAssets[0] || ''), - clientActionModule: - isBuild && hasRouteChunkByExportName.clientAction - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientAction') - ) - : undefined, - clientLoaderModule: - isBuild && hasRouteChunkByExportName.clientLoader - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientLoader') - ) - : undefined, - clientMiddlewareModule: - isBuild && hasRouteChunkByExportName.clientMiddleware - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientMiddleware') - ) - : undefined, - hydrateFallbackModule: - isBuild && hasRouteChunkByExportName.HydrateFallback - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'HydrateFallback') - ) - : undefined, + clientActionModule: routeChunkMap?.clientAction + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientAction') + ) + : undefined, + clientLoaderModule: routeChunkMap?.clientLoader + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientLoader') + ) + : undefined, + clientMiddlewareModule: routeChunkMap?.clientMiddleware + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientMiddleware') + ) + : undefined, + hydrateFallbackModule: routeChunkMap?.HydrateFallback + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'HydrateFallback') + ) + : undefined, hasAction: exports.has(SERVER_EXPORTS.action), hasLoader: exports.has(SERVER_EXPORTS.loader), hasClientAction, diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 13bd4b5..b172eac 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -3,9 +3,10 @@ import type { Route, PluginOptions } from './types.js'; import { rspack } from '@rsbuild/core'; import type { Rspack } from '@rsbuild/core'; import { + createReactRouterManifestStats, + getReactRouterManifestChunkNames, getReactRouterManifestForDev, getReactRouterManifestPath, - REACT_ROUTER_MANIFEST_STATS_OPTIONS, } from './manifest.js'; import { combineURLs } from './plugin-utils.js'; import jsesc from 'jsesc'; @@ -25,20 +26,29 @@ export function createModifyBrowserManifestPlugin( routeChunkOptions?: Parameters[5], options?: { future?: { unstable_subResourceIntegrity?: boolean }; + manifestChunkNames?: ReadonlySet; onManifest?: ( manifest: Awaited>, sri: Record | undefined ) => void; } ) { + const manifestChunkNames = + options?.manifestChunkNames ?? + getReactRouterManifestChunkNames( + routes, + routeChunkOptions?.splitRouteModules + ); + return { apply(compiler: Rspack.Compiler): void { compiler.hooks.emit.tapAsync( 'ModifyBrowserManifest', async (compilation: Rspack.Compilation, callback) => { - const stats = compilation - .getStats() - .toJson(REACT_ROUTER_MANIFEST_STATS_OPTIONS); + const stats = createReactRouterManifestStats( + compilation, + manifestChunkNames + ); const manifest = await getReactRouterManifestForDev( routes, pluginOptions, diff --git a/src/parallel-route-transform-worker.ts b/src/parallel-route-transform-worker.ts index 20f4095..513ae4d 100644 --- a/src/parallel-route-transform-worker.ts +++ b/src/parallel-route-transform-worker.ts @@ -5,9 +5,14 @@ import { type RouteTransformTask, } from './route-transform-tasks.js'; +type CachedRouteTransformTask = Omit & { + code?: string; +}; + type WorkerRequest = { id: number; - task: RouteTransformTask; + task: RouteTransformTask | CachedRouteTransformTask; + sourceCacheKey?: string; }; type WorkerErrorPayload = { @@ -45,15 +50,61 @@ if (!parentPort) { throw new Error('parallel route transform worker requires parentPort'); } -parentPort.on('message', async ({ id, task }: WorkerRequest) => { - try { - const result = await executeRouteTransformTask(task); - parentPort?.postMessage({ id, ok: true, result } satisfies WorkerResponse); - } catch (error) { - parentPort?.postMessage({ - id, - ok: false, - error: serializeError(error), - } satisfies WorkerResponse); +const MAX_SOURCE_CACHE_ENTRIES = 2048; +const sourceCache = new Map(); + +const setSourceCacheEntry = (key: string, code: string) => { + if (!sourceCache.has(key) && sourceCache.size >= MAX_SOURCE_CACHE_ENTRIES) { + const oldestKey = sourceCache.keys().next().value; + if (oldestKey !== undefined) { + sourceCache.delete(oldestKey); + } + } + sourceCache.set(key, code); +}; + +const hydrateTaskSource = ({ + task, + sourceCacheKey, +}: Pick): RouteTransformTask => { + if (!sourceCacheKey) { + return task as RouteTransformTask; + } + + if (typeof task.code === 'string') { + setSourceCacheEntry(sourceCacheKey, task.code); + return task as RouteTransformTask; + } + + const code = sourceCache.get(sourceCacheKey); + if (code === undefined) { + throw new Error( + `Missing cached route transform source for ${sourceCacheKey}.` + ); + } + return { + ...task, + code, + } as RouteTransformTask; +}; + +parentPort.on( + 'message', + async ({ id, task, sourceCacheKey }: WorkerRequest) => { + try { + const hydratedTask = hydrateTaskSource({ task, sourceCacheKey }); + const result = await executeRouteTransformTask(hydratedTask); + parentPort?.postMessage({ + id, + ok: true, + result, + } satisfies WorkerResponse); + } catch (error) { + parentPort?.postMessage({ + id, + ok: false, + error: serializeError(error), + } satisfies WorkerResponse); + } } -}); +); diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index afecce3..623b21b 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -1,5 +1,6 @@ import { availableParallelism, cpus } from 'node:os'; import { Worker } from 'node:worker_threads'; +import { SERVER_ONLY_ROUTE_EXPORTS } from './constants.js'; import { executeRouteTransformTask, type RouteTransformResult, @@ -8,14 +9,15 @@ import { } from './route-transform-tasks.js'; import type { PluginOptions } from './types.js'; -export type ParallelTransformsConfig = NonNullable< - PluginOptions['parallelTransforms'] -> extends infer Config - ? Exclude - : never; +export type ParallelTransformsConfig = + NonNullable extends infer Config + ? Exclude + : never; export type RouteTransformExecutorOptions = RouteTransformTaskOptions & { parallelTransforms?: PluginOptions['parallelTransforms']; + routeCount?: number; + splitRouteModules?: boolean; }; export type RouteTransformExecutor = { @@ -35,6 +37,14 @@ type WorkerResponse = error: WorkerErrorPayload; }; +type WorkerRequest = { + id: number; + task: + | RouteTransformTask + | (Omit & { code?: string }); + sourceCacheKey?: string; +}; + type WorkerErrorPayload = { name?: string; message: string; @@ -49,6 +59,13 @@ type PendingTask = { type WorkerState = { worker: Worker; pending: Map; + sourceCache: Map; + startupError?: WorkerStartupError; +}; + +type RouteModuleResultCacheEntry = { + source: string; + result: Promise; }; class WorkerStartupError extends Error { @@ -58,26 +75,68 @@ class WorkerStartupError extends Error { } } +const DEFAULT_RESERVED_CORES = 2; +const DEFAULT_MIN_PARALLEL_ROUTES = 128; const DEFAULT_MAX_WORKERS = 8; +const DEFAULT_ROUTE_MAX_WORKERS = 6; +const DEFAULT_SPLIT_ROUTE_MAX_WORKERS = 2; +const DEFAULT_LARGE_ROUTE_MIN_ROUTES = 1024; +const DEFAULT_LARGE_ROUTE_MAX_WORKERS = 2; +const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; +const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; + +const getAvailableCpuCount = (): number => + typeof availableParallelism === 'function' + ? availableParallelism() + : cpus().length; + +export const getDefaultWorkerCount = ( + cpuCount: number = getAvailableCpuCount(), + { + routeCount, + splitRouteModules = false, + }: Pick< + RouteTransformExecutorOptions, + 'routeCount' | 'splitRouteModules' + > = {} +): number => { + if ( + typeof routeCount === 'number' && + routeCount < DEFAULT_MIN_PARALLEL_ROUTES + ) { + return 0; + } -const getDefaultWorkerCount = (): number => { - const cpuCount = - typeof availableParallelism === 'function' - ? availableParallelism() - : cpus().length; - return Math.max(1, Math.min(DEFAULT_MAX_WORKERS, cpuCount)); + const maxWorkers = + typeof routeCount === 'number' && + routeCount >= DEFAULT_LARGE_ROUTE_MIN_ROUTES + ? DEFAULT_LARGE_ROUTE_MAX_WORKERS + : splitRouteModules + ? DEFAULT_SPLIT_ROUTE_MAX_WORKERS + : typeof routeCount === 'number' + ? DEFAULT_ROUTE_MAX_WORKERS + : DEFAULT_MAX_WORKERS; + const workerCount = Math.floor(cpuCount) - DEFAULT_RESERVED_CORES; + if (workerCount < 2) { + return 0; + } + return Math.min(maxWorkers, workerCount); }; const getConfiguredWorkerCount = ( - parallelTransforms: ParallelTransformsConfig + parallelTransforms: ParallelTransformsConfig, + options: Pick< + RouteTransformExecutorOptions, + 'routeCount' | 'splitRouteModules' + > ): number => { if (parallelTransforms === true) { - return getDefaultWorkerCount(); + return getDefaultWorkerCount(undefined, options); } const configured = parallelTransforms.maxWorkers; if (configured === undefined) { - return getDefaultWorkerCount(); + return getDefaultWorkerCount(undefined, options); } if (!Number.isFinite(configured) || configured < 1) { throw new Error( @@ -110,14 +169,27 @@ const createWorkerUrl = (): URL => const isWorkerStartupError = (error: unknown): error is WorkerStartupError => error instanceof WorkerStartupError; +const canShareRouteModuleBuildResult = (task: RouteTransformTask): boolean => + task.kind === 'routeModule' && + task.isBuild && + task.ssr && + !task.isSpaMode && + !SERVER_ONLY_ROUTE_EXPORTS.some(exportName => task.code.includes(exportName)); + class ParallelRouteTransformExecutor implements RouteTransformExecutor { #closed = false; #nextId = 1; + #nextRouteModuleWorkerIndex = 0; + #nextSplitRouteAnalysisWorkerIndex = 0; + #routeModuleResultCache = new Map(); + #splitRouteAnalysisWorkers = new Map(); #workers: WorkerState[]; constructor( workerCount: number, - private readonly options: RouteTransformTaskOptions + private readonly options: RouteTransformTaskOptions, + private readonly balanceRouteModuleTransforms: boolean, + private readonly shareRouteModuleBuildResults: boolean ) { this.#workers = Array.from({ length: workerCount }, () => this.#createWorkerState() @@ -129,6 +201,13 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { return executeRouteTransformTask(task, this.options); } + if ( + this.shareRouteModuleBuildResults && + canShareRouteModuleBuildResult(task) + ) { + return this.#runCachedRouteModuleBuildTask(task); + } + try { return await this.#runInWorker(task); } catch (error) { @@ -162,6 +241,7 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { const state: WorkerState = { worker, pending: new Map(), + sourceCache: new Map(), }; worker.on('message', (response: WorkerResponse) => { @@ -180,6 +260,7 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { worker.on('error', (error: Error) => { const startupError = new WorkerStartupError(error.message); startupError.stack = error.stack; + state.startupError = startupError; for (const pending of state.pending.values()) { pending.reject(startupError); } @@ -193,6 +274,7 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { const startupError = new WorkerStartupError( `Route transform worker exited with code ${code}.` ); + state.startupError = startupError; for (const pending of state.pending.values()) { pending.reject(startupError); } @@ -202,34 +284,158 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { return state; } + #runCachedRouteModuleBuildTask( + task: RouteTransformTask + ): Promise { + const cacheKey = task.resourcePath; + const cached = this.#routeModuleResultCache.get(cacheKey); + if (cached?.source === task.code) { + return cached.result; + } + + if ( + !this.#routeModuleResultCache.has(cacheKey) && + this.#routeModuleResultCache.size >= MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES + ) { + const oldestKey = this.#routeModuleResultCache.keys().next().value; + if (oldestKey !== undefined) { + this.#routeModuleResultCache.delete(oldestKey); + } + } + + const result = this.#runInWorker(task).catch(error => { + if (this.#routeModuleResultCache.get(cacheKey)?.result === result) { + this.#routeModuleResultCache.delete(cacheKey); + } + if (isWorkerStartupError(error)) { + return executeRouteTransformTask(task, this.options); + } + throw error; + }); + this.#routeModuleResultCache.set(cacheKey, { + source: task.code, + result, + }); + return result; + } + #runInWorker(task: RouteTransformTask): Promise { - const workerIndex = - hashString(task.resourcePath) % Math.max(1, this.#workers.length); + const workerIndex = this.#getWorkerIndex(task); const state = this.#workers[workerIndex]; if (!state) { return executeRouteTransformTask(task, this.options); } + if (state.startupError) { + return Promise.reject(state.startupError); + } const id = this.#nextId++; + const sourceCacheKey = task.resourcePath; + const requestTask = this.#createWorkerRequestTask( + state, + task, + sourceCacheKey + ); return new Promise((resolve, reject) => { state.pending.set(id, { resolve, reject }); - state.worker.postMessage({ id, task }); + state.worker.postMessage({ + id, + task: requestTask, + sourceCacheKey, + } satisfies WorkerRequest); }); } + + #createWorkerRequestTask( + state: WorkerState, + task: RouteTransformTask, + sourceCacheKey: string + ): WorkerRequest['task'] { + const cachedSource = state.sourceCache.get(sourceCacheKey); + if (cachedSource === task.code) { + const { code: _code, ...cachedTask } = task; + return cachedTask; + } + + if ( + !state.sourceCache.has(sourceCacheKey) && + state.sourceCache.size >= MAX_WORKER_SOURCE_CACHE_ENTRIES + ) { + const oldestKey = state.sourceCache.keys().next().value; + if (oldestKey !== undefined) { + state.sourceCache.delete(oldestKey); + } + } + state.sourceCache.set(sourceCacheKey, task.code); + return task; + } + + #getWorkerIndex(task: RouteTransformTask): number { + const workerCount = Math.max(1, this.#workers.length); + if ( + this.balanceRouteModuleTransforms && + (task.kind === 'routeClientEntry' || + task.kind === 'routeChunk' || + task.kind === 'splitRouteExports') + ) { + const existingWorkerIndex = this.#splitRouteAnalysisWorkers.get( + task.resourcePath + ); + if (existingWorkerIndex !== undefined) { + return existingWorkerIndex % workerCount; + } + const workerIndex = this.#nextSplitRouteAnalysisWorkerIndex % workerCount; + this.#nextSplitRouteAnalysisWorkerIndex += 1; + this.#splitRouteAnalysisWorkers.set(task.resourcePath, workerIndex); + return workerIndex; + } + if ( + this.balanceRouteModuleTransforms && + task.kind === 'routeModule' && + !(task.environmentName === 'web' && !task.ssr && task.isSpaMode) + ) { + const workerIndex = this.#nextRouteModuleWorkerIndex % workerCount; + this.#nextRouteModuleWorkerIndex += 1; + return workerIndex; + } + return hashString(task.resourcePath) % workerCount; + } } export const createRouteTransformExecutor = ({ parallelTransforms, routeChunkCache, + routeCount, + splitRouteModules, }: RouteTransformExecutorOptions = {}): RouteTransformExecutor => { const options = { routeChunkCache }; - if (!parallelTransforms) { + const effectiveParallelTransforms = parallelTransforms ?? true; + if (!effectiveParallelTransforms) { + return { + run: task => executeRouteTransformTask(task, options), + close: async () => {}, + }; + } + + const workerCount = getConfiguredWorkerCount(effectiveParallelTransforms, { + routeCount, + splitRouteModules, + }); + if (workerCount < 1) { return { run: task => executeRouteTransformTask(task, options), close: async () => {}, }; } - const workerCount = getConfiguredWorkerCount(parallelTransforms); - return new ParallelRouteTransformExecutor(workerCount, options); + return new ParallelRouteTransformExecutor( + workerCount, + options, + Boolean(splitRouteModules), + Boolean( + splitRouteModules && + typeof routeCount === 'number' && + routeCount >= DEFAULT_LARGE_ROUTE_MIN_ROUTES + ) + ); }; diff --git a/src/performance.ts b/src/performance.ts index 4e46a0a..fb9adf6 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -13,7 +13,11 @@ type OperationTiming = { type OperationInterval = { startMs: number; endMs: number }; -type MutableOperationTiming = Omit & { +type MutableOperationTiming = Omit & { + slowest: Array<{ + durationMs: number; + resource: string; + }>; intervals: OperationInterval[]; }; @@ -21,6 +25,30 @@ type EnvironmentTimings = Map; const MAX_SLOWEST_ENTRIES = 5; +const insertSlowestEntry = ( + slowest: MutableOperationTiming['slowest'], + entry: MutableOperationTiming['slowest'][number] +) => { + if ( + slowest.length === MAX_SLOWEST_ENTRIES && + entry.durationMs <= slowest[slowest.length - 1].durationMs + ) { + return; + } + + let insertIndex = slowest.length; + while ( + insertIndex > 0 && + entry.durationMs > slowest[insertIndex - 1].durationMs + ) { + insertIndex -= 1; + } + slowest.splice(insertIndex, 0, entry); + if (slowest.length > MAX_SLOWEST_ENTRIES) { + slowest.pop(); + } +}; + export const roundMs = (value: number): number => Math.round(value * 10) / 10; export type ReactRouterPerformanceReport = { @@ -112,10 +140,13 @@ export const createReactRouterPerformanceProfiler = ({ timing: MutableOperationTiming ): OperationTiming => ({ count: timing.count, - totalMs: timing.totalMs, + totalMs: roundMs(timing.totalMs), wallMs: computeWallMs(timing.intervals), - maxMs: timing.maxMs, - slowest: timing.slowest, + maxMs: roundMs(timing.maxMs), + slowest: timing.slowest.map(entry => ({ + durationMs: roundMs(entry.durationMs), + resource: entry.resource, + })), }); const recordDuration = ( @@ -125,17 +156,16 @@ export const createReactRouterPerformanceProfiler = ({ startMs: number, endMs: number ) => { - const roundedDuration = roundMs(endMs - startMs); + const duration = endMs - startMs; const timing = getOperationTiming(environment, operation); timing.count += 1; - timing.totalMs = roundMs(timing.totalMs + roundedDuration); - timing.maxMs = Math.max(timing.maxMs, roundedDuration); + timing.totalMs += duration; + timing.maxMs = Math.max(timing.maxMs, duration); timing.intervals.push({ startMs, endMs }); - timing.slowest.push({ durationMs: roundedDuration, resource }); - timing.slowest.sort((a, b) => b.durationMs - a.durationMs); - if (timing.slowest.length > MAX_SLOWEST_ENTRIES) { - timing.slowest.pop(); - } + insertSlowestEntry(timing.slowest, { + durationMs: duration, + resource, + }); }; return { @@ -147,10 +177,18 @@ export const createReactRouterPerformanceProfiler = ({ const resolvedEnvironment = environment ?? 'unknown'; const start = performance.now(); try { - return callback().finally(() => { - const end = performance.now(); - recordDuration(resolvedEnvironment, operation, resource, start, end); - }); + return callback().then( + result => { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + return result; + }, + error => { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + throw error; + } + ); } catch (error) { const end = performance.now(); recordDuration(resolvedEnvironment, operation, resource, start, end); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 7521d6f..1ba9166 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -562,11 +562,76 @@ const removeNewlyDeadTopLevelDeclarations = ( }); }; +const hasRemovableExport = ( + program: AnyNode, + exportsToRemove: ReadonlySet +): boolean => { + for (const statement of program.body ?? []) { + if (statement.type === 'ExportAllDeclaration') { + const exportedName = statement.exported + ? getExportedName({ exported: statement.exported }) + : null; + if (!exportedName || exportsToRemove.has(exportedName)) { + return true; + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + if (exportsToRemove.has('default')) { + return true; + } + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.type !== 'ExportSpecifier') { + continue; + } + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemove.has(exportedName)) { + return true; + } + } + + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + for (const name of getPatternIdentifierNames(declarator.id)) { + if (exportsToRemove.has(name)) { + return true; + } + } + } + continue; + } + + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemove.has(declaration.id.name) + ) { + return true; + } + } + return false; +}; + export const removeExports = ( ast: ParseResult | AnyNode, - exportsToRemove: readonly string[] -): void => { + exportsToRemove: readonly string[], + exportsToRemoveSet: ReadonlySet = new Set(exportsToRemove) +): boolean => { const program = getProgram(ast); + if (!hasRemovableExport(program, exportsToRemoveSet)) { + return false; + } + const declarationGraph = createTopLevelDeclarationGraph(program); const previouslyLive = collectLiveTopLevelDeclarations( program, @@ -591,7 +656,7 @@ export const removeExports = ( const exportedName = statement.exported ? getExportedName({ exported: statement.exported }) : null; - if (!exportedName || exportsToRemove.includes(exportedName)) { + if (!exportedName || exportsToRemoveSet.has(exportedName)) { exportsChanged = true; removeFromArray(program.body, statement); } @@ -606,7 +671,7 @@ export const removeExports = ( return true; } const exportedName = getExportedName(specifier); - if (exportedName && exportsToRemove.includes(exportedName)) { + if (exportedName && exportsToRemoveSet.has(exportedName)) { exportsChanged = true; if (specifier.local?.name) { removedExportLocalNames.add(specifier.local.name); @@ -627,7 +692,7 @@ export const removeExports = ( declaration.declarations = declaration.declarations.filter( (declarator: AnyNode) => { if (declarator.id.type === 'Identifier') { - if (exportsToRemove.includes(declarator.id.name)) { + if (exportsToRemoveSet.has(declarator.id.name)) { exportsChanged = true; removedExportLocalNames.add(declarator.id.name); removedExportReferencedNames.add(declarator.id.name); @@ -650,7 +715,7 @@ export const removeExports = ( (declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') && declaration.id?.name && - exportsToRemove.includes(declaration.id.name) + exportsToRemoveSet.has(declaration.id.name) ) { exportsChanged = true; removedExportLocalNames.add(declaration.id.name); @@ -662,7 +727,7 @@ export const removeExports = ( if ( statement.type === 'ExportDefaultDeclaration' && - exportsToRemove.includes('default') + exportsToRemoveSet.has('default') ) { exportsChanged = true; const declaration = statement.declaration; @@ -700,6 +765,8 @@ export const removeExports = ( removedExportReferencedNames ); } + + return exportsChanged; }; export const removeUnusedImports = (ast: ParseResult | AnyNode): void => { @@ -792,26 +859,98 @@ const variableDeclaration = (name: string, init: AnyNode): AnyNode => ({ ], }); -const collectUsedNames = (program: AnyNode): Set => { - const names = new Set(); - walk(program as any, { - Identifier(node: AnyNode) { - names.add(node.name); - }, - }); - return names; +const patternIncludesName = ( + pattern: AnyNode | null | undefined, + name: string +): boolean => { + if (!pattern) { + return false; + } + if (pattern.type === 'Identifier') { + return pattern.name === name; + } + if (pattern.type === 'RestElement') { + return patternIncludesName(pattern.argument, name); + } + if (pattern.type === 'AssignmentPattern') { + return patternIncludesName(pattern.left, name); + } + if (pattern.type === 'ArrayPattern') { + return (pattern.elements ?? []).some((element: AnyNode | null) => + patternIncludesName(element, name) + ); + } + if (pattern.type === 'ObjectPattern') { + return (pattern.properties ?? []).some((property: AnyNode) => + property.type === 'RestElement' + ? patternIncludesName(property.argument, name) + : patternIncludesName(property.value, name) + ); + } + return false; +}; + +const declarationIncludesName = ( + declaration: AnyNode, + name: string +): boolean => { + if (declaration.type === 'VariableDeclaration') { + return (declaration.declarations ?? []).some((declarator: AnyNode) => + patternIncludesName(declarator.id, name) + ); + } + if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration') && + declaration.id?.name + ) { + return declaration.id.name === name; + } + if (declaration.type === 'ImportDeclaration') { + return (declaration.specifiers ?? []).some( + (specifier: AnyNode) => specifier.local?.name === name + ); + } + return false; +}; + +const hasTopLevelBindingName = (program: AnyNode, name: string): boolean => { + for (const statement of program.body ?? []) { + if (statement.type === 'ImportDeclaration') { + if (declarationIncludesName(statement, name)) { + return true; + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + if (statement.declaration?.id?.name === name) { + return true; + } + continue; + } + + const declaration = + statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement; + if (declaration && declarationIncludesName(declaration, name)) { + return true; + } + } + return false; }; export const transformRoute = (ast: ParseResult | AnyNode): void => { const program = getProgram(ast); - const usedNames = collectUsedNames(program); + const usedNames = new Set(); const hocs: Array<[string, string]> = []; const componentWrapperDeclarations: AnyNode[] = []; function getUid(name: string) { let uid = `_${name}`; let index = 2; - while (usedNames.has(uid)) { + while (usedNames.has(uid) || hasTopLevelBindingName(program, uid)) { uid = `_${name}${index++}`; } usedNames.add(uid); @@ -843,7 +982,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { ? toFunctionExpression(declaration) : declaration?.type === 'ClassDeclaration' ? toClassExpression(declaration) - : declaration; + : declaration; if (expr) { const uid = getHocUid('withComponentProps'); statement.declaration = callExpression(uid, [expr]); @@ -882,7 +1021,10 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { } for (const specifier of statement.specifiers ?? []) { - if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') { + if ( + specifier.type !== 'ExportSpecifier' || + specifier.exportKind === 'type' + ) { continue; } const exportedName = getExportedName(specifier); diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index eaa89dd..d577a63 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -2,16 +2,14 @@ import { CLIENT_ROUTE_EXPORTS_SET, SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; -import { - getBundlerRouteAnalysis, - getExportNames, - transformToEsm, -} from './export-utils.js'; +import { getExportNames } from './export-utils.js'; import { buildEnforceChunkValidity, + detectRouteChunksIfEnabled, emptyRouteChunkSnippet, getRouteChunkIfEnabled, getRouteChunkNameFromModuleId, + shouldAnalyzeRouteChunks, validateRouteChunks, type RouteChunkCache, type RouteChunkConfig, @@ -83,17 +81,25 @@ export const createRouteClientEntryArtifact = async ({ routeChunkCache, routeChunkConfig, }: RouteClientEntryArtifactOptions): Promise => { - const analysis = await getBundlerRouteAnalysis(code, resourcePath); const isServer = environmentName === 'node'; - const splitRouteModules = routeChunkConfig.splitRouteModules; - const chunkedExports = - !isServer && isBuild && splitRouteModules - ? (await analysis.getRouteChunkInfo(routeChunkCache, routeChunkConfig)) - .chunkedExports - : []; + const mightHaveRouteChunks = + !isServer && + isBuild && + shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code); + const routeChunkInfo = mightHaveRouteChunks + ? await detectRouteChunksIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + code + ) + : null; + const exportNames = + routeChunkInfo?.exportNames ?? (await getExportNames(code)); + const chunkedExports = routeChunkInfo?.chunkedExports ?? []; return { code: buildRouteClientEntryCode({ - exportNames: analysis.exportNames, + exportNames, chunkedExports, isServer, resourcePath, @@ -128,13 +134,12 @@ export const createRouteChunkArtifact = async ({ }; } - const transformed = await transformToEsm(code, resourcePath); const chunk = await getRouteChunkIfEnabled( routeChunkCache, routeChunkConfig, resourcePath, chunkName, - transformed + code ); if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) { diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 154cc6b..ef7eee9 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -31,6 +31,7 @@ type RouteChunkCacheEntry = { export type RouteChunkCache = Map>; export type RouteChunkInfo = { + exportNames: string[]; hasRouteChunks: boolean; hasRouteChunkByExportName: Record; chunkedExports: RouteChunkExportName[]; @@ -48,6 +49,9 @@ export const routeChunkNames: RouteChunkName[] = [ ...routeChunkExportNames, ]; +export const mightContainRouteChunkExportName = (source: string): boolean => + routeChunkExportNames.some(exportName => source.includes(exportName)); + const createRouteChunkExportMap = ( getValue: (exportName: RouteChunkExportName) => boolean ): Record => @@ -55,8 +59,7 @@ const createRouteChunkExportMap = ( routeChunkExportNames.map(exportName => [exportName, getValue(exportName)]) ) as Record; -export const emptyRouteChunkSnippet = (reason: string): string => - `Math.random()<0&&console.log(${JSON.stringify(reason)});`; +export const emptyRouteChunkSnippet = (_reason: string): string => 'export {};'; const routeChunkQueryStringPrefix = '?route-chunk='; @@ -67,6 +70,7 @@ const routeChunkQueryStrings: Record = { clientMiddleware: `${routeChunkQueryStringPrefix}clientMiddleware`, HydrateFallback: `${routeChunkQueryStringPrefix}HydrateFallback`, }; +const routeChunkQueryStringValues = Object.values(routeChunkQueryStrings); const routeChunkEntrySuffix: Record = { clientAction: 'client-action', @@ -99,6 +103,12 @@ const getOrSetFromCache = ( return value; }; +const hasCachedValue = ( + cache: RouteChunkCache, + key: string, + version: string +): boolean => cache.get(key)?.version === version; + type AnalyzedModule = { module: Module; // Dependency sets use these node identities. Consumers must shallow-copy @@ -149,21 +159,6 @@ const getTopLevelStatementForNode = ( return current; }; -const addTopLevelStatement = ( - module: Module, - dependencies: ExportDependencies, - node: AnyNode -) => { - const statement = getTopLevelStatementForNode(module, node); - dependencies.topLevelStatements.add(statement); - if ( - statement.type !== 'ImportDeclaration' && - !statement.type.startsWith('Export') - ) { - dependencies.topLevelNonModuleStatements.add(statement); - } -}; - const getVariableDeclaratorForNode = ( module: Module, node: AnyNode @@ -212,6 +207,44 @@ const getExportDependencies = ( () => { const { module } = analyzeCode(code, cache, cacheKey); const exportDependencies = new Map(); + const topLevelStatementCache = new Map(); + const variableDeclaratorCache = new Map(); + + const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => { + const cached = topLevelStatementCache.get(node); + if (cached) { + return cached; + } + const statement = getTopLevelStatementForNode(module, node); + topLevelStatementCache.set(node, statement); + return statement; + }; + + const getCachedVariableDeclaratorForNode = ( + node: AnyNode + ): AnyNode | null => { + if (variableDeclaratorCache.has(node)) { + return variableDeclaratorCache.get(node) ?? null; + } + const declarator = getVariableDeclaratorForNode(module, node); + variableDeclaratorCache.set(node, declarator); + return declarator; + }; + + const addCachedTopLevelStatement = ( + dependencies: ExportDependencies, + node: AnyNode + ) => { + const statement = getCachedTopLevelStatementForNode(node); + dependencies.topLevelStatements.add(statement); + if ( + statement.type !== 'ImportDeclaration' && + !statement.type.startsWith('Export') + ) { + dependencies.topLevelNonModuleStatements.add(statement); + } + return statement; + }; const handleExport = ( exportName: string, @@ -249,18 +282,17 @@ const getExportDependencies = ( visitedSymbols.add(symbol); for (const declaration of symbol.declarations as AnyNode[]) { - const statement = getTopLevelStatementForNode(module, declaration); - addTopLevelStatement(module, dependencies, declaration); + const statement = addCachedTopLevelStatement( + dependencies, + declaration + ); if (statement.type === 'ImportDeclaration') { dependencies.importedIdentifierNames.add(symbol.name); } - const declarator = getVariableDeclaratorForNode( - module, - declaration - ); + const declarator = getCachedVariableDeclaratorForNode(declaration); if ( declarator && - getTopLevelStatementForNode(module, declarator).type === + getCachedTopLevelStatementForNode(declarator).type === 'ExportNamedDeclaration' ) { dependencies.exportedVariableDeclarators.add(declarator); @@ -269,25 +301,23 @@ const getExportDependencies = ( } for (const reference of symbol.references as any[]) { - const statement = getTopLevelStatementForNode( - module, + const statement = addCachedTopLevelStatement( + dependencies, reference.node ); - addTopLevelStatement(module, dependencies, reference.node); - const declarator = getVariableDeclaratorForNode( - module, + const declarator = getCachedVariableDeclaratorForNode( reference.node ); scanNode(declarator ?? statement); } }; - addTopLevelStatement(module, dependencies, exportNode); + addCachedTopLevelStatement(dependencies, exportNode); if (localSymbol) { visitSymbol(localSymbol); } else { - const statement = getTopLevelStatementForNode(module, exportNode); + const statement = getCachedTopLevelStatementForNode(exportNode); scanNode(statement); } @@ -306,64 +336,72 @@ const getExportDependencies = ( ); }; -const hasChunkableExport = ( - code: string, +const isExportChunkable = ( exportName: string, - cache: RouteChunkCache, - cacheKey: string + exportDependencies: Map ) => { - return getOrSetFromCache( - cache, - `${cacheKey}::hasChunkableExport::${exportName}`, - code, - () => { - const exportDependencies = getExportDependencies(code, cache, cacheKey); - const dependencies = exportDependencies.get(exportName); - if (!dependencies) { - return false; - } - for (const [ - currentExportName, - currentDependencies, - ] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.topLevelNonModuleStatements, - dependencies.topLevelNonModuleStatements - ) - ) { - return false; - } + const dependencies = exportDependencies.get(exportName); + if (!dependencies) { + return false; + } + for (const [currentExportName, currentDependencies] of exportDependencies) { + if (currentExportName === exportName) { + continue; + } + if ( + setsIntersect( + currentDependencies.topLevelNonModuleStatements, + dependencies.topLevelNonModuleStatements + ) + ) { + return false; + } + } + if (dependencies.exportedVariableDeclarators.size > 1) { + return false; + } + if (dependencies.exportedVariableDeclarators.size > 0) { + for (const [currentExportName, currentDependencies] of exportDependencies) { + if (currentExportName === exportName) { + continue; } - if (dependencies.exportedVariableDeclarators.size > 1) { + if ( + setsIntersect( + currentDependencies.exportedVariableDeclarators, + dependencies.exportedVariableDeclarators + ) + ) { return false; } - if (dependencies.exportedVariableDeclarators.size > 0) { - for (const [ - currentExportName, - currentDependencies, - ] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.exportedVariableDeclarators, - dependencies.exportedVariableDeclarators - ) - ) { - return false; - } - } - } - return true; } - ); + } + return true; }; +const getChunkableExportMap = ( + code: string, + cache: RouteChunkCache, + cacheKey: string +): Record => + getOrSetFromCache(cache, `${cacheKey}::getChunkableExportMap`, code, () => { + const exportDependencies = getExportDependencies(code, cache, cacheKey); + return createRouteChunkExportMap(exportName => + isExportChunkable(exportName, exportDependencies) + ); + }); + +const hasChunkableExport = ( + code: string, + exportName: string, + cache: RouteChunkCache, + cacheKey: string +) => + (routeChunkExportNames as string[]).includes(exportName) + ? getChunkableExportMap(code, cache, cacheKey)[ + exportName as RouteChunkExportName + ] + : false; + const generateCode = (program: AnyNode): string | undefined => { if (program.body.length === 0) { return undefined; @@ -465,6 +503,19 @@ const getChunkedExport = ( ); }; +const getChunkedExportCacheKey = ( + cacheKey: string, + exportName: RouteChunkExportName +) => `${cacheKey}::getChunkedExport::${exportName}`; + +const hasCachedChunkedExport = ( + code: string, + exportName: RouteChunkExportName, + cache: RouteChunkCache, + cacheKey: string +): boolean => + hasCachedValue(cache, getChunkedExportCacheKey(cacheKey, exportName), code); + const omitChunkedExports = ( code: string, exportNames: string[], @@ -476,10 +527,11 @@ const omitChunkedExports = ( `${cacheKey}::omitChunkedExports::${exportNames.join(',')}`, code, () => { - const isChunkable = (exportName: string) => - hasChunkableExport(code, exportName, cache, cacheKey); + const chunkableExportMap = getChunkableExportMap(code, cache, cacheKey); + const exportNameSet = new Set(exportNames); const isOmitted = (exportName: string) => - exportNames.includes(exportName) && isChunkable(exportName); + exportNameSet.has(exportName) && + Boolean(chunkableExportMap[exportName as RouteChunkExportName]); const isRetained = (exportName: string) => !isOmitted(exportName); const exportDependencies = getExportDependencies(code, cache, cacheKey); @@ -489,6 +541,8 @@ const omitChunkedExports = ( const omittedStatements = new Set(); const omittedExportedVariableDeclarators = new Set(); + const retainedImportedIdentifierNames = new Set(); + const omittedImportedIdentifierNames = new Set(); for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); @@ -502,6 +556,19 @@ const omitChunkedExports = ( for (const declarator of dependencies.exportedVariableDeclarators) { omittedExportedVariableDeclarators.add(declarator); } + for (const importedName of dependencies.importedIdentifierNames) { + omittedImportedIdentifierNames.add(importedName); + } + } + + for (const retainedExportName of retainedExportNames) { + const dependencies = exportDependencies.get(retainedExportName); + if (!dependencies) { + continue; + } + for (const importedName of dependencies.importedIdentifierNames) { + retainedImportedIdentifierNames.add(importedName); + } } const program = analyzeCode(code, cache, cacheKey).program; @@ -512,18 +579,8 @@ const omitChunkedExports = ( return node; } return filterImportSpecifiers(node, importedName => { - for (const retainedExportName of retainedExportNames) { - const dependencies = exportDependencies.get(retainedExportName); - if (dependencies?.importedIdentifierNames.has(importedName)) { - return true; - } - } - for (const omittedExportName of omittedExportNames) { - const dependencies = exportDependencies.get(omittedExportName); - if (dependencies?.importedIdentifierNames.has(importedName)) { - return false; - } - } + if (retainedImportedIdentifierNames.has(importedName)) return true; + if (omittedImportedIdentifierNames.has(importedName)) return false; return true; }); }) @@ -573,20 +630,44 @@ const omitChunkedExports = ( ); }; +const precomputeChunkedExports = ( + code: string, + cache: RouteChunkCache, + cacheKey: string +) => { + const chunkableExportMap = getChunkableExportMap(code, cache, cacheKey); + for (const exportName of routeChunkExportNames) { + if (!chunkableExportMap[exportName]) { + continue; + } + if (!hasCachedChunkedExport(code, exportName, cache, cacheKey)) { + getChunkedExport(code, exportName, cache, cacheKey); + } + } +}; + export const detectRouteChunks = ( code: string, cache: RouteChunkCache | undefined, cacheKey: string ): RouteChunkInfo => { const analysisCache = cache ?? new Map(); - const hasRouteChunkByExportName = createRouteChunkExportMap(exportName => - hasChunkableExport(code, exportName, analysisCache, cacheKey) + const exportDependencies = getExportDependencies( + code, + analysisCache, + cacheKey + ); + const hasRouteChunkByExportName = getChunkableExportMap( + code, + analysisCache, + cacheKey ); const chunkedExports = Object.entries(hasRouteChunkByExportName) .filter(([, isChunked]) => isChunked) .map(([exportName]) => exportName as RouteChunkExportName); const hasRouteChunks = chunkedExports.length > 0; return { + exportNames: Array.from(exportDependencies.keys()), hasRouteChunks, hasRouteChunkByExportName, chunkedExports, @@ -613,6 +694,9 @@ export const getRouteChunkCode: ( cacheKey ); } + if (!hasCachedChunkedExport(code, chunkName, analysisCache, cacheKey)) { + precomputeChunkedExports(code, analysisCache, cacheKey); + } return getChunkedExport(code, chunkName, analysisCache, cacheKey); }; @@ -622,9 +706,7 @@ export const getRouteChunkModuleId = ( ) => `${filePath}${routeChunkQueryStrings[chunkName]}`; export const isRouteChunkModuleId: (id: string) => boolean = (id: string) => - Object.values(routeChunkQueryStrings).some(queryString => - id.endsWith(queryString) - ); + routeChunkQueryStringValues.some(queryString => id.endsWith(queryString)); const isRouteChunkName = (name: string): name is RouteChunkName => name === 'main' || (routeChunkExportNames as string[]).includes(name); @@ -632,10 +714,16 @@ const isRouteChunkName = (name: string): name is RouteChunkName => export const getRouteChunkNameFromModuleId = ( id: string ): RouteChunkName | null => { - if (!id.includes(routeChunkQueryStringPrefix)) { + const queryIndex = id.indexOf(routeChunkQueryStringPrefix); + if (queryIndex === -1) { return null; } - const chunkName = id.split(routeChunkQueryStringPrefix)[1].split('&')[0]; + const chunkNameStart = queryIndex + routeChunkQueryStringPrefix.length; + const chunkNameEnd = id.indexOf('&', chunkNameStart); + const chunkName = id.slice( + chunkNameStart, + chunkNameEnd === -1 ? undefined : chunkNameEnd + ); if (!isRouteChunkName(chunkName)) { return null; } @@ -651,6 +739,15 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; +export const shouldAnalyzeRouteChunks = ( + config: RouteChunkConfig, + id: string, + code: string +): boolean => + Boolean(config.splitRouteModules) && + mightContainRouteChunkExportName(code) && + !isRootRouteModuleId(config, id); + export const createEmptyRouteChunkByExportName = (): Record< RouteChunkExportName, boolean @@ -686,18 +783,13 @@ export const detectRouteChunksIfEnabled: ( code: string ) => { const noRouteChunks = (): RouteChunkInfo => ({ + exportNames: [], chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, hasRouteChunkByExportName: createEmptyRouteChunkByExportName(), }); - if (!config.splitRouteModules) { - return noRouteChunks(); - } - if (isRootRouteModuleId(config, id)) { - return noRouteChunks(); - } - if (!routeChunkExportNames.some(exportName => code.includes(exportName))) { + if (!shouldAnalyzeRouteChunks(config, id, code)) { return noRouteChunks(); } @@ -721,6 +813,13 @@ export const getRouteChunkIfEnabled: ( if (!config.splitRouteModules) { return null; } + if (chunkName === 'main') { + if (!mightContainRouteChunkExportName(code)) { + return code; + } + } else if (!code.includes(chunkName)) { + return null; + } const cacheKey = normalizeRelativeFilePath(id, config.appDirectory); return getRouteChunkCode(code, chunkName, cache, cacheKey) ?? null; }; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index d2beb7f..7375304 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from 'node:fs'; +import { statSync, type Stats } from 'node:fs'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; @@ -10,7 +10,8 @@ import { SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { - getBundlerRouteAnalysis, + collectProgramExportNames, + getExportNamesAndExportAll, getRouteModuleAnalysis, } from './export-utils.js'; import { @@ -23,6 +24,7 @@ import { createRouteClientEntryArtifact, } from './route-artifacts.js'; import { + detectRouteChunksIfEnabled, getRouteChunkModuleId, type RouteChunkCache, type RouteChunkConfig, @@ -30,7 +32,7 @@ import { export type RouteTransformResult = { code: string; - map?: any; + map?: ReturnType['map']; }; type BaseRouteTransformTask = { @@ -66,6 +68,7 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & { resource: string; environmentName: string; ssr: boolean; + isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; }; @@ -86,22 +89,26 @@ const defaultRouteChunkCache: RouteChunkCache = new Map(); const getRouteChunkCache = (options?: RouteTransformTaskOptions) => options?.routeChunkCache ?? defaultRouteChunkCache; +const tryStat = (path: string): Stats | null => + statSync(path, { throwIfNoEntry: false }) ?? null; + const splitRouteExports = async ( task: SplitRouteExportsTransformTask, options?: RouteTransformTaskOptions ): Promise => { - const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); - const { hasRouteChunks, chunkedExports } = await analysis.getRouteChunkInfo( - getRouteChunkCache(options), - task.routeChunkConfig - ); + const { exportNames, hasRouteChunks, chunkedExports } = + await detectRouteChunksIfEnabled( + getRouteChunkCache(options), + task.routeChunkConfig, + task.resourcePath, + task.code + ); if (!hasRouteChunks) { return { code: task.code, map: null }; } - const sourceExports = analysis.exportNames; const chunkedExportSet = new Set(chunkedExports); - const mainChunkReexports = sourceExports + const mainChunkReexports = exportNames .filter(name => !chunkedExportSet.has(name)) .join(', '); const chunkBasePath = `./${pathBasename(task.resourcePath)}`; @@ -131,47 +138,31 @@ const splitRouteExports = async ( const resolveIndexFile = (dirPath: string): string | null => { for (const ext of JS_EXTENSIONS) { const candidate = resolve(dirPath, `index${ext}`); - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { + const stats = tryStat(candidate); + if (!stats?.isFile()) { continue; } + return candidate; } return null; }; const resolvePathWithExtensions = (basePath: string): string | null => { - if (existsSync(basePath)) { - try { - const stats = statSync(basePath); - if (stats.isFile()) { - return basePath; - } - if (stats.isDirectory()) { - return resolveIndexFile(basePath); - } - } catch { - // Ignore invalid paths and fall back to extension probing. - } + const stats = tryStat(basePath); + if (stats?.isFile()) { + return basePath; + } + if (stats?.isDirectory()) { + return resolveIndexFile(basePath); } for (const ext of JS_EXTENSIONS) { const candidate = `${basePath}${ext}`; - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { + const candidateStats = tryStat(candidate); + if (!candidateStats?.isFile()) { continue; } + return candidate; } return resolveIndexFile(basePath); @@ -202,8 +193,8 @@ const resolveExportAllModule = ( const createClientOnlyStub = async ( task: ClientOnlyStubTransformTask ): Promise => { - const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); - const { exportNames: directExportNames, exportAllModules } = analysis; + const { exportNames: directExportNames, exportAllModules } = + await getExportNamesAndExportAll(task.code); const exportNames = new Set(directExportNames); const unresolvedExportAll = new Set(); const visitedModules = new Set(); @@ -215,10 +206,8 @@ const createClientOnlyStub = async ( return; } visitedModules.add(modulePath); - const { - exports: moduleExportNames, - exportAllModules: moduleExportAll, - } = await getRouteModuleAnalysis(modulePath); + const { exports: moduleExportNames, exportAllModules: moduleExportAll } = + await getRouteModuleAnalysis(modulePath); for (const name of moduleExportNames) { if (name !== 'default') { exportNames.add(name); @@ -269,11 +258,19 @@ const createClientOnlyStub = async ( const transformRouteModule = async ( task: RouteModuleTransformTask ): Promise => { - const analysis = await getBundlerRouteAnalysis(task.code, task.resourcePath); - let code = analysis.code; + let code = task.code; + + const defaultExportMatch = code.match(/\n\s{0,}([\w\d_]+)\sas default,?/); + if (defaultExportMatch && typeof defaultExportMatch.index === 'number') { + code = + code.slice(0, defaultExportMatch.index) + + code.slice(defaultExportMatch.index + defaultExportMatch[0].length); + code += `\nexport default ${defaultExportMatch[1]};`; + } + const ast = parse(code, { sourceType: 'module' }); if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) { - const resolvedExportNames = analysis.exportNames; + const resolvedExportNames = collectProgramExportNames(ast.program); const isRootRoute = task.resourcePath === task.rootRoutePath; const relativePath = relative(process.cwd(), task.resourcePath); @@ -301,25 +298,21 @@ const transformRouteModule = async ( } } - const defaultExportMatch = code.match(/\n\s{0,}([\w\d_]+)\sas default,?/); - if (defaultExportMatch && typeof defaultExportMatch.index === 'number') { - code = - code.slice(0, defaultExportMatch.index) + - code.slice(defaultExportMatch.index + defaultExportMatch[0].length); - code += `\nexport default ${defaultExportMatch[1]};`; - } - - const ast = parse(code, { sourceType: 'module' }); - if (task.environmentName === 'web') { - removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); - } + const removedServerOnlyExports = + task.environmentName === 'web' + ? removeExports( + ast, + SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET + ) + : false; transformRoute(ast); - if (task.environmentName === 'web') { + if (removedServerOnlyExports) { removeUnusedImports(ast); } return generate(ast, { - sourceMaps: true, + sourceMaps: !task.isBuild, filename: task.resource, sourceFileName: task.resourcePath, }); diff --git a/src/types.ts b/src/types.ts index ff59892..8531ec4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,27 +31,21 @@ export type PluginOptions = { federation?: boolean; /** - * Configure Rsbuild's dev-only lazy compilation behavior. - * - * This forwards to `dev.lazyCompilation` when set and does not affect - * production builds. Route modules are loaded synchronously during hydration, - * so this remains opt-in. + * Rsbuild dev-only lazy compilation behavior. + * @default false */ lazyCompilation?: NonNullable['lazyCompilation']; /** - * Emit structured React Router plugin timing logs after each compiler - * environment finishes. + * Emit structured React Router plugin timing logs. * @default false */ logPerformance?: boolean; /** - * Run CPU-heavy route transforms in a worker-thread pool. - * - * Set to `true` to use an automatically sized pool, or pass - * `{ maxWorkers }` to cap the pool size. - * @default false + * Run route transforms in a worker-thread pool. + * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. + * @default true, inline for small route graphs or low-core CPUs; otherwise `available CPUs - 2`, capped at 8 workers, 6 workers for known large route graphs, or 2 workers for split builds and 1024+ route graphs. */ parallelTransforms?: | boolean diff --git a/task/lexer-route-export-triage.md b/task/lexer-route-export-triage.md deleted file mode 100644 index e27efbc..0000000 --- a/task/lexer-route-export-triage.md +++ /dev/null @@ -1,208 +0,0 @@ -# Lexer-assisted route export analysis triage - -Branch: `perf/bundling-performance` -Commit: `c2452de1393264c2b01ef8aa03908077bce025db` -Task: `t_a0ef9422` - -## Conclusion - -Do not implement a standalone lexer-first route-export discovery change. - -`es-module-lexer` is already in the hot path, but only after `transformToEsm` has produced parseable ESM (`src/export-utils.ts:52-81`, `src/index.ts:1377-1378`, `src/index.ts:1749-1762`). For route modules, the transform is still load-bearing for TS/TSX/JSX, default-export normalization, and route-chunk analysis. A lexer-first experiment that skips the client-entry warmup only shifts the same transform cost into `route:module`; it does not create a real build-time win. - -The smallest safe optimization path is not “lexer first”, but a unified bundler-side route analysis cache that shares `{ transformed code, export names, optional chunk info }` across the existing transform hooks while keeping `route:client-entry` as the cache warmer. - -## Code-path evidence - -Current route analysis is split across two layers: - -1. Shared helper caches in `src/export-utils.ts` - - `transformCache` keyed by `(resourcePath, source)` at `src/export-utils.ts:24-24` - - `exportNamesCache` keyed by transformed `code` at `src/export-utils.ts:25-25` - - `routeModuleAnalysisCache` keyed by `(resourcePath, mtime, size)` for disk reads at `src/export-utils.ts:26-29`, `src/export-utils.ts:130-156` - -2. Bundler hooks in `src/index.ts` - - `route:client-entry` transforms + lexes + route-chunk detects at `src/index.ts:1367-1411` - - `route:split-exports` transforms + route-chunk detects + lexes at `src/index.ts:1476-1549` - - `route:chunk` transforms + chunk-generates at `src/index.ts:1414-1474` - - `route:module` transforms + SPA export validation + default-export rewrite + Babel parse/generate at `src/index.ts:1737-1825` - -The important point is that `route:client-entry` currently warms `transformCache` before `route:module` runs. Keeping that warmup matters because `route:module` still requires transformed code for correctness work that cannot be done from a raw lexer scan. - -## Design comparison - -| Design | What changes | Upside | Why it fails / succeeds | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Current transform-warming path | Leave `route:client-entry` as `transformToEsm(args.code)` + `getExportNames(code)` and let `route:module` reuse the cache | Correct today; required transform work is paid once when `args.code` matches disk/bundler content | Still has duplicate call sites and repeated bookkeeping across hooks, but the expensive transform is already shared through `transformCache` | -| Lexer-first + transform prewarm | Discover exports earlier with `es-module-lexer`, but still fire `transformToEsm` to warm later hooks | Looks cheaper on paper if you count only export extraction | No real net win for route modules: TS/TSX/JSX cannot be lexed directly, so you still need `transformToEsm`; if you skip that warmup the cost just moves into `route:module`; if you keep it, you have nearly the same work plus more coordination | -| Unified route analysis cache | Cache bundler-side analysis once per `(resourcePath, args.code)` and reuse it across `route:client-entry`, `route:split-exports`, `route:module`, and optionally `route:chunk` | Attacks the actual duplication boundary: repeated “transform → export scan → maybe route-chunk detect” preludes | Safest real improvement path. Must preserve hook-specific post-processing and keep route-chunk work lazy/off unless needed | - -## Correctness constraints that any redesign must preserve - -1. TS / TSX / JSX / MTS inputs still require esbuild loader normalization - - `JS_LOADERS` maps `.ts/.tsx/.jsx/.js/.mjs/.mts` to esbuild loaders in `src/constants.ts:3-19`. - - `transformToEsm` depends on that loader selection in `src/export-utils.ts:47-67`. - - Raw `es-module-lexer` on source text is therefore unsafe for common route files. - -2. `route:module` still needs transformed code beyond export discovery - - SPA-mode validation reads export names from transformed code at `src/index.ts:1755-1790`. - - The default export is normalized with a regex rewrite at `src/index.ts:1792-1805` before Babel parses the module. - - Any shared cache must either return pre-rewrite transformed code plus let `route:module` keep this rewrite, or explicitly model a separate post-processed `routeModuleCode` variant. - -3. Re-export behavior is intentionally narrow for route modules - - Route-module paths use `getExportNames(code)` only (`src/export-utils.ts:83-104`, `src/index.ts:1378`, `src/index.ts:1762`). - - The only place that resolves `export * from` recursively is the `.client` stub path via `getExportNamesAndExportAll` at `src/export-utils.ts:106-127` and `src/index.ts:1588-1722`. - - A lexer-first refactor must not accidentally expand or break route-module export semantics around re-exports without an intentional product decision. - -4. Route-chunk mode depends on the same transformed code string and lazy chunk analysis - - `detectRouteChunksIfEnabled` and `getRouteChunkIfEnabled` both key off normalized file path + exact `code` string in `src/route-chunks.ts:835-889`. - - `route:client-entry`, `route:split-exports`, and `manifest.ts` all feed the same transformed code shape into that cache. - - A redesign that makes the code strings diverge will silently defeat chunk-cache reuse. - -5. Manifest/disk-path unification still has one raw-source dependency today - - `manifest.ts` uses `source` for the dev CSS fallback regex at `src/manifest.ts:191-199`. - - If future work merges disk and bundler analysis more aggressively, that fallback either needs to move to transformed `code` or remain available separately. - -## Benchmark evidence from this run - -Artifacts: - -- `.benchmark/results/triage-smoke-current/baseline.json` -- `.benchmark/results/triage-default-current/baseline.json` - -Commands run: - -```sh -node scripts/bench-builds.mjs \ - --profile smoke \ - --iterations 1 \ - --warmup 0 \ - --format both \ - --out .benchmark/results/triage-smoke-current - -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 1 \ - --warmup 0 \ - --clean build \ - --format both \ - --out .benchmark/results/triage-default-current -``` - -Observed results: - -### Smoke (48-route SSR ESM) - -- Wall: `1071.2 ms` -- Max RSS: `307152 kB` -- Web compiler lifecycle: `760.9 ms` -- Node compiler lifecycle: `845.5 ms` -- Web `route:client-entry.totalMs`: `1712.3 ms` -- Web `route:module.totalMs`: `73.6 ms` - -### 256-route non-split vs split (same run, same commit) - -Non-split `synthetic-256-ssr-esm` - -- Wall: `1937.2 ms` -- Max RSS: `501884 kB` -- Web compiler lifecycle: `1250.2 ms` -- Node compiler lifecycle: `1446.1 ms` -- Web `route:client-entry.totalMs`: `36337.2 ms` -- Web `route:module.totalMs`: `240.8 ms` - -Split `synthetic-256-ssr-esm-split` - -- Wall: `2201.0 ms` -- Max RSS: `694036 kB` -- Web compiler lifecycle: `1681.9 ms` -- Node compiler lifecycle: `1872.9 ms` -- Web `route:client-entry.totalMs`: `76313.8 ms` -- Web `route:module.totalMs`: `224.2 ms` -- Web `route:chunk.totalMs`: `84524.4 ms` - -Delta (split - non-split) - -- Wall: `+263.8 ms` (`+13.6%`) -- Max RSS: `+192152 kB` (`+38.3%`) -- Web compiler lifecycle: `+431.7 ms` -- Node compiler lifecycle: `+426.8 ms` -- Web `route:client-entry.totalMs`: `+39976.6 ms` -- Web `route:module.totalMs`: `-16.6 ms` - -Interpretation: - -- The split build’s extra cost is not showing up as a `route:module` surge. -- The big additional work is in `route:chunk` plus heavier `route:client-entry`/split-route activity. -- That makes the earlier “move lexer work out of client-entry” idea especially unconvincing: `route:module` is not the dominant split-build hotspot here, and simply relocating transform cost there is unlikely to improve total wall time. - -Important caveat: `totalMs` overcounts concurrent async spans, so the ground-truth numbers here are wall-clock and compiler lifecycle times, not the raw sums of per-resource totals. - -## Smallest safe implementation path - -If we do follow-up work, it should be this, in order: - -1. Add a bundler-side route-analysis helper/cache - - Touch: `src/export-utils.ts` or a new helper module. - - Shape: cache by `(resourcePath, args.code)` and return a promise for - `{ code, exportNames, chunkInfo? }`. - - Keep chunk info lazy so non-split routes do not pay Babel parse/traverse cost. - -2. Swap the three main hook preludes onto that helper - - Touch: `src/index.ts:1367-1411`, `src/index.ts:1476-1549`, `src/index.ts:1737-1825`. - - `route:client-entry` remains the warm path. - - `route:module` consumes the shared transformed code and keeps its SPA validation + default-export rewrite. - - `route:split-exports` consumes shared export names and shared/lazy chunk info. - -3. Only then consider manifest/prerender dedup - - Touch later: `src/manifest.ts:185-238`, `src/index.ts:758-778`. - - First move the CSS fallback off raw `source` (`src/manifest.ts:191-199`), then thread export names/analysis out of manifest generation so prerender validation stops re-walking routes. - -This is the smallest path that can plausibly reduce real work instead of shuffling it between hooks. - -## Recommendation - -Reject a standalone lexer-first route-export-discovery change as “not worth it”. - -Recommended follow-up instead: - -- Implement a unified bundler-side route analysis cache. -- Measure it with the existing harness. -- Keep the disk/manifest-side dedup as a second phase only after the bundler-side helper proves a wall-clock win. - -Suggested follow-up card title: - -- `Implement unified bundler-side route analysis cache (keep client-entry transform warmup)` - -Suggested benchmark commands for that future A/B: - -```sh -# quick correctness / smoke -node scripts/bench-builds.mjs \ - --profile smoke \ - --iterations 1 \ - --warmup 0 \ - --format both \ - --out .benchmark/results/-smoke - -# canonical 256-route comparison: compare split and non-split rows from the same JSON -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 5 \ - --warmup 1 \ - --clean build \ - --format both \ - --out .benchmark/results/-baseline -``` - -For final sign-off, the stronger profile from the existing methodology docs is still appropriate: - -```sh -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 8 \ - --warmup 2 \ - --clean build \ - --format both \ - --out .benchmark/results/-final -``` diff --git a/task/route-chunk-correctness-test-spec.md b/task/route-chunk-correctness-test-spec.md deleted file mode 100644 index 43b38d2..0000000 --- a/task/route-chunk-correctness-test-spec.md +++ /dev/null @@ -1,437 +0,0 @@ -# Route Chunk Correctness — Test Specification - -**Kanban:** `t_1c0421c6` (feeds triage `t_d3ed9b84` → plan `t_f8636ea4`) -**Branch:** `perf/bundling-performance` (PR #39) -**Status:** SPEC ONLY — no test bodies implemented. Each entry below is ready for an -implementer to write against. Behavior values marked **(verified)** were produced by -running the real `src/route-chunks.ts` functions against the listed fixtures on the -current head (`c2452de`); they are the golden values the tests must pin. - ---- - -## 0. What this spec protects - -A future change precomputes all chunk analysis for one route in a single parse/traverse -pass (see sibling tasks `t_0f2688a9`, `t_34486796`). That refactor must not change any -externally observable result. This spec defines the exact tests that lock the current -behavior so the precompute can be proven equivalent. - -**The invariant, stated once:** For every route module, the triple -(detection result `RouteChunkInfo`, generated chunk code per `RouteChunkName`, -consumer-visible output in the rspack transforms and the React Router manifest) must be -byte-for-byte identical before and after the precompute refactor, across all five -dimensions: per-export splits, enforce mode, root route, empty/no-split modules, and -detection↔generation↔consumer consistency. - ---- - -## 1. Architecture recap (so tests target the right seams) - -Source: `src/route-chunks.ts`, `src/index.ts`, `src/manifest.ts`, `src/export-utils.ts`. - -``` - detectRouteChunksIfEnabled(cache, config, id, code) - ───────────────────────────────────────────────── - guards (return noRouteChunks, NO parse): ── detectRouteChunks - • config.splitRouteModules falsy ── hasChunkableExport ×4 - • isRootRouteModuleId(config, id) (getExportDependencies - • !routeChunkExportNames.some(name => code.includes(name)) one heavy traverse) - │ - getRouteChunkIfEnabled(cache, config, id, chunkName, code) - ──────────────────────────────────────────────── - • guard: config.splitRouteModules falsy (NOTE: no root guard — see §7) - • getRouteChunkCode: - 'main' → omitChunkedExports(code, allClientExports) - clientAction… → getChunkedExport(code, name) (undefined if !hasChunkableExport) - - CONSUMERS - index.ts entry creation (L433-449) substring source.includes(name) ← NOT full detect - index.ts ?react-router-route transform detectRouteChunksIfEnabled filters reexports - index.ts ?route-chunk= transform getRouteChunkIfEnabled emits chunk code - index.ts split-exports transform detectRouteChunksIfEnabled rewrites module→reexports - index.ts ?route-chunk= + enforce getExportNames(mainChunk) validateRouteChunks - manifest.ts getReactRouterManifestForDev detectRouteChunksIfEnabled sets *Module fields -``` - -Key asymmetries the tests MUST pin (these are intentional or at least load-bearing): - -- **A1** Entry creation uses a cheap `source.includes(name)` substring check, so a - non-splittable export still gets a bundler entry — but that entry resolves to an - `preventEmptyChunkSnippet` module, and the manifest omits the `*Module` field. (§8-H1) -- **A2** `getRouteChunkIfEnabled` has no root-route guard; only `detectRouteChunksIfEnabled` - does. (§7-E3) -- **A3** The substring guard in `detectRouteChunksIfEnabled` is a pre-filter; the parse - is the source of truth, so a comment mentioning `clientAction` does not create a chunk. (§6-F3) - ---- - -## 2. Verified-behavior reference table (golden values) - -Fixtures below were run through the real functions. `cfg(true)` = `{splitRouteModules:true, -appDirectory:'/app', rootRouteFile:'root.tsx'}`, id `/app/routes/r.tsx`. - -| Fixture | clientAction | clientLoader | clientMiddleware | HydrateFallback | main chunk | note | -| ---------------------------------------------------------------------------------------------- | ------------ | ------------ | ---------------- | --------------- | -------------------------------------- | --------------------------------------------- | -| one client export `export const clientAction = async () => {}` + default | true | false | false | false | omits clientAction | splittable | -| all four, each own helper + default | true | true | true | true | omits all four | splittable | -| `const helper; export default Route(){helper()}; export const clientAction=()=>helper()` | **false** | false | false | false | full module | shares top-level stmt w/ default (§4-B2) | -| `const shared; export const clientAction=()=>shared(); export const clientLoader=()=>shared()` | false | false | false | false | full module | existing test; shares helper | -| `function make(); export const { clientAction } = make()` + default | **true** | false | false | false | omits clientAction | single-bind destructure IS chunkable (§4-B3a) | -| `function make(); export const { clientAction, foo } = make()` + default | **false** | false | false | false | full module | shared declarator w/ foo (§4-B3b) | -| `export const clientAction; export const clientLoader` (no default) | true | true | false | false | **undefined** | empty main (§5-C3) | -| `import {json}; export async function action(){json()}; export default Route` | false | false | false | false | full module incl. import | no client exports (§6-G2) | -| `// clientAction in a comment` + default | false | false | false | false | full module incl. comment | substring false positive (§6-F3) | -| same clientAction code, id `/app/root.tsx` (detect) | false | false | false | false | — | root route (§7-E1) | -| same clientAction code, id `/app/root.tsx` (getRouteChunkIfEnabled 'clientAction') | — | — | — | — | generates `export const clientAction…` | root asymmetry (§7-E3) | - ---- - -## 3. File layout (where each test lives) - -| File | Type | Covers | -| -------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------- | -| `tests/route-chunks.test.ts` (EXPAND existing) | unit, pure fns | §4 detection, §5 generation, §6 disabled/empty, §7 root, §9 cache | -| `tests/route-chunks-cache.test.ts` (NEW) | unit | §9 cache versioning + single-pass equivalence (the core regression guards) | -| `tests/manifest-split-route-modules.test.ts` (EXPAND existing) | integration | §8-H1/H2 manifest consumer + enforce at manifest level | -| `tests/route-chunk-transforms.test.ts` (NEW) | integration via stub Rsbuild | §8-H3/H4 bundler transforms + preventEmptyChunkSnippet | -| `tests/fixtures/route-chunks/` (NEW) | fixtures | shared module snippets for §4–§5 | - -Conventions: rstest (`@rstest/core`), tests are ESM, `await` the async functions, -`setup.ts` mocks `node:fs` and provides `createStubRsbuild` (already wired). Fixtures are -plain `.tsx` strings — detection operates on code strings, not files, so inline template -literals are preferred; use `tests/fixtures/` only for the transform-integration tests that -must read real files. - ---- - -## 4. Detection unit tests → `tests/route-chunks.test.ts` (describe "detect route chunks") - -All call `detectRouteChunksIfEnabled(cache, cfg(true), '/app/routes/r.tsx', code)` with a -fresh `new Map()` cache. Assert the full `RouteChunkInfo` shape -(`hasRouteChunks`, `hasRouteChunkByExportName`, `chunkedExports`). - -**D-Detect-01 — each client export is independently splittable (parametrized ×4)** -Fixture (per export `E` in `[clientAction, clientLoader, clientMiddleware, HydrateFallback]`): - -```ts -export const E = async () => {}; // HydrateFallback uses: export function HydrateFallback(){return null} -export default function Route() { - return null; -} -``` - -Expected: `hasRouteChunkByExportName[E]===true`, the other three `false`, `hasRouteChunks===true`, -`chunkedExports===[E]`. Covers function-decl vs const-arrow declaration forms. - -**D-Detect-02 — all four splittable together** -Fixture: all four exports, each referencing its own local helper (no sharing), + default. -Expected: all four `true`, `hasRouteChunks===true`, `chunkedExports` length 4 (order = -`routeChunkExportNames` order). - -**D-Detect-03 — export depends on an import** -Fixture: `import {json} from 'react-router'; export const clientLoader = async()=>json({});` + default. -Expected: `clientLoader===true` (imports do not block chunkability). - -**D-Detect-04 — two client exports share a top-level helper (not chunkable)** [existing, keep] -Fixture: `const shared=()=>{}; export const clientAction=async()=>shared(); export const clientLoader=async()=>shared();` -Expected: both `false`, `hasRouteChunks===false`. (existing test asserts clientAction/clientLoader false.) - -**D-Detect-05 — client export shares top-level code with the DEFAULT export (not chunkable)** -Fixture: `const helper=()=>{}; export default function Route(){return helper();} export const clientAction=async()=>helper();` -Expected: `clientAction===false`, `hasRouteChunks===false`. **(verified)** Pins that the -default export participates in the shared-statement intersection. - -**D-Detect-06a — single-binding destructuring IS chunkable** -Fixture: `function make(){return{clientAction:async()=>{}}} export const{clientAction}=make();` + default. -Expected: `clientAction===true`, `chunkedExports===['clientAction']`. **(verified)** - -**D-Detect-06b — multi-binding destructuring sharing a declarator is NOT chunkable** -Fixture: `function make(){return{clientAction:async()=>{},foo:1}} export const{clientAction,foo}=make();` + default. -Expected: `clientAction===false` (shares declarator with sibling export `foo`). **(verified)** - -**D-Detect-07 — chunkable export isolated from a non-chunkable sibling** -Fixture: clientAction self-contained (chunkable) + clientLoader sharing a helper with default (not chunkable). -Expected: `clientAction===true`, `clientLoader===false`, `hasRouteChunks===true`, -`chunkedExports===['clientAction']`. Pins partial-split detection. - -**D-Detect-08 — `chunkedExports` ordering follows `routeChunkExportNames`** -Fixture: exports in source order HydrateFallback, clientLoader, clientAction, all splittable. -Expected: `chunkedExports===['clientAction','clientLoader','HydrateFallback']` (declaration -order in source must not leak into the result order). - ---- - -## 5. Generated-code unit tests → `tests/route-chunks.test.ts` (describe "generate route chunk code") - -Call `getRouteChunkIfEnabled(cache, cfg(true), id, chunkName, code)` (or `getRouteChunkCode` -directly). Assert by re-parsing the output with `getExportNames` (from `src/export-utils`) and -checking membership — do NOT assert exact whitespace. - -**G-Gen-01 — main chunk omits all chunkable client exports, keeps default + server exports** -Fixture: `import{json}from'react-router'; export async function action(){return json({})} export const clientAction=async()=>{}; export default function Route(){return null}`. -Expected (`chunkName='main'`): output exports include `default` and `action`, exclude `clientAction`. - -**G-Gen-02 — individual client chunk contains only that export + its deps** -Same fixture, `chunkName='clientAction'`: output exports === `['clientAction']` only. Does -not contain `default`/`action`. - -**G-Gen-03 — client chunk retains only used import specifiers** -Fixture: `import{json,useFetcher}from'react-router'; export const clientLoader=async()=>json({}); export default function Route(){return null}`. -`chunkName='clientLoader'`: output contains `import{json}` but NOT `useFetcher`. - -**G-Gen-04 — main chunk is `undefined` when only client exports exist** -Fixture: `export const clientAction=async()=>{}; export const clientLoader=async()=>{};` (no default). -`chunkName='main'` → result `null`/`undefined`. **(verified)** This is the empty-main edge -that maps to `preventEmptyChunkSnippet` in the bundler. - -**G-Gen-05 — non-chunkable export yields `undefined` chunk** -Fixture from D-Detect-05 (clientAction shares with default). `chunkName='clientAction'` → -`null`/`undefined` (because `!hasChunkableExport`). **(verified)** - -**G-Gen-06 — main chunk for a module with NO chunkable exports returns the full module** -Fixture from §6-G2 (only `action`+default). `chunkName='main'` → full source regenerated, -exports include `default`,`action`; nothing omitted. **(verified)** - -**G-Gen-07 — `getRouteChunkCode` dispatch: 'main'→omit, named→extract** -Direct unit test of `getRouteChunkCode(code,'main',…)` vs `getRouteChunkCode(code,'clientAction',…)` -asserting they route to `omitChunkedExports` / `getChunkedExport` respectively (compare outputs -against calling those paths). Pin the public dispatch contract. - -**G-Gen-08 — module-id helpers round-trip** -`getRouteChunkModuleId('/app/routes/r.tsx','clientAction')` === `'/app/routes/r.tsx?route-chunk=clientAction'`; -`isRouteChunkModuleId(that)===true`; `getRouteChunkNameFromModuleId(that)==='clientAction'`; -`getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=main')==='main'`; -`getRouteChunkNameFromModuleId('/app/routes/r.tsx')===null`; -`getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=bogus')===null`; -`getRouteChunkEntryName('routes/clients','clientAction')==='routes/clients-client-action'`. - ---- - -## 6. Disabled / empty / no-split tests → `tests/route-chunks.test.ts` (describe "mode + early-exit") - -**F-Mode-01 — splitRouteModules falsy returns noRouteChunks without parsing** -`detectRouteChunksIfEnabled(cache, cfg(false), id, clientActionCode)` → all four `false`, -`hasRouteChunks===false`. Also `cfg(undefined)` (splitRouteModules absent). Assert no parse -side-effect is observable (e.g. malformed code does NOT throw when disabled — feed syntactically -invalid code and assert clean noRouteChunks return). - -**F-Mode-02 — substring guard early-exits when no client export name appears** -`detectRouteChunksIfEnabled(cache, cfg(true), id, 'export default function Route(){return null}')` -→ all false. Asserts the fast path. - -**F-Mode-03 — substring false positive does not create a chunk** **(verified)** -Code: `// clientAction mentioned in a comment only` + default. Substring guard passes (parse -runs) but `hasChunkableExport` returns false → all four `false`. Pins that the parse is the -source of truth, not the substring filter. - -**G-Empty-01 — route with default only: detect no-op** -Already covered by F-Mode-02 shape; assert `hasRouteChunks===false`. - -**G-Empty-02 — `getRouteChunkIfEnabled` returns null when disabled** -`cfg(false)` → `getRouteChunkIfEnabled(…,'main',clientActionCode)===null` regardless of content. - ---- - -## 7. Root-route tests → `tests/route-chunks.test.ts` (describe "root route") - -**E-Root-01 — detect returns noRouteChunks for the root route id** [existing, keep] -`detectRouteChunksIfEnabled(cache, cfg(true), '/app/root.tsx', clientActionCode)` → all false. - -**E-Root-02 — root detection is path-normalized (query strings, relative segments)** -Assert `isRootRouteModuleId` equivalence via detect on ids: - -- `/app/root.tsx` ✓ root -- `/app/./root.tsx` ✓ root (normalize) -- `/app/root.tsx?react-router-route` ✓ root (query stripped by `normalizeRelativeFilePath`) -- `/app/routes/root.tsx` ✗ not root -- windows-style or trailing slashes per `pathe.normalize` behavior — document expected. - -**E-Root-03 — `getRouteChunkIfEnabled` has NO root guard (asymmetry pin)** **(verified)** -`getRouteChunkIfEnabled(cache, cfg(true), '/app/root.tsx','clientAction', clientActionCode)` -returns the generated `export const clientAction…` — NOT null. This is the intentional -asymmetry: detection gates root, generation does not. Test pins current behavior so the -precompute refactor preserves it (callers only request root chunks they never created). - -**E-Root-04 — validateRouteChunks is a no-op for root route** -`validateRouteChunks({config:cfg('enforce'), id:'/app/root.tsx', valid:{clientAction:false,…}})` -does NOT throw. Pins the `isRootRouteModuleId` early return in `validateRouteChunks`. - ---- - -## 8. Enforce + consumer-consistency tests - -### 8a. Enforce unit → `tests/route-chunks.test.ts` (describe "enforce mode") - -`validateRouteChunks` throws iff any `valid[name]===false` for a non-root route, regardless -of caller. Enforce vs. plain-`true` gating happens at the call sites (manifest/index). - -**V-Enforce-01 — all valid → no throw** -`validateRouteChunks({config:cfg('enforce'), id:'/app/routes/r.tsx', valid:{clientAction:true,clientLoader:true,clientMiddleware:true,HydrateFallback:true}})` returns silently. - -**V-Enforce-02 — one invalid → throws naming the export** [existing, keep/extend] -valid has clientAction:false only. Assert `throwError(/Error splitting route module/)` AND the -message contains `clientAction` and the singular guidance phrasing ("This export…its own chunk…shares"). - -**V-Enforce-03 — multiple invalid → throws plural message listing all** -valid: clientAction:false, clientLoader:false. Assert message lists both and uses plural -phrasing ("These exports…their own chunks…they share"). Pins the `plural` branch. - -**V-Enforce-04 — enforce skipped for root** (cross-ref E-Root-04) - -### 8b. Manifest consumer → `tests/manifest-split-route-modules.test.ts` (EXPAND) - -Use the existing `createTempApp()` helper (writes `app/root.tsx` + a route file). Build a -`clientStats.assetsByChunkName` map. - -**M-Manifest-01 — clientActionModule set when splittable** [existing, keep] -Route exports self-contained clientAction → `manifest.routes[…].clientActionModule` points to -the `…-client-action.js` asset. Repeat the shape for clientLoaderModule, clientMiddlewareModule, -hydrateFallbackModule (parametrized). - -**M-Manifest-02 — \*Module fields omitted in dev** [existing, keep] -`isBuild:false` → all four `*Module` fields undefined even when exports present. - -**M-Manifest-03 — \*Module field omitted when export is NOT splittable** **(H1 critical)** -Route file where clientAction shares a top-level helper with default (D-Detect-05 fixture). -Build mode. Expected: `hasClientAction===true` (export exists) BUT -`clientActionModule===undefined` (not splittable, so `hasRouteChunkByExportName.clientAction===false`). -Pins the entry/manifest asymmetry: a bundler entry may still be created (substring), but the -manifest must not advertise a module that was not split. - -**M-Manifest-04 — enforce throws at manifest level for unsplittable export** -`splitRouteModules:'enforce'`, build mode, route with clientAction sharing code (D-Detect-05). -Expected: `getReactRouterManifestForDev` rejects / `validateRouteChunks` throws inside it. -Assert the throw propagates (wrap call in `expect(…).rejects.toThrow(/Error splitting route module/)`). - -**M-Manifest-05 — plain `true` (non-enforce) does NOT throw for unsplittable** -Same route as M-Manifest-04 but `splitRouteModules:true`. Expected: manifest resolves without -throwing; `clientActionModule===undefined`, `hasClientAction===true`. Pins that enforce gating -is at the call site, not in detect. - -**M-Manifest-06 — root route: no \*Module fields even with client exports** -Root route file exports clientAction. Build + split. Expected: all `*Module` undefined on the -root entry (detect returned noRouteChunks for root). - -### 8c. Bundler-transform consumer → `tests/route-chunk-transforms.test.ts` (NEW) - -These exercise the three `api.transform` hooks in `src/index.ts`. Use `createStubRsbuild` -(from `setup.ts`) to drive `reactRouter()` setup, then assert on the `transform` spy calls or -on `processAssets` output. **Mark these `it.skip` with a TODO if the stub harness cannot yet -isolate a single transform invocation** — they are the highest-value but hardest tests. - -**T-Transform-01 — split-exports rewrites a chunkable route module to reexport stubs (H3)** -Route with splittable clientAction + default. Assert the generated module code is: - -``` -export { default } from "./r.tsx?route-chunk=main"; -export { clientAction } from "./r.tsx?route-chunk=clientAction"; -``` - -(non-chunked names go to `main`; each `chunkedExports` name gets its own reexport line.) - -**T-Transform-02 — non-chunkable route module is passed through unchanged (H3)** -Route with only `action`+default (no client exports): split-exports transform returns original -code (`hasRouteChunks===false` no-op branch). - -**T-Transform-03 — `?route-chunk=` returns generated chunk or preventEmptyChunkSnippet (G3)** -For a splittable clientAction module id `…?route-chunk=clientAction`: transform returns the -generated chunk code. For a disabled/non-build config: returns -`Math.random()<0&&console.log("…");`. For a non-chunkable export: chunk is null → snippet. - -**T-Transform-04 — enforce validates the generated MAIN chunk (H4)** -Enforce + splittable route: main chunk generated → `getExportNames(main)` excludes client -exports → `validateRouteChunks` passes. Inject a fixture where main would still contain a -client export (regression sim) and assert the transform throws. Pins the generate→validate loop. - -**T-Transform-05 — entry map created per substring, not per detect (H1)** -Build + split, route whose clientAction shares code (non-splittable). Assert -`webRouteEntries` contains a `routes/r-client-action` entry (substring match created it) even -though detection says not-splittable. (Assert via unwrapConfig or a spy on the entries object.) - ---- - -## 9. Cache + single-pass equivalence tests → `tests/route-chunks-cache.test.ts` (NEW) - -These are the **most important regression guards for the precompute refactor.** They prove a -single-pass precomputed analysis produces identical results to today's per-call cache. - -**C-Cache-01 — version invalidation on content change** -cacheKey = `/app/routes/r.tsx`. Call `detectRouteChunksIfEnabled` with code A (clientAction -chunkable), then with code B (clientAction non-chunkable, e.g. shares helper). Same cache -instance, same cacheKey. Assert B's result reflects B, not a stale A. Pins that `version===code` -keys actually invalidate. - -**C-Cache-02 — same code + cacheKey returns cached result (no recompute)** -Spy/stub `parse` (or count via a module-level counter in a throwaway double) and assert that a -second `detectRouteChunksIfEnabled` with identical code does not re-parse. Pins the cache hit path. - -**C-Cache-03 — structuredClone isolation: mutating a returned AST does not corrupt the cache** -This guards `codeToAst`'s `structuredClone`. Call `getExportDependencies` (or any path that -returns derived data), then call again with the same code; assert the second result equals the -first byte-for-byte even if test code mutated the first return's structures. (If the public API -does not expose AST, frame as: two sequential identical calls return deeply-equal results and -the second is served from cache.) - -**C-Cache-04 — single-pass equivalence: detect + all chunks == per-export calls** ★ -The headline test. For a fixture with all four client exports splittable + shared-code -siblings, compute via the CURRENT per-export API: - -- `info = detectRouteChunksIfEnabled(…)` -- `main = getRouteChunkIfEnabled(…,'main',…)` -- for each name: `chunk[name] = getRouteChunkIfEnabled(…, name, …)` - Then (after the refactor) compute via the NEW precompute API (e.g. a hypothetical - `analyzeRouteChunks(code, config, id)` returning `{info, chunks: Record}`) - and assert `info`, `main`, and every `chunk[name]` are identical. Until the new API exists, - write this test against the current API as the **reference oracle** and mark the new-API half - `it.skip('TODO: re-enable when precompute API lands')`. - -**C-Cache-05 — undefined cache (no Map) still computes correct results** -Pass `undefined` as cache to all functions; `getOrSetFromCache` short-circuits to `getValue()`. -Assert results identical to the cached path (C-Cache-04 oracle). Pins the no-cache fallback. - -**C-Cache-06 — cache is shared across index + manifest callers (H2)** ★ -Simulate the real wiring: one `routeChunkCache` Map is passed to both the manifest path -(`getReactRouterManifestForDev(…, {cache}`) and the index transform path. For the same route -module, assert both derive the same `hasRouteChunkByExportName`. This is the consistency -property the precompute must guarantee — a single analysis object feeding both consumers. - ---- - -## 10. Coverage matrix - -| Task-body dimension | Tests | -| ----------------------------- | ------------------------------------------------------- | -| split: clientAction | D-Detect-01, D-02, D-03, D-04, D-05, D-07, G-Gen-01..07 | -| split: clientLoader | (same set, parametrized) | -| split: clientMiddleware | (same set, parametrized) | -| split: HydrateFallback | (same set, parametrized; function-decl form) | -| enforce enabled | V-Enforce-01..04, M-Manifest-04 | -| enforce disabled (plain true) | M-Manifest-05 | -| enforce error behavior | V-Enforce-02, V-Enforce-03, M-Manifest-04 | -| root route | E-Root-01..04, M-Manifest-06 | -| no split exports | G-Gen-06, F-Mode-02, T-Transform-02 | -| empty chunks | G-Gen-04, G-Gen-05, T-Transform-03 | -| detection ↔ generated code | G-Gen-01..07, T-Transform-01, T-Transform-04 | -| consumed by index caller | T-Transform-01..05 | -| consumed by manifest caller | M-Manifest-01..06 | -| precompute equivalence | C-Cache-01..06 (esp. C-Cache-04, C-Cache-06) | - ---- - -## 11. Implementation notes for the implementer - -1. **Order:** write §4–§7 first (pure units, fast, no harness). They validate the golden - table in §2. Then §9 (cache) — the regression backbone. Then §8b (manifest, uses - `createTempApp`). Leave §8c (transforms) for last; if the stub harness can't isolate a - transform, ship them as `it.skip` with the assertion encoded in a comment. -2. **Assertions on generated code:** always re-parse with `getExportNames` and assert on - export membership / import specifier presence — never on `generate()` whitespace. -3. **The substring guard (F-Mode-03) and root asymmetry (E-Root-03) are deliberate load-bearing - behaviors, not bugs.** Tests pin them so the precompute doesn't "fix" them and break callers. -4. **C-Cache-04 is the single most valuable test** — it is the equivalence oracle. Build the - precompute against it. -5. **H1 (M-Manifest-03, T-Transform-05)** documents that bundler entries and manifest fields - can disagree for non-splittable exports. The precompute must preserve this disagreement - exactly (entry created via substring; module field absent via detect). -6. rstest config (`rstest.config.ts`) already includes `tests/**/*.test.ts` and loads - `tests/setup.ts`; new test files are picked up with no config change. diff --git a/task/route-chunk-precompute-plan.md b/task/route-chunk-precompute-plan.md deleted file mode 100644 index 0359452..0000000 --- a/task/route-chunk-precompute-plan.md +++ /dev/null @@ -1,321 +0,0 @@ -# Implementation Plan: Single-Pass Route Chunk Precompute - -**Kanban:** `t_f8636ea4` (synthesis) → triage root `t_d3ed9b84` -**Branch:** `perf/bundling-performance` (PR #39 — _Add React Router plugin performance benchmarks_) -**Head at authoring:** `c2452de` -**Scope of this plan:** `src/route-chunks.ts` only (no edits to `src/index.ts` or `src/manifest.ts`). - -**Source artifacts this plan synthesizes (read these for full detail, the plan below is self-contained):** - -- `route-chunk-parse-traverse-analysis.md` — current-behavior map (parent `t_0f2688a9`) -- `.benchmark/design/route-chunk-analysis.md` — cache representation design (parent `t_34486796`) -- `task/route-chunk-correctness-test-spec.md` — 50+ named correctness tests (parent `t_1c0421c6`) -- `benchmarks/chunk-precompute-methodology.md` — A/B benchmark commands (parent `t_4d84984e`) - ---- - -## 0. Headline answers (acceptance criteria, up front) - -| Question | Answer | -| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Can all chunks for one route be computed from one parse/traverse pass? | **Yes.** Parse and traverse are _already_ single-pass today (cached once per `(path, code)`). The avoidable cost is not re-parsing — it is (a) `structuredClone` of the full AST on every `codeToAst` call (~6× per splittable module) and (b) the `t.isNodesEquivalent` membership scans (O(body × deps) per generate). | -| Store generated chunk code, or AST + metadata? | **Store AST + index-based metadata, generate on demand (design "Option B").** Do NOT pre-generate and cache chunk strings: only the `?route-chunk=` transform hook ever reads chunk text (1 of the 4 consumers); the manifest + client-entry + split-exports hooks consume only `hasRouteChunkByExportName` / `chunkedExports`. Eagerly materializing 5 strings per module wastes the single biggest retained object. Generating from a pre-filtered node array is cheap; the expensive part today is the parse + full-AST clone _before_ generate, which Option B removes entirely while preserving byte-for-byte output. | -| Exact tests? | §6 below: 3 existing → ~50 tests per `task/route-chunk-correctness-test-spec.md`; the differential equivalence oracle (`C-Cache-04`) is mandatory before flipping the default. | -| Exact benchmark commands? | §7 below, lifted from `benchmarks/chunk-precompute-methodology.md`. | -| Implementer re-triage needed? | **No.** Steps §4 are ordered, name exact files/functions/line numbers, and each carries its own verification gate. | - ---- - -## 1. Current state (ground truth, verified at `c2452de`) - -All references are `src/route-chunks.ts` unless noted. - -``` -codeToAst (L87-97) → parse() cached at ${ck}::codeToAst; structuredClone RUNS ON EVERY CALL (cache hit or miss) -getExportDependencies (L158-315)→ one traverse() building Map; cached ${ck}::getExportDependencies -hasChunkableExport (L460-516) → set-intersection over ExportDependencies; cached ${ck}::hasChunkableExport::${name} -getChunkedExport (L518-617) → codeToAst(CLONE) + filter body via t.isNodesEquivalent + generate(); cached ${ck}::getChunkedExport::${name}::opts -omitChunkedExports (L619-758) → codeToAst(CLONE) + filter body via t.isNodesEquivalent + generate(); cached ${ck}::omitChunkedExports::${names}::opts -detectRouteChunks (L760-780) → hasChunkableExport ×4 -getRouteChunkCode (L782-797) → dispatch 'main'→omitChunkedExports, named→getChunkedExport -detectRouteChunksIfEnabled (L834-868) → guards (splitRouteModules / root / substring) then detectRouteChunks -getRouteChunkIfEnabled (L870-888) → guards (splitRouteModules only — NO root guard, intentional) then getRouteChunkCode -``` - -Per-module cost for a 4-export splittable route across one build (3 transform hooks + manifest + 5 `?route-chunk=` queries share one `routeChunkCache`): - -- `parse()`: **1×** (cached) — already optimal. -- `traverse()`: **1×** (cached) — already optimal. -- `generate()`: **5×** (main + 4 named) — inherent floor, each chunk is a distinct program. -- `structuredClone()`: **~6×** of the **full AST** (1 in `getExportDependencies` miss + 4 in `getChunkedExport` + 1 in `omitChunkedExports`) — **the avoidable hot spot.** -- `t.isNodesEquivalent` scans: O(body × deps) per generate — **the second avoidable cost.** - -Cache primitive: `getOrSetFromCache(cache, key, version, getValue)` (L69), `version === code` (full source text) at every site. The shared `routeChunkCache: RouteChunkCache = new Map()` is created once per plugin instance at `src/index.ts:403` and passed by reference to manifest (`index.ts:408`) and the three transform hooks (`index.ts:1384/1447/1510`). No config-coupled keying. - ---- - -## 2. Target design (what the implementer builds) - -Collapse the scatter of `getOrSetFromCache` entries (`codeToAst`, `getExportDependencies`, `hasChunkableExport` ×4, `getChunkedExport` ×N, `omitChunkedExports`) into **one analysis object per route module**, computed in one parse + one traverse, cached under one key. - -```ts -// NEW types in src/route-chunks.ts -type ExportDependencyIndex = { - // Indices into ast.program.body — plain serializable data, never node references. - topLevelStatementIndices: ReadonlySet; - topLevelNonModuleStatementIndices: ReadonlySet; - importedIdentifierNames: ReadonlySet; - exportedDeclaratorIndex: number; // -1 if not a var-declarator - exportedDeclaratorParentIndex: number; // for destructuring-export binding lookup -}; - -type RouteChunkAnalysis = { - readonly code: string; // doubles as cache version - readonly ast: t.File; // IMMUTABLE shared AST — consumers never mutate - readonly exports: Map; // keyed by export name - readonly topLevel: readonly t.Statement[]; // alias of ast.program.body (stable: body never reordered) - readonly chunkableExports: ReadonlySet; // materialized once from exports -}; -``` - -**Why indices, not node references:** the current `ExportDependencies` stores `Set` / `Set` and re-identifies them via `t.isNodesEquivalent` (L550/584/670/715). That is both mutation-unsafe (forces the per-call `structuredClone`) and O(n×m) per match. Index-based metadata is plain data, survives across the immutable shared AST with zero aliasing risk, and lets `getRouteChunkCode` select statements by array index in O(1). - -**Constructor:** - -```ts -// NEW in src/route-chunks.ts — replaces codeToAst+getExportDependencies+hasChunkableExport trio -const analyzeRouteModule = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string -): RouteChunkAnalysis => { - // one getOrSetFromCache under `${cacheKey}::analysis`, version = code. - // On miss: parse(code) once, traverse once to record ExportDependencyIndex map, - // derive chunkableExports (same intersection rule as hasChunkableExport L477-513), - // return the analysis. Reuse getDependentIdentifiersForPath / - // getTopLevelStatementPathForPath helpers unchanged — just record body.indexOf(path.node). -}; -``` - -**Consumers rewritten:** - -- `detectRouteChunks` → reads `analysis.chunkableExports`; no per-export `hasChunkableExport` calls. -- `getChunkedExport` / `omitChunkedExports` → `analyzeRouteModule(...)`, select `analysis.topLevel[i]` by stored indices, build `t.program([...])`, call `t.cloneNode(node, false)` only on the narrowed import/export nodes, `generate()`. **Delete the `t.isNodesEquivalent` scans (L550/584/670/715) entirely** — selection is by index. -- `codeToAst` → **deleted** (no callers after the rewrite). -- `getExportDependencies` body → moves into the `analyzeRouteModule` miss-closure, refactored to record indices; the standalone function is removed. -- `hasChunkableExport` → removed; logic folds into `analyzeRouteModule`'s `chunkableExports` derivation. - -**Public signatures unchanged:** `detectRouteChunks`, `getRouteChunkCode`, `detectRouteChunksIfEnabled`, `getRouteChunkIfEnabled`, `validateRouteChunks` keep their current signatures. `src/index.ts` and `src/manifest.ts` need **zero edits** — they already pass the shared `routeChunkCache`. - -**Root route, substring guard, enforce validation, empty-chunk snippet:** stay exactly where they are (pre-analysis early returns / caller policy). The analysis is a pure function of source code and must not encode any of them — see `.benchmark/design/route-chunk-analysis.md` §9 for the rationale (baking root-route suppression into the cache would couple the key to config and break cross-caller reuse). - ---- - -## 3. Toggle (transient scaffolding, not a permanent flag) - -To measure old vs new on **one commit** (required by the benchmark methodology), gate the new path behind an env var for exactly one measured commit, then delete it. - -```ts -// src/route-chunks.ts -const PRECOMPUTE_ENABLED = process.env.ROUTE_CHUNK_PRECOMPUTE === '1'; -``` - -- `detectRouteChunks` and `getRouteChunkCode` branch on `PRECOMPUTE_ENABLED`: old branch keeps today's codeToAst/structuredClone/isNodesEquivalent path; new branch calls `analyzeRouteModule` + index selection. -- The toggle exists **only** for the A/B benchmark + differential-equivalence commit. The very next commit (after §6 + §7 are green) deletes the old branch and the constant — it is not a shipped feature flag. (If a permanent opt-out is later wanted, promote it to `pluginReactRouter({ future: { v8_routeChunkPrecompute } })`, but that is out of scope here.) - ---- - -## 4. Ordered implementation steps - -Each step is independently verifiable. Do not skip the RED-test step — it is the contract the refactor is proven against. - -### Step 0 — RED: pin current behavior (no src changes) - -**Files:** `tests/route-chunks.test.ts` (expand), `tests/route-chunks-cache.test.ts` (new), `tests/fixtures/route-chunks/` (new). -**What:** Implement §4–§9 of the correctness spec against the **current** API. Concretely: `D-Detect-01..08`, `G-Gen-01..08`, `F-Mode-01..03`, `E-Root-01..04`, `V-Enforce-01..04`, `C-Cache-01..06` (write `C-Cache-04` against the current API as the reference oracle; mark the precompute-API half `it.skip`), and the `M-Manifest-01..06` expansions. Defer `T-Transform-01..05` (§8c) to Step 5 — they need the stub harness. -**Why:** These are the golden values the refactor must preserve byte-for-byte. Writing them first means every later step is gated by a green suite, not by reading prose. -**Verify:** `pnpm exec rstest run` — all new + existing (3) tests green against unchanged `src/`. -**Acceptance:** spec's verified-behavior table (§2) reproduced as passing assertions. - -### Step 1 — Add the analysis layer in parallel (old path still live) - -**File:** `src/route-chunks.ts`. -**What:** Add the `ExportDependencyIndex` + `RouteChunkAnalysis` types and `analyzeRouteModule`. Port the `getExportDependencies` body into the miss-closure, recording `body.indexOf(path.node)` instead of node references. Derive `chunkableExports` using the same intersection + single-declarator rule as `hasChunkableExport` (L477-513). Wire it through `setBoundedCacheEntry`-style insertion so the new single entry respects the existing cap (reuse the helper from `src/export-utils.ts`; the cap constant is `MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048`). Do **not** wire it into any consumer yet — it is dead code exercised only by a unit test. -**Why:** Isolates the representation change from the consumer rewrite. If indices are wrong, the failure is local to this step's unit test, not a cascade through 4 consumers. -**Verify:** add one unit test that calls `analyzeRouteModule` directly (export it test-only or via a thin internal wrapper) and asserts `chunkableExports` matches `hasChunkableExport` for every fixture from Step 0. `pnpm exec rstest run`. -**Acceptance:** analysis output == old detection output for all Step-0 fixtures. - -### Step 2 — Route detection through the analysis (toggle-gated) - -**File:** `src/route-chunks.ts`. -**What:** Branch `detectRouteChunks` on `PRECOMPUTE_ENABLED`. New branch returns `{ hasRouteChunks, hasRouteChunkByExportName, chunkedExports }` derived from `analyzeRouteModule(...).chunkableExports`. Old branch untouched. -**Verify:** `ROUTE_CHUNK_PRECOMPUTE=0 pnpm exec rstest run` (old path, all green) **and** `ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run` (new path, all green). The `C-Cache-04` oracle is the headline equivalence check. -**Acceptance:** both toggle values produce identical `RouteChunkInfo` for every fixture. - -### Step 3 — Chunk generation through the analysis (toggle-gated) - -**File:** `src/route-chunks.ts`. -**What:** Branch `getRouteChunkCode` (and through it `getChunkedExport` / `omitChunkedExports`) on `PRECOMPUTE_ENABLED`. New branch: `analyzeRouteModule(...)`, select `analysis.topLevel[i]` by the stored indices, `t.cloneNode(node, false)` on narrowed import/export nodes only, `t.program([...])`, `generate(program, {})`. **Delete the `t.isNodesEquivalent` scans in the new branch** — selection is by index. `generateOptions` stays `{}` (kept in the cache key for forward-compat, unchanged). Old branch untouched. -**Verify:** both toggle values green; additionally run the **byte-for-byte differential** — for every fixture × every chunk name, `ROUTE_CHUNK_PRECOMPUTE=0` output === `ROUTE_CHUNK_PRECOMPUTE=1` output (string equality). This is `C-Cache-04` extended to generation, and the design's mandatory safeguard (risk #4). -**Acceptance:** zero byte drift across all chunks. Emitted chunk hashes do not change. - -### Step 4 — Dev-mode immutability guard - -**File:** `src/route-chunks.ts`. -**What:** In the `analyzeRouteModule` miss-closure (dev/non-production only), `Object.freeze`-shallow `analysis.ast.program.body` and assert in each new-branch consumer that the array length is unchanged before/after selection. Add a code comment at every `t.cloneNode(node, false)` site stating the shallow-clone invariant (mutation reassigns only a top-level array property — `node.specifiers` / `declaration.declarations`). -**Why:** The whole design rests on `ast.program.body` never being reordered or mutated between analysis and generation. Today's code already treats it as read-only up to the post-clone mutation, so the guard is cheap insurance (design risk #1, #3). -**Verify:** `ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run`; the freeze guard must not fire on any fixture. - -### Step 5 — Transform-integration tests (§8c of the spec) - -**Files:** `tests/route-chunk-transforms.test.ts` (new), reuse `createStubRsbuild` from `tests/setup.ts`. -**What:** Implement `T-Transform-01..05`. If the stub harness cannot isolate a single transform invocation, ship as `it.skip` with the assertion encoded in a comment (per spec §11.1) — do not block the refactor on harness work. -**Verify:** `pnpm exec rstest run` (both toggle values for the non-skipped ones). - -### Step 6 — Cleanup: delete the old path and the toggle - -**File:** `src/route-chunks.ts`. -**What:** Remove the `PRECOMPUTE_ENABLED` constant, the old branches in `detectRouteChunks` / `getRouteChunkCode`, and the now-dead `codeToAst`, `getExportDependencies`, `hasChunkableExport` functions. Convert `C-Cache-04`'s `it.skip` precompute-API half into the live assertion (or delete the skip if the test already asserts via the now-only path). The differential test from Step 3 becomes a no-op (only one path) — keep it as a snapshot/golden regression or delete per `task/route-chunk-correctness-test-spec.md` guidance. -**Prerequisite:** §6 testing sequence green **and** §7 benchmark sequence shows the expected win (§5) with no RSS regression. -**Verify:** `pnpm exec rstest run` + `pnpm build` + `pnpm run format`. - ---- - -## 5. Expected performance wins - -Derived from the current-state map + design; confirm with §7 before locking in. - -| Metric (per splittable route module, 4 exports) | Today | After | Δ | -| ----------------------------------------------- | --------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | -| `parse()` calls | 1 (cached) | 1 | 0 — already optimal | -| `traverse()` calls | 1 (cached) | 1 | 0 — already optimal | -| `generate()` calls | 5 | 5 | 0 — inherent floor | -| `structuredClone(full AST)` calls | ~6 | **0** | −6 full-tree deep clones/module | -| `t.isNodesEquivalent` scans | O(body × deps) × 5 | **0** (index lookup) | removed | -| Cache map entries / module | ~8 | **1** | −87% entries; ~8× better LRU coverage at the 2048 cap | -| Peak transient memory | 6 full-AST clone copies/module | 0 transient clones | sharp drop in GC pressure | -| Steady-state retained | node-ref Sets + 1-5 chunk strings | index maps (≪ node Sets); 0 chunk strings by default | modest drop | - -Headline: **all chunks for one route already come from one parse + one traverse; the win is eliminating ~6 full-AST `structuredClone`s and the `isNodesEquivalent` scans per splittable module.** CPU-time and `route:chunk.totalMs` should drop with no peak-RSS regression beyond the retained `RouteChunkAnalysis` heap cost (quantified separately by the micro-benchmark). - ---- - -## 6. Testing sequence - -Conventions: rstest (`@rstest/core`), ESM, `tests/**/*.test.ts` auto-included via `rstest.config.ts`, `tests/setup.ts` mocks `node:fs` + provides `createStubRsbuild`. Assert generated code by re-parsing with `getExportNames` (from `src/export-utils`) and checking export/import membership — **never** assert `generate()` whitespace. - -```sh -# 0. Full suite, current code (baseline green) — run once before starting -pnpm exec rstest run - -# 1. After each step — both toggle values for Steps 2-5 -ROUTE_CHUNK_PRECOMPUTE=0 pnpm exec rstest run # old path -ROUTE_CHUNK_PRECOMPUTE=1 pnpm exec rstest run # new path - -# 2. Type check + format + build (after Step 6) -pnpm run build -pnpm run format -``` - -**Mandatory tests (from `task/route-chunk-correctness-test-spec.md`):** - -- §4 detection: `D-Detect-01..08` (incl. verified single-bind destructure chunkable, multi-bind not, default-export sharing). -- §5 generation: `G-Gen-01..08` (incl. verified empty-main → `undefined`, non-chunkable → `undefined`). -- §6 mode/early-exit: `F-Mode-01..03` (incl. verified substring false-positive does not chunk). -- §7 root: `E-Root-01..04` (incl. **verified root-guard asymmetry** — `getRouteChunkIfEnabled` has NO root guard; pin it). -- §8 enforce + consumers: `V-Enforce-01..04`, `M-Manifest-01..06` (incl. **H1 critical** `M-Manifest-03` — entry created via substring but `*Module` field absent when not splittable), `T-Transform-01..05`. -- §9 cache: `C-Cache-01..06`. **`C-Cache-04` (single-pass equivalence oracle) and `C-Cache-06` (cache shared across index + manifest callers) are the headline regression guards — the refactor is built against them.** - -Today's `tests/route-chunks.test.ts` has 3 tests; the spec takes it to ~50. The implementer writes §4–§7 first (pure units), then §9 (cache backbone), then §8b (manifest via `createTempApp`), then §8c (transforms, `it.skip` if the stub can't isolate). - ---- - -## 7. Benchmark sequence - -Lifted verbatim from `benchmarks/chunk-precompute-methodology.md` — run after Step 5 (toggle live, both paths in one commit) and before Step 6 (cleanup). - -**Pre-flight:** - -```sh -git status --short # confirm tree state (note: src/performance.ts has an unrelated uncommitted sort tweak — commit/leave separately, not part of this plan) -pnpm install -pnpm build -node --version # record (v22.x here) -``` - -**End-to-end (primary comparison, 256 routes):** - -```sh -ROUTE_CHUNK_PRECOMPUTE=0 pnpm bench:baseline \ - --profile default --iterations 8 --warmup 2 --clean build \ - --format both --out .benchmark/results/lazy - -ROUTE_CHUNK_PRECOMPUTE=1 pnpm bench:baseline \ - --profile default --iterations 8 --warmup 2 --clean build \ - --format both --out .benchmark/results/precompute -``` - -Compare the `synthetic-256-ssr-esm-split` row (code path that changes) **and** the `synthetic-256-ssr-esm` row (non-split control — must show no meaningful diff; if it diverges, the toggle is leaking, which is a bug). - -**Scaling sweep (does the win grow with route count?):** - -```sh -for PRECOMPUTE in 0 1; do - ROUTE_CHUNK_PRECOMPUTE=$PRECOMPUTE pnpm bench:full \ - --profile full --filter split \ - --iterations 5 --warmup 1 --clean build \ - --out .benchmark/results/scale-precompute-$PRECOMPUTE -done -``` - -**Micro-benchmark (direct parse/traverse/generate/structuredClone attribution):** -Create `scripts/bench-chunk-analysis.mjs` (imports the analysis fns from `dist/`, runs over generated route modules in-process). Then: - -```sh -node scripts/bench-chunk-analysis.mjs --routes 256 --variant ssr-esm-split \ - --iterations 50 --warmup 5 --mode lazy --out .benchmark/results/micro-lazy.json -node scripts/bench-chunk-analysis.mjs --routes 256 --variant ssr-esm-split \ - --iterations 50 --warmup 5 --mode precompute --out .benchmark/results/micro-precompute.json -``` - -**Metrics to report** (per methodology §5): CPU time (`userMs+sysMs` median), wall median, peak RSS p95, `route:chunk` / `route:split-exports` / `route:client-entry` `totalMs`+`maxMs`, and from the micro: `parse`/`traverse`/`generate`/`structuredClone` call counts per route, per-route mean ms, heap delta. Expected micro signature: precompute shows `parse = routeCount` (1/module) vs lazy's `≤ 5×routeCount`, and `structuredClone ≈ 0`. - -**A win =** CPU time and `route:chunk.totalMs` drop, no peak-RSS regression beyond the retained `RouteChunkAnalysis` heap cost. Fill the comparison table template in methodology §7.3. - -**Hygiene:** benchmark output is gitignored under `.benchmark/`. Clean with `rm -rf .benchmark/` — **not** `git clean -fdX` (also nukes `node_modules/` and `.tracedecay/`). Pin one Node version; run both halves back-to-back with no other load. - ---- - -## 8. Compatibility risks + mitigations - -| # | Risk | Mitigation | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | **Index stability.** Design rests on `ast.program.body` never being reordered between analysis and generation. | Dev-mode `Object.freeze`-shallow on `body` (Step 4) + length assertions. Low risk — today's code already treats parsed body as read-only up to the post-clone mutation. | -| 2 | **Byte-for-byte output drift.** `generate()` output changing would invalidate downstream chunk hashes / break snapshot tests. | Mandatory differential test (Step 3): old vs new `getRouteChunkCode` output === for every fixture × chunk name, both toggle values. Do not proceed to Step 6 until green. | -| 3 | **`t.cloneNode(node, false)` correctness.** Shallow clone is safe only because mutation reassigns a single top-level array property. A future deep-edit would silently share state. | Code comment at every clone site + the Step 4 freeze guard. | -| 4 | **Root-guard asymmetry (load-bearing).** `detectRouteChunksIfEnabled` suppresses root; `getRouteChunkIfEnabled` does NOT. Callers only ever request root chunks they never created. | `E-Root-03` pins it explicitly. The refactor preserves both guards exactly where they are — the analysis encodes neither. | -| 5 | **Entry/manifest disagreement (H1).** Bundler entries are created via substring (`source.includes(name)`); manifest `*Module` fields via detect. They can disagree for non-splittable exports. | `M-Manifest-03` + `T-Transform-05` pin it. Refactor preserves: entry path unchanged (substring in `index.ts`, not touched), manifest path consumes `chunkableExports`. | -| 6 | **Code-source divergence (pre-existing).** Transform path gets `code` from `args.code`; manifest path from `readFile`. If they ever differ, version strings differ and the manifest re-parses. | Pre-existing; the refactor does not worsen it (still versions by full `code`). Flagged in the behavior map §5; out of scope here. | -| 7 | **Cache eviction pattern change.** ~8 entries/module → 1 entry/module changes LRU eviction. At cap 2048 this is strictly better coverage (~2048 modules vs ~256). | Confirm cap not lowered under the new shape (it isn't — reuses `MAX_EXPORT_UTILS_CACHE_ENTRIES`). | -| 8 | **Free-floating top-level side effects.** Statements not in any chunkable export's dependency closure must land in `main` only. Subtle — index-selection preserves today's `omitChunkedExports` keep-everything-not-omitted behavior. | Test matrix must include a module with a free-floating top-level statement; assert it lands in `main` and nowhere else (spec §9 risk #7). | - ---- - -## 9. Rollback strategy - -1. **Per-commit reversibility.** Steps 0-5 each leave the old path fully functional behind `ROUTE_CHUNK_PRECOMPUTE=0`. A bad step is reverted with a single `git revert` of that step's commit; production is unaffected because the default is the old path until Step 6. -2. **Toggle kill-switch.** If the new path misbehaves after Step 6 (toggle deleted), `git revert` the Step 6 commit restores the toggle, then set `ROUTE_CHUNK_PRECOMPUTE=0` while diagnosing. Because Steps 1-5 are independently revertible, you can also roll back to any intermediate state. -3. **No data/manifest migration.** The change is internal to `src/route-chunks.ts`; public signatures, emitted chunk bytes (proven by the differential test), and the manifest shape are identical. There is nothing to migrate or restore on the consumer side — rollback is purely source-level. -4. **No persisted state.** `routeChunkCache` is in-memory, per plugin instance, never serialized. A rollback takes effect on the next build with no cleanup. - -The safest sequencing: land Steps 0-5 as one PR (or PR-range) on `perf/bundling-performance` with the toggle defaulting to old; run §7; only after the win is confirmed and §6 is green, land Step 6 as a follow-up commit deleting the toggle. - ---- - -## 10. Out of scope (explicit non-goals) - -- **`getExportNames` consolidation.** `src/index.ts` calls `getExportNames` via a separate `mlly`/`es-module-lexer` parser (different from Babel). Merging it into the single Babel traverse is theoretically possible but couples the chunk pipeline to the export-name contract and risks `export *` divergence. Flagged as a future consolidation, not a blocker (design §9 #6). -- **`getDependentIdentifiersForPath` resolver cost.** The scope-walking per export is the real CPU cost inside the single traverse; moving to indices does not speed it up. If profiling later shows it dominates, that is a separate memoization optimization. -- **Permanent feature flag / `future` opt-out.** The toggle is transient scaffolding for measurement, deleted in Step 6. -- **Lazy per-chunk string memo.** A `Map` on top of Option B so each `generate()` runs at most once per build is a cheap follow-on micro-optimization, not part of the core representation (design §3 hybrid note). -- **`src/performance.ts` uncommitted change** (slowest-list sort + hoisted `resolvedEnvironment`) — orthogonal perf tweak on this branch; commit or leave separately, not part of this plan. diff --git a/task/unified-route-module-analysis-cache-triage.md b/task/unified-route-module-analysis-cache-triage.md deleted file mode 100644 index f3d8c48..0000000 --- a/task/unified-route-module-analysis-cache-triage.md +++ /dev/null @@ -1,598 +0,0 @@ -# Unified Route Module Analysis Cache Triage - -Task: `t_07287a3f` -Branch: `perf/bundling-performance` @ `c2452de` -Scope: design-only synthesis for a unified per-route analysis cache spanning `src/export-utils.ts`, `src/manifest.ts`, `src/index.ts`, and `src/route-chunks.ts`. - -Inputs synthesized: - -- `route-analysis-duplication-audit.md` -- `.benchmark/design/manifest-route-analysis-triage.md` -- `.benchmark/design/shared-route-analysis-cache-proposal.md` -- `.benchmark/design/test-impact-plan-shared-cache.md` -- `task/route-chunk-precompute-plan.md` -- live code in `src/export-utils.ts`, `src/manifest.ts`, `src/index.ts`, `src/route-chunks.ts` - ---- - -## 0. Headline answer - -The repo already shares low-level transform/export caches in `src/export-utils.ts:24-29` and a per-plugin `routeChunkCache` in `src/index.ts:403-409`, but it still duplicates higher-level route analysis because manifest generation, prerender validation, and three build transforms each reconstruct overlapping facts from the same route module. - -Recommended direction: - -1. Introduce a plugin-instance-scoped `RouteAnalysisCache` beside `routeChunkCache`. -2. Make it the single source of truth for: - - transformed ESM code, - - export-name list, - - manifest booleans, - - dev CSS fallback bit, - - route chunk metadata, - - future pointer to the single-pass `RouteChunkAnalysis` object proposed for `src/route-chunks.ts`. -3. Keep build/dev/root-route/split-mode guards outside the base cache entry where possible so one source analysis can be safely reused across callers. -4. Remove the prerender re-extraction pass in `src/index.ts:758-762` by threading route analysis out of manifest generation. -5. Treat raw-source web entry emission in `src/index.ts:433-450` as a follow-up hardening step unless it can be safely switched to the same cache without changing config timing. - ---- - -## 1. Current consumers: what each caller needs - -### 1.1 Shared low-level helpers - -`src/export-utils.ts` - -- `transformToEsm(code, resourcePath)` at `:52-80` -- `getExportNames(code)` at `:83-104` -- `getRouteModuleAnalysis(resourcePath)` at `:130-157` -- `getRouteModuleExports(resourcePath)` at `:159-163` - -Current caches: - -- `transformCache` keyed by `resourcePath` and validated by exact source string (`src/export-utils.ts:24,56-59`) -- `exportNamesCache` keyed by transformed `code` (`src/export-utils.ts:25,83-104`) -- `routeModuleAnalysisCache` keyed by `resourcePath` and validated by `mtimeMs + size` (`src/export-utils.ts:26-29,133-155`) - -### 1.2 Consumer matrix - -| Consumer | Callsite | Needs raw source? | Needs transformed code? | Needs export names? | Needs route chunk info? | -| ----------------------------------------------- | -------------------------------------- | -------------------------------------------------: | --------------------------------------------------: | -----------------------------------------------------------------: | --------------------------------------------------------------------------: | -| Manifest generation | `src/manifest.ts:163-285` | Yes today, only for dev CSS fallback at `:191-199` | Yes, for `detectRouteChunksIfEnabled` at `:202-210` | Yes, to derive manifest booleans at `:216-279` | Yes in build mode | -| Prerender validation | `src/index.ts:733-816` | No | No | Yes, via `getRouteModuleExports()` at `:758-762` | No | -| Client-entry transform (`route:client-entry`) | `src/index.ts:1368-1411` | No | Yes, `transformToEsm` at `:1377` | Yes, `getExportNames` at `:1378` | Yes, `detectRouteChunksIfEnabled` at `:1383-1389` | -| Route-chunk transform (`route:chunk`) | `src/index.ts:1414-1474` | No | Yes, `transformToEsm` at `:1442-1445` | Yes, but only for generated main-chunk enforcement at `:1454-1465` | Yes, plus generated chunk body via `getRouteChunkIfEnabled` at `:1446-1452` | -| Split-exports transform (`route:split-exports`) | `src/index.ts:1476-1547` | No | Yes, `transformToEsm` at `:1504-1507` | Yes, `getExportNames` at `:1519` | Yes, `detectRouteChunksIfEnabled` at `:1508-1514` | -| Route-module transform (`route:module`) | `src/index.ts:1738-1824` | No | Yes, `transformToEsm` at `:1749` | Yes in SPA mode, `getExportNames` at `:1762` | No | -| Browser manifest emit hook | `src/modify-browser-manifest.ts:39-46` | Indirectly through manifest | Indirectly through manifest | Indirectly through manifest | Indirectly through manifest | - -### 1.3 Current duplication that matters - -1. `getReactRouterManifestForDev()` can run up to three times per build: - - prerender path: `src/index.ts:869-876` - - node virtual server-manifest fallback: `src/index.ts:1352-1359` - - browser emit hook: `src/modify-browser-manifest.ts:39-46` -2. prerender validation immediately re-reads route exports after manifest generation via `getRouteModuleExports()` (`src/index.ts:758-762`). -3. build transforms each replay some combination of `transformToEsm()`, `getExportNames()`, and `detectRouteChunksIfEnabled()` from bundler `args.code` rather than consuming one shared analysis object. -4. manifest dev CSS fallback still depends on raw `source` (`src/manifest.ts:191-199`), which is the only remaining raw-source-only consumer in the route analysis path. - ---- - -## 2. Proposed unified cache shape - -Base principle: cache the source-derived facts once per route file and make build/dev policy a caller concern, not a property of the base analysis entry. - -Recommended module: - -```ts -// src/route-analysis-cache.ts -export type RouteAnalysisCache = { - getRouteAnalysis(args: RouteAnalysisRequest): Promise; - getRouteAnalysisFromCode( - args: RouteCodeAnalysisRequest - ): Promise; - invalidateFile?(filePath: string): void; - clear?(): void; -}; -``` - -Recommended stored shape: - -```ts -type RouteAnalysis = { - key: { - filePath: string; // normalized absolute path, query stripped - routeRelativePath: string; // normalized path relative to appDirectory - }; - version: { - mtimeMs: number; - size: number; - contentHash: string; // hash of raw source - }; - code: string; // transformed ESM - codeHash: string; // hash of transformed code - exports: { - exports: readonly string[]; - hasAction: boolean; - hasLoader: boolean; - hasClientAction: boolean; - hasClientLoader: boolean; - hasClientMiddleware: boolean; - hasDefaultExport: boolean; - hasErrorBoundary: boolean; - hasHydrateFallback: boolean; - }; - css: { - hasCssImport: boolean; // derived from transformed code, not raw source - }; - chunks: { - hasRouteChunks: boolean; - hasRouteChunkByExportName: Record; - chunkedExports: readonly RouteChunkExportName[]; - }; - // optional future field when the route-chunk single-pass analysis lands: - // routeChunkAnalysis?: InternalRouteChunkAnalysis; -}; -``` - -### Why this shape works - -- It covers every current caller without making them re-run analysis. -- It lets manifest reuse the same export list that prerender validation currently rebuilds. -- It keeps route chunk metadata alongside the same transformed code that generated it. -- It allows the route-chunk internal precompute plan to plug in later without changing external consumers again. - -### Important design choice - -Move the dev CSS fallback regex from raw `source` to transformed `code`. - -Current regex in `src/manifest.ts:194`: - -```ts -/\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/; -``` - -That regex should remain, but be evaluated against `analysis.code`. This removes the only load-bearing raw-source requirement from manifest generation. - ---- - -## 3. Cache keying and versioning - -## 3.1 Primary key - -Use normalized absolute file path with query string stripped: - -```ts -const key = normalize(resolve(filePath)).split('?')[0]; -``` - -## 3.2 Versioning strategy - -Use a two-layer strategy. - -### Disk-read path - -For `getRouteAnalysis({ readFromDisk: true })`: - -- primary lookup key: normalized absolute file path -- warm-hit guard: `mtimeMs + size` -- stale-hit confirmation: `contentHash` after read -- transformed-code equivalence diagnostic: `codeHash` - -Why: `mtimeMs + size` is cheap for warm hits, while `contentHash` protects against edge cases where metadata changes but content does not, or content changes in a way the metadata check alone should not trust. - -### Bundler-code path - -For `getRouteAnalysisFromCode({ readFromDisk: false, sourceCode })`: - -- primary lookup key: normalized absolute file path -- secondary version key: exact source variant / `codeHash` -- do not overwrite the disk-read entry with a bundler-source variant unless hashes match - -Recommended representation: - -```ts -type PerFileRouteAnalysisEntry = { - disk?: CacheEntry; - codeVersions: Map>; -}; -``` - -This is the safe answer to the current F-3 divergence: disk-source and bundler-source analysis for the same file can coexist without clobbering each other. - -## 3.3 Build/dev/split-route safety - -Do not encode `isBuild` or root-route suppression into the base route-analysis key. - -Recommended split: - -- base cache entry: source-derived facts only (`code`, `exports`, CSS bit, pure chunkability metadata) -- caller-side policy: - - build vs dev decides whether chunk metadata is requested/used - - root-route suppression remains in `detectRouteChunksIfEnabled`-style policy - - `splitRouteModules` / `enforce` remain policy inputs, not source-version inputs - -Reason: the same route file should be able to serve manifest, prerender, and transform callers without polluting one caller with another caller’s guard semantics. - -If the implementation chooses to cache guard-applied route chunk results instead of pure chunkability, then the cache subkey must include: - -- `splitRouteModules` mode (`false | true | 'enforce'`) -- normalized `rootRouteFile` -- normalized `appDirectory` -- caller intent (`detect` vs `getChunk`) because `detectRouteChunksIfEnabled` suppresses root routes while `getRouteChunkIfEnabled` does not (`src/route-chunks.ts:857-888`) - -Recommended design: avoid this complexity by caching the pure analysis and applying caller policy after lookup. - ---- - -## 4. Concurrency and failure hazards - -These are the hazards the implementation must explicitly handle. - -### H-1. Divergent disk vs bundler source versions - -Current risk: - -- manifest/prerender read from disk via `getRouteModuleAnalysis()` -- build transforms analyze `args.code` -- same path may produce different transformed inputs - -Hazard: - -- a resourcePath-only cache entry can be silently overwritten by a different source variant -- later callers observe misses or inconsistent chunk metadata without any explicit signal - -Mitigation: - -- keep separate per-file code-version entries -- compare `codeHash`/source identity in development and log or assert on divergence - -### H-2. Rejected Promise poisoning - -`transformToEsm()`, `getExportNames()`, and `getRouteModuleAnalysis()` already use delete-on-rejection logic (`src/export-utils.ts:69-74`, `95-100`, `144-149`). The unified cache must preserve that behavior. - -Hazard: - -- if a rejected in-flight Promise stays cached, every future caller fails forever until process restart - -Mitigation: - -- every Promise-backed cache layer must remove its own entry on rejection -- if a higher-level entry fans out into subentries (`disk`, `codeVersions`), rejection cleanup must remove the failed subentry only - -### H-3. Stat/read race on disk files - -Current `getRouteModuleAnalysis()` does `stat()` before deciding to reuse a cached Promise (`src/export-utils.ts:133-155`). - -Hazard: - -- file changes between `stat()` and `readFile()` -- metadata can drift while the content is already different - -Mitigation: - -- treat `mtimeMs + size` as a cheap warm-hit filter only -- canonicalize on `contentHash` after reading when metadata changed -- store `contentHash` in the entry so equivalent content can reuse transformed/export/chunk data even if metadata changed - -### H-4. Guarded route-chunk results poisoning other callers - -Current asymmetry: - -- `detectRouteChunksIfEnabled()` suppresses root routes at `src/route-chunks.ts:860-861` -- `getRouteChunkIfEnabled()` does not apply the same root-route guard (`src/route-chunks.ts:884-888`) - -Hazard: - -- caching a final caller-shaped result instead of a pure analysis can make one caller's policy leak into another - -Mitigation: - -- cache pure analysis/chunkability only -- apply root/build/split guards outside the shared entry - -### H-5. Shared AST mutation when route-chunk precompute lands - -The route-chunk precompute plan already identifies `structuredClone()` as a correctness guard because chunk consumers mutate `ast.program.body` in place. - -Hazard: - -- if the unified cache later stores a shared `RouteChunkAnalysis.ast`, consumers can accidentally mutate it and poison every later read - -Mitigation: - -- keep the current clone-and-filter behavior until the single-pass route-chunk refactor lands -- when that refactor lands, use immutable/index-based metadata as proposed in `task/route-chunk-precompute-plan.md` -- add dev-only immutability guards/freeze assertions before sharing an AST object broadly - ---- - -## 5. Exact tests that need coverage - -The exact named tests are already spelled out in `.benchmark/design/test-impact-plan-shared-cache.md` and `task/route-chunk-correctness-test-spec.md`. The implementation should treat the lists below as the required coverage set. - -### 5.1 New cache-layer tests - -New file: `tests/route-analysis-cache.test.ts` - -Required cases: - -- `T-CACHE-01` warm-hit reuse -- `T-CACHE-02` mtime/size drift with identical content hash still reuses analysis -- `T-CACHE-03` content change recomputes analysis -- `T-CACHE-04` disk and bundler source variants for the same file do not overwrite each other -- `T-CACHE-05` bounded-cache eviction at the configured cap -- `T-CACHE-06` explicit `invalidateFile()` / `clear()` behavior -- `T-CACHE-07` dev diagnostic when disk and bundler code hashes diverge -- `T-CACHE-08` shared-consumer consistency between manifest and transform-hook callers - -### 5.2 Manifest + prerender tests - -Update/add in: - -- `tests/manifest-split-route-modules.test.ts` -- `tests/manifest-version.test.ts` -- `tests/manifest.test.ts` -- `tests/index.test.ts` -- either export `validateSsrFalsePrerenderExports` for direct testing or add dedicated cases through the plugin harness - -Required named cases: - -- `T-MAN-06` through `T-MAN-13` -- `T-MAN-14` through `T-MAN-16` -- `T-PRE-01` through `T-PRE-05` -- `T-IDX-01` - -These specifically cover: - -- dev CSS fallback parity after moving from raw `source` to transformed `code` -- manifest export-boolean parity -- build-only chunk metadata correctness and no cross-mode leakage -- serialized manifest staying free of internal cache fields -- removal of the `getRouteModuleExports()` re-extraction pass from prerender validation - -### 5.3 Route-chunk passthrough tests - -Update: - -- `tests/route-chunks.test.ts` - -Required shared-cache case: - -- `T-CHUNK-01` cache-derived chunk metadata matches direct `detectRouteChunksIfEnabled()` behavior - -In addition, the sibling route-chunk correctness/precompute work remains required because the unified cache will eventually point at that analysis: - -- `D-Detect-01..08` -- `G-Gen-01..08` -- `F-Mode-01..03` -- `E-Root-01..04` -- `V-Enforce-01..04` -- `M-Manifest-01..06` -- `T-Transform-01..05` -- `C-Cache-01..06` - -Source of truth: `task/route-chunk-correctness-test-spec.md` and `task/route-chunk-precompute-plan.md`. - -### 5.4 serverBundles and SRI compatibility tests - -Update/add: - -- `tests/build-manifest.test.ts` -- new `tests/modify-browser-manifest.test.ts` - -Required named cases: - -- `T-BM-01` -- `T-BM-02` -- `T-SRI-01` through `T-SRI-05` - -These prove: - -- `build-manifest.ts` remains route-tree-only -- `serverBundles({ branch })` is not coupled to route-source analysis -- emitted manifest assets remain serializable/public-only -- SRI is still computed from emitted JS asset bytes only -- manifest chunk URLs still line up with emitted assets - -### 5.5 Existing coverage gaps to close - -These areas are currently effectively untested and should be considered mandatory coverage gaps: - -- `src/modify-browser-manifest.ts` emit/SRI path -- `validateSsrFalsePrerenderExports()` in `src/index.ts:733-816` -- dev CSS fallback in `src/manifest.ts:191-199` -- cache behavior in `src/export-utils.ts` - ---- - -## 6. Benchmark commands and counters - -### 6.1 Primary before/after benchmark commands - -From the existing methodology and scripts: - -Canonical baseline: - -```sh -pnpm bench:baseline -``` - -Equivalent explicit command: - -```sh -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 5 \ - --warmup 1 \ - --clean build \ - --format both \ - --out .benchmark/results/manifest-baseline -``` - -After the cache refactor: - -```sh -node scripts/bench-builds.mjs \ - --profile default \ - --iterations 5 \ - --warmup 1 \ - --clean build \ - --format both \ - --out .benchmark/results/manifest-after-cache-dedup -``` - -Focused split-smoke run: - -```sh -node scripts/bench-builds.mjs \ - --profile default \ - --filter split \ - --iterations 3 \ - --warmup 1 \ - --clean build \ - --format both \ - --out .benchmark/results/manifest-split-smoke -``` - -Existing package shortcut for the broader suite: - -```sh -pnpm bench:full -``` - -### 6.2 Verification commands during implementation - -```sh -pnpm exec rstest run -pnpm run build -pnpm run format -``` - -### 6.3 Counters to watch - -Top-level counts should stay stable for the same fixture: - -- `manifest:transform` -- `manifest:stage` -- `route:client-entry` -- `route:chunk` -- `route:split-exports` -- `route:module` - -New lower-level counters worth adding or watching: - -- `manifest:route-stat` -- `manifest:route-read` -- `manifest:route-transform-to-esm` -- `manifest:route-export-extract` -- `manifest:route-analysis` -- `manifest:route-map` -- `manifest:route-chunk-detect` -- `route-chunk:parse` -- `route-chunk:traverse` -- `route-chunk:structured-clone` -- `route-chunk:generate` - -Success criterion: - -- top-level transform counts remain stable -- direct route-analysis work drops -- route-chunk structured-clone overhead drops once the single-pass chunk-analysis follow-up lands - ---- - -## 7. Recommended implementation breakdown - -This should not be one commit. Minimum recommended sequence is three commits, with one optional hardening follow-up. - -### Commit 1 — Introduce the cache as an orchestration layer - -Files: - -- create `src/route-analysis-cache.ts` -- wire creation in `src/index.ts` beside `routeChunkCache` -- keep using existing helpers from `src/export-utils.ts` and `src/route-chunks.ts` -- add `tests/route-analysis-cache.test.ts` -- add the passthrough test in `tests/route-chunks.test.ts` - -Goal: - -- prove the cache can wrap existing behavior without changing outputs - -Merge gate: - -- `T-CACHE-01,03,06,08` -- `T-CHUNK-01` -- `T-MAN-13` - -### Commit 2 — Remove the raw-source-only manifest/prerender duplication - -Files: - -- `src/manifest.ts` -- `src/index.ts` (`validateSsrFalsePrerenderExports` path) -- `tests/manifest-split-route-modules.test.ts` -- `tests/manifest-version.test.ts` -- `tests/manifest.test.ts` -- `tests/index.test.ts` and/or dedicated prerender validation tests - -Goal: - -- move CSS fallback to transformed code -- thread route analysis out of manifest generation -- delete the `getRouteModuleExports()` re-extraction pass from prerender validation - -Merge gate: - -- `T-MAN-06..09` -- `T-PRE-01..05` -- `T-IDX-01` -- `T-MAN-14..16` - -### Commit 3 — Convert transform/emit consumers to the shared cache - -Files: - -- `src/index.ts` transform hooks -- `src/modify-browser-manifest.ts` -- `tests/build-manifest.test.ts` -- new `tests/modify-browser-manifest.test.ts` - -Goal: - -- `route:client-entry`, `route:split-exports`, and `route:module` consume cached analysis -- browser-manifest emission receives the shared cache without changing SRI semantics - -Merge gate: - -- `T-BM-01..02` -- `T-SRI-01..05` -- transform-hook parity tests from the sibling chunk spec remain green - -### Commit 4 — Optional hardening follow-up - -Files: - -- `src/index.ts` web route entry emission around `:433-450` -- possibly manifest staging/reuse paths - -Goal: - -- replace raw `source.includes(exportName)` entry emission with analysis-driven chunk entries -- investigate whether prerender can reuse a staged manifest instead of forcing another generation - -This is optional because it may change config timing or asset-list behavior. Keep it separate from the main cache landing. - ---- - -## 8. Bottom line - -If the goal is a safe unified route-module analysis cache, the best path is: - -1. keep one plugin-instance cache for source-derived route facts, -2. move CSS fallback onto transformed code, -3. thread manifest analysis into prerender validation, -4. let build transforms reuse the same analysis object, -5. preserve separate source versions for disk and bundler inputs, -6. leave entry-emission hardening as a follow-up unless it can be proven behavior-neutral. - -That gives one analysis source of truth without breaking `serverBundles`, SRI, root-route chunk policy, or the future single-pass route-chunk plan. diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts index 095ddb8..d20bd40 100644 --- a/tests/benchmark-fixture.test.ts +++ b/tests/benchmark-fixture.test.ts @@ -38,8 +38,18 @@ describe('benchmark fixture generator', () => { expect(routeModule).toContain('export default function Route0003'); const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(rsbuildConfig).toContain( + "import { pluginReact } from '@rsbuild/plugin-react';" + ); + expect(rsbuildConfig).toContain('pluginReact(),'); + expect(rsbuildConfig.indexOf('pluginReact(),')).toBeLessThan( + rsbuildConfig.indexOf('pluginReactRouter({') + ); expect(rsbuildConfig).toContain('logPerformance'); - expect(rsbuildConfig).toContain('sourceMap: true'); + expect(rsbuildConfig).toContain( + "sourceMap: { js: 'cheap-module-source-map', css: false }" + ); + expect(rsbuildConfig).not.toContain('parallelTransforms:'); const reactRouterConfig = readFileSync( join(root, 'react-router.config.ts'), @@ -99,6 +109,28 @@ describe('benchmark fixture generator', () => { } }); + it('can explicitly disable parallel route transforms in benchmark config', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + parallelTransforms: false, + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(result.parallelTransforms).toBe(false); + expect(rsbuildConfig).toContain('parallelTransforms: false,'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('omits server-only route exports from SPA benchmark fixtures', async () => { const { generateSyntheticFixture } = await import( '../scripts/benchmark/fixture.mjs' @@ -215,7 +247,8 @@ describe('benchmark fixture generator', () => { '--iterations=1', '--warmup=0', '--filter=missing', - '--parallel-transforms=true', + '--rspack-profile=ALL', + '--rspack-trace-output=rspack.log', '--skip-root-build', ], { diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index ef28132..921d1d6 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -13,7 +13,7 @@ const routeChunkConfig = { }; describe('getBundlerRouteAnalysis', () => { - it('reuses transformed code, export names, and chunk info for the same source', async () => { + it('reuses source code, export names, and chunk info for the same source', async () => { const source = ` export const clientAction = async () => {}; export default function Route() { return null; } @@ -30,6 +30,7 @@ describe('getBundlerRouteAnalysis', () => { first.getRouteChunkInfo(undefined, routeChunkConfig) ); + expect(first.code).toBe(source); expect(first.exportNames).toEqual(['clientAction', 'default']); await expect( first.getRouteChunkInfo(undefined, routeChunkConfig) diff --git a/tests/features.test.ts b/tests/features.test.ts index a494069..9f122c0 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1,6 +1,7 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it, rstest } from '@rstest/core'; import { rspack } from '@rsbuild/core'; +import * as fs from 'node:fs'; import path from 'node:path'; import { pluginReactRouter } from '../src'; import { getVirtualModuleFilePath } from '../src/virtual-modules'; @@ -160,12 +161,22 @@ describe('pluginReactRouter', () => { }); it('should register build and dot file transforms', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); const rsbuild = await createStubRsbuild({ + action: 'build', rsbuildConfig: {}, }); - const plugin = pluginReactRouter(); - await plugin.setup(rsbuild as any); + try { + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as any); + } finally { + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + readFileSync.mockRestore(); + } const calls = (rsbuild.transform as any).mock.calls.map( (call: any[]) => call[0] @@ -186,13 +197,19 @@ describe('pluginReactRouter', () => { ) ).toBe(true); + const splitRouteExportsTransform = calls.find( + (call: any) => + typeof call.test === 'function' && + call.resourceQuery?.not?.toString().includes('route-chunk=') && + call.environments?.includes('web') + ); + expect(splitRouteExportsTransform).toBeDefined(); expect( - calls.some( - (call: any) => - call.test?.toString().includes('\\.[cm]?') && - call.environments?.includes('web') - ) + splitRouteExportsTransform.test(path.resolve('app/routes/index.tsx')) ).toBe(true); + expect(splitRouteExportsTransform.test(path.resolve('app/other.tsx'))).toBe( + false + ); expect( calls.some( diff --git a/tests/index.test.ts b/tests/index.test.ts index 3c6347c..85db3fe 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,6 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it, rstest } from '@rstest/core'; +import * as fs from 'node:fs'; import { pluginReactRouter } from '../src'; describe('pluginReactRouter', () => { @@ -112,6 +113,104 @@ describe('pluginReactRouter', () => { expect(nodeConfig.output.module).toBe(false); }); + it('configures web entries to avoid unnecessary entry IIFEs', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect( + config.environments?.web?.tools?.rspack?.optimization?.avoidEntryIife + ).toBe(true); + }); + + it('reduces file size reporting overhead for medium split route builds by default', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + process.env.RR_TEST_ROUTE_COUNT = '256'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + total: true, + detail: false, + compressed: false, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + + it('reduces file size reporting overhead for medium route builds by default', async () => { + process.env.RR_TEST_ROUTE_COUNT = '256'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + total: true, + detail: false, + compressed: false, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + + it('keeps explicit object file size reporting config for large split route builds', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + process.env.RR_TEST_ROUTE_COUNT = '1024'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: { + performance: { + printFileSize: { + detail: true, + compressed: true, + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + detail: true, + compressed: true, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + it('should forward lazy compilation when explicitly configured', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -168,6 +267,18 @@ describe('pluginReactRouter', () => { expect(webConfig.externalsType).toBe('module'); expect(webConfig.output.chunkFormat).toBe('module'); expect(webConfig.output.module).toBe(true); + + const webEntries = config.environments?.web?.source?.entry; + expect(webEntries['entry.client']).toEqual( + expect.stringMatching(/entry\.client/) + ); + expect(webEntries['virtual/react-router/browser-manifest']).toEqual({ + import: 'virtual/react-router/browser-manifest', + html: false, + }); + expect(webEntries['routes/index']).toMatchObject({ + html: false, + }); }); it('should configure node environment correctly', async () => { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index cb67291..2d77c1e 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { + createReactRouterManifestStats, configRoutesToRouteManifest, getReactRouterManifestForDev, + getReactRouterManifestChunkNames, getRouteManifestModuleExports, } from '../src/manifest'; @@ -42,6 +44,115 @@ const clientStats = { }; describe('manifest', () => { + it('creates manifest stats from named chunks without stats JSON', () => { + const compilation = { + namedChunks: new Map([ + [ + 'runtime', + { + files: new Set(['static/js/runtime.js']), + }, + ], + [ + 'entry.client', + { + files: new Set([ + 'static/js/entry.client.js', + 'static/css/entry.client.css', + ]), + }, + ], + [ + 'routes/page', + { + files: new Set(['static/js/routes/page.js']), + }, + ], + ]), + }; + + expect(createReactRouterManifestStats(compilation)).toEqual({ + assetsByChunkName: { + runtime: ['static/js/runtime.js'], + 'entry.client': [ + 'static/js/entry.client.js', + 'static/css/entry.client.css', + ], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('filters manifest stats to requested chunk names', () => { + const compilation = { + namedChunks: new Map([ + ['runtime', { files: new Set(['static/js/runtime.js']) }], + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['routes/page', { files: new Set(['static/js/routes/page.js']) }], + ['vendor', { files: new Set(['static/js/vendor.js']) }], + ]), + }; + + expect( + createReactRouterManifestStats( + compilation, + new Set(['entry.client', 'routes/page']) + ) + ).toEqual({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('uses direct named chunk lookup for filtered manifest stats when available', () => { + const chunks = new Map([ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['routes/page', { files: new Set(['static/js/routes/page.js']) }], + ]); + const compilation = { + namedChunks: { + get: (chunkName: string) => chunks.get(chunkName), + *[Symbol.iterator](): IterableIterator< + [string, { files: Set }] + > { + throw new Error('filtered manifest stats should not scan all chunks'); + }, + }, + }; + + expect( + createReactRouterManifestStats( + compilation, + new Set(['entry.client', 'routes/page']) + ) + ).toEqual({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('collects only manifest-readable chunk names', () => { + expect(Array.from(getReactRouterManifestChunkNames(routes, false))).toEqual( + ['entry.client', 'root', 'routes/page'] + ); + + expect(getReactRouterManifestChunkNames(routes, true)).toEqual( + new Set([ + 'entry.client', + 'root', + 'routes/page', + 'routes/page-client-action', + 'routes/page-client-loader', + 'routes/page-client-middleware', + 'routes/page-hydrate-fallback', + ]) + ); + }); + describe('configRoutesToRouteManifest', () => { it('should convert simple route config to manifest', () => { const routeConfig = [ @@ -213,7 +324,7 @@ describe('manifest', () => { expect(item).toHaveProperty('hasDefaultExport', false); }); - it('keeps route export names available without serializing internal analysis fields', async () => { + it('tracks route exports outside the manifest payload', async () => { const { root, appDir } = createTempApp(` export function headers() { return {}; } export async function action() { return null; } @@ -234,22 +345,15 @@ describe('manifest', () => { } ); - expect(manifest.routes['routes/page']).toMatchObject({ + const routeManifest = manifest.routes['routes/page']; + expect(routeManifest).toMatchObject({ hasAction: true, hasLoader: true, }); expect(getRouteManifestModuleExports(manifest)['routes/page']).toEqual( expect.arrayContaining(['headers', 'action', 'loader', 'default']) ); - expect(Object.keys(manifest).sort()).toEqual([ - 'entry', - 'hmr', - 'routes', - 'sri', - 'url', - 'version', - ]); - expect(JSON.stringify(manifest)).not.toContain('headers'); + expect(routeManifest).not.toHaveProperty('headers'); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts new file mode 100644 index 0000000..4141abf --- /dev/null +++ b/tests/modify-browser-manifest.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { createModifyBrowserManifestPlugin } from '../src/modify-browser-manifest'; + +const createTempApp = () => { + const root = mkdtempSync(join(tmpdir(), 'rr-modify-manifest-')); + const appDir = join(root, 'app'); + mkdirSync(join(appDir, 'routes'), { recursive: true }); + writeFileSync( + join(appDir, 'root.tsx'), + `export default function Root() { return null; }` + ); + writeFileSync( + join(appDir, 'routes/page.tsx'), + `export default function Page() { return null; }` + ); + return { root, appDir }; +}; + +const createAsset = (source: string) => ({ + source: () => source, + size: () => source.length, +}); + +describe('modify browser manifest plugin', () => { + it('does not read ignored chunk files while creating manifest stats', async () => { + const { root, appDir } = createTempApp(); + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, + }; + let emit: + | ((compilation: unknown, callback: (error?: Error) => void) => void) + | undefined; + const compiler = { + hooks: { + emit: { + tapAsync(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + + try { + createModifyBrowserManifestPlugin(routes, {}, appDir).apply( + compiler as never + ); + + const ignoredChunk = {}; + Object.defineProperty(ignoredChunk, 'files', { + get() { + throw new Error('ignored chunk files should not be read'); + }, + }); + + await new Promise((resolve, reject) => { + emit?.( + { + namedChunks: new Map([ + [ + 'entry.client', + { files: new Set(['static/js/entry.client.js']) }, + ], + ['root', { files: new Set(['static/js/root.js']) }], + [ + 'routes/page', + { files: new Set(['static/js/routes/page.js']) }, + ], + ['vendor', ignoredChunk], + ]), + assets: { + 'static/js/virtual/react-router/browser-manifest.js': createAsset( + 'window.__reactRouterManifest="PLACEHOLDER";' + ), + }, + }, + error => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('uses actual manifest chunk names instead of theoretical split route chunks', async () => { + const { root, appDir } = createTempApp(); + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, + }; + let emit: + | ((compilation: unknown, callback: (error?: Error) => void) => void) + | undefined; + const compiler = { + hooks: { + emit: { + tapAsync(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + + try { + createModifyBrowserManifestPlugin( + routes, + {}, + appDir, + '/', + { + splitRouteModules: true, + rootRouteFile: 'root.tsx', + isBuild: true, + }, + { + manifestChunkNames: new Set([ + 'entry.client', + 'root', + 'routes/page', + ]), + } + ).apply(compiler as never); + + const theoreticalSplitChunk = {}; + Object.defineProperty(theoreticalSplitChunk, 'files', { + get() { + throw new Error('theoretical split chunk files should not be read'); + }, + }); + + await new Promise((resolve, reject) => { + emit?.( + { + namedChunks: new Map([ + [ + 'entry.client', + { files: new Set(['static/js/entry.client.js']) }, + ], + ['root', { files: new Set(['static/js/root.js']) }], + [ + 'routes/page', + { files: new Set(['static/js/routes/page.js']) }, + ], + ['routes/page-client-loader', theoreticalSplitChunk], + ]), + assets: { + 'static/js/virtual/react-router/browser-manifest.js': createAsset( + 'window.__reactRouterManifest="PLACEHOLDER";' + ), + }, + }, + error => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 967b440..503a308 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it } from '@rstest/core'; -import { executeRouteTransformTask } from '../src/route-transform-tasks'; -import { createRouteTransformExecutor } from '../src/parallel-route-transforms'; +import { describe, expect, it, rstest } from '@rstest/core'; +import * as exportUtils from '../src/export-utils'; +import { + executeRouteTransformTask, + type RouteModuleTransformTask, +} from '../src/route-transform-tasks'; +import { + createRouteTransformExecutor, + getDefaultWorkerCount, +} from '../src/parallel-route-transforms'; import type { RouteChunkConfig } from '../src/route-chunks'; const routeChunkConfig: RouteChunkConfig = { @@ -15,8 +22,140 @@ const disabledRouteChunkConfig: RouteChunkConfig = { }; const resourcePath = '/app/routes/demo.tsx'; +const createRouteModuleTask = ( + overrides: Partial> = {} +): RouteModuleTransformTask => ({ + kind: 'routeModule' as const, + code: ` + import { serverValue } from '../server-data.server'; + export async function loader() { return serverValue; } + export default function Route() { return null; } + `, + resource: `${resourcePath}?react-router-route`, + resourcePath, + environmentName: 'web', + ssr: true, + isBuild: false, + isSpaMode: false, + rootRoutePath: '/app/root.tsx', + ...overrides, +}); describe('parallel route transforms', () => { + it.each([ + [1, {}, 0], + [2, {}, 0], + [3, {}, 0], + [4, {}, 2], + [6, {}, 4], + [8, {}, 6], + [24, {}, 8], + [24, { routeCount: 48 }, 0], + [24, { routeCount: 256 }, 6], + [24, { routeCount: 256, splitRouteModules: true }, 2], + [24, { routeCount: 1024 }, 2], + [24, { routeCount: 1024, splitRouteModules: true }, 2], + ])('chooses the default worker count', (cpus, options, workers) => { + expect(getDefaultWorkerCount(cpus, options)).toBe(workers); + }); + + it.each([ + [1, 0], + [2, 0], + [3, 0], + [4, 2], + [8, 2], + [10, 2], + [24, 2], + ])('caps medium split route module builds at two workers', (cpus, workers) => { + expect( + getDefaultWorkerCount(cpus, { + routeCount: 256, + splitRouteModules: true, + }) + ).toBe(workers); + }); + + it.each([ + [4, 2], + [6, 2], + [10, 2], + [24, 2], + ])('caps very large route module builds at two workers', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus, { routeCount: 1024 })).toBe(workers); + expect( + getDefaultWorkerCount(cpus, { + routeCount: 1024, + splitRouteModules: true, + }) + ).toBe(workers); + }); + + it.each([ + [1, 0], + [2, 0], + [3, 0], + [4, 2], + [6, 4], + [10, 6], + [24, 6], + ])('caps regular route builds at six workers', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus, { routeCount: 256 })).toBe(workers); + }); + + it.each([ + [1, 0], + [24, 0], + ])('runs small route builds inline by default', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus, { routeCount: 48 })).toBe(workers); + }); + + it('honors explicit maxWorkers for small route builds', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + routeCount: 48, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('runs small route builds inline when no worker pool is needed', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: true, + routeCount: 48, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it.each([ + [1, 0], + [2, 0], + [3, 0], + [4, 2], + [6, 4], + [8, 6], + [10, 8], + [12, 8], + [24, 8], + ])('defaults to cpu count minus two cores capped at eight workers', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus)).toBe(workers); + }); + it('executes route client entry tasks through the shared task executor', async () => { await expect( executeRouteTransformTask({ @@ -36,31 +175,266 @@ describe('parallel route transforms', () => { }); }); - it('can execute route module tasks through worker-backed parallelism', async () => { - const executor = createRouteTransformExecutor({ - parallelTransforms: { maxWorkers: 2 }, - }); + it('does not run bundler route analysis for client entries without split route chunks', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); try { - const result = await executor.run({ - kind: 'routeModule', + await executeRouteTransformTask({ + kind: 'routeClientEntry', code: ` - import { serverValue } from '../server-data.server'; - export async function loader() { return serverValue; } + export async function loader() { return null; } + export async function clientLoader() { return null; } export default function Route() { return null; } `, - resource: `${resourcePath}?react-router-route`, resourcePath, environmentName: 'web', - ssr: true, - isSpaMode: false, - rootRoutePath: '/app/root.tsx', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for split client entries without split export names', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + const result = await executeRouteTransformTask({ + kind: 'routeClientEntry', + code: ` + export async function loader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result.code).toBe( + `export { default } from "${resourcePath}?react-router-route";` + ); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for split route export modules without split export names', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + const code = ` + export async function loader() { return null; } + export default function Route() { return null; } + `; + + try { + const result = await executeRouteTransformTask({ + kind: 'splitRouteExports', + code, + resourcePath, + routeChunkConfig, + }); + + expect(result).toEqual({ code, map: null }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for client-only stubs', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + await executeRouteTransformTask({ + kind: 'clientOnlyStub', + code: ` + export const clientValue = 'client'; + export default function ClientOnly() { return null; } + `, + resourcePath: '/app/client-data.client.ts', + }); + + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('can execute route module tasks through worker-backed parallelism', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + expect(result.code).toContain('export default _withComponentProps'); expect(result.code).not.toContain('loader'); } finally { await executor.close(); } }); + + it('shares build route module results across environments when output is identical', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + routeCount: 1024, + splitRouteModules: true, + }); + const task = createRouteModuleTask({ + code: ` + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + environmentName: 'node', + isBuild: true, + }); + + try { + const nodeResult = await executor.run(task); + const webResult = await executor.run({ + ...task, + environmentName: 'web', + }); + + expect(webResult).toEqual(nodeResult); + } finally { + await executor.close(); + } + }); + + it('does not share build route module results when web removes server-only exports', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + routeCount: 1024, + splitRouteModules: true, + }); + const task = createRouteModuleTask({ + environmentName: 'node', + isBuild: true, + }); + + try { + const nodeResult = await executor.run(task); + const webResult = await executor.run({ + ...task, + environmentName: 'web', + }); + + expect(nodeResult.code).toContain('loader'); + expect(webResult.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('preserves value imports when web route modules have no server-only exports', async () => { + const result = await executeRouteTransformTask( + createRouteModuleTask({ + code: ` + import { setup } from './side-effect'; + export default function Route() { return null; } + `, + environmentName: 'web', + ssr: false, + isBuild: true, + }) + ); + + expect(result.code).toContain(`import { setup } from './side-effect';`); + }); + + it('does not run bundler route analysis for non-SPA route module transforms', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + await executeRouteTransformTask(createRouteModuleTask()); + + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('validates SPA route modules without bundler route analysis', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + const result = await executeRouteTransformTask( + createRouteModuleTask({ + code: ` + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + ssr: false, + isSpaMode: true, + }) + ); + + expect(result.code).toContain('clientLoader'); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('rejects invalid SPA route module exports from the route transform AST', async () => { + await expect( + executeRouteTransformTask( + createRouteModuleTask({ + code: ` + export async function action() { return null; } + export default function Route() { return null; } + `, + ssr: false, + isSpaMode: true, + }) + ) + ).rejects.toThrow('SPA Mode: 1 invalid route export'); + }); + + it('generates route module source maps only outside build mode', async () => { + const task = createRouteModuleTask({ + code: ` + export async function loader() { return null; } + export default function Route() { return null; } + `, + }); + + await expect( + executeRouteTransformTask({ + ...task, + isBuild: true, + }) + ).resolves.toMatchObject({ map: null }); + + const devResult = await executeRouteTransformTask({ + ...task, + isBuild: false, + }); + + expect(devResult.map).not.toBeNull(); + }); }); diff --git a/tests/performance.test.ts b/tests/performance.test.ts index 218dbdd..9738cef 100644 --- a/tests/performance.test.ts +++ b/tests/performance.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from '@rstest/core'; import { createReactRouterPerformanceProfiler } from '../src/performance'; +const parsePerformanceReport = (message: string) => { + const prefix = '[react-router:performance] '; + expect(message.startsWith(prefix)).toBe(true); + return JSON.parse(message.slice(prefix.length)); +}; + describe('React Router performance profiler', () => { it('aggregates operation timings by environment and logs structured JSON', async () => { const logs: string[] = []; @@ -27,7 +33,7 @@ describe('React Router performance profiler', () => { expect(logs).toHaveLength(1); expect(logs[0]).toContain('[react-router:performance]'); - const report = JSON.parse(logs[0].replace(/^.*?\{/, '{')); + const report = parsePerformanceReport(logs[0]); expect(report.environment).toBe('web'); expect(report.compilerLifecycleMs).toBe(123.4); expect(report.operations['route:client-entry'].count).toBe(2); @@ -41,7 +47,7 @@ describe('React Router performance profiler', () => { profiler.flush('web'); expect(logs).toHaveLength(2); - const secondReport = JSON.parse(logs[1].replace(/^.*?\{/, '{')); + const secondReport = parsePerformanceReport(logs[1]); expect(secondReport.operations['route:client-entry'].count).toBe(1); expect(secondReport.operations['manifest:stage']).toBeUndefined(); }); @@ -83,7 +89,7 @@ describe('React Router performance profiler', () => { profiler.flush('web'); - const report = JSON.parse(logs[0].replace(/^.*?\{/, '{')); + const report = parsePerformanceReport(logs[0]); expect(report.operations['route:module']).toMatchObject({ count: 2, totalMs: 55, @@ -99,6 +105,103 @@ describe('React Router performance profiler', () => { } }); + it('keeps only the five slowest operation entries in descending order', () => { + const logs: string[] = []; + const originalNow = performance.now; + const times = [ + 0, 3, 3, 12, 12, 14, 14, 20, 20, 21, 21, 29, 29, 33, + ]; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + const time = times.shift(); + if (time === undefined) { + throw new Error('unexpected timer read'); + } + return time; + }; + + for (const resource of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) { + profiler.recordSync('web', 'route:module', resource, () => resource); + } + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 9, resource: 'b' }, + { durationMs: 8, resource: 'f' }, + { durationMs: 6, resource: 'd' }, + { durationMs: 4, resource: 'g' }, + { durationMs: 3, resource: 'a' }, + ]); + } finally { + performance.now = originalNow; + } + }); + + it('rounds reported operation timings when flushing', () => { + const logs: string[] = []; + const originalNow = performance.now; + const times = [0, 1.04, 1.04, 1.16]; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + const time = times.shift(); + if (time === undefined) { + throw new Error('unexpected timer read'); + } + return time; + }; + + profiler.recordSync('web', 'route:module', 'app/routes/a.tsx', () => {}); + profiler.recordSync('web', 'route:module', 'app/routes/b.tsx', () => {}); + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module']).toMatchObject({ + totalMs: 1.2, + wallMs: 1.2, + maxMs: 1, + }); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 1, resource: 'app/routes/a.tsx' }, + { durationMs: 0.1, resource: 'app/routes/b.tsx' }, + ]); + } finally { + performance.now = originalNow; + } + }); + + it('records async operations without Promise finally overhead', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + const operation = Promise.resolve('route-module'); + operation.finally = () => { + throw new Error('profiler should avoid Promise.prototype.finally'); + }; + + await expect( + profiler.record('web', 'route:module', 'app/routes/a.tsx', () => { + return operation; + }) + ).resolves.toBe('route-module'); + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module'].count).toBe(1); + }); + it('does not evaluate timers or log output when disabled', async () => { const logs: string[] = []; const originalNow = performance.now; diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index ddb0e7d..ca85de0 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -165,5 +165,40 @@ describe('plugin-utils', () => { ); expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); }); + + it('avoids top-level generated helper name collisions', () => { + const result = transformRouteCode(` + const _withComponentProps = 'reserved'; + export default function Route() { return null; } + `); + + expect(result).toContain('withComponentProps as _withComponentProps2'); + expect(result).toContain('export default _withComponentProps2'); + }); + + it('does not reserve generated helper names used only in local scopes', () => { + const result = transformRouteCode(` + export default function Route() { + const _withComponentProps = 'local'; + return _withComponentProps; + } + `); + + expect(result).toContain('withComponentProps as _withComponentProps'); + expect(result).toContain('export default _withComponentProps(function Route'); + expect(result).not.toContain('_withComponentProps2'); + }); + + it('does not reserve generated helper names from re-export specifiers', () => { + const result = transformRouteCode(` + export { foo as _withComponentProps } from './foo'; + export default function Route() { return null; } + `); + + expect(result).toContain('withComponentProps as _withComponentProps'); + expect(result).toContain('export default _withComponentProps'); + expect(result).not.toContain('_withComponentProps2'); + }); }); + }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index bac6fba..144a597 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -19,6 +19,38 @@ function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { } describe('removeExports', () => { + it('returns false when no matching export can be removed', () => { + const code = ` + export const clientLoader = async () => null; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + const removed = removeExports(ast, ['loader', 'action']); + + expect(removed).toBe(false); + expect(generate(ast).code).toContain('clientLoader'); + }); + + it('returns true when a matching export is removed', () => { + const code = ` + export async function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + const removed = removeExports(ast, ['loader']); + + expect(removed).toBe(true); + expect(generate(ast).code).not.toContain('loader'); + }); + it('removes top-level property assignment when removed export is referenced by local name', () => { const code = ` const local = () => {}; diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index 16628c2..8200d6b 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from '@rstest/core'; -import { getBundlerRouteAnalysis } from '../src/export-utils'; +import { describe, expect, it, rstest } from '@rstest/core'; +import * as exportUtils from '../src/export-utils'; import { createRouteChunkArtifact, createRouteClientEntryArtifact, @@ -96,21 +96,62 @@ describe('route artifact helpers', () => { }); it('excludes split client exports from web build route entries', async () => { - const result = await createRouteClientEntryArtifact({ - code: ` - export const clientAction = async () => {}; - export async function clientLoader() { return null; } - export default function Route() { return null; } - `, - resourcePath, - environmentName: 'web', - isBuild: true, - routeChunkConfig, - }); + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); - expect(result).toEqual({ - code: `export { default } from ${JSON.stringify(routeRequest)};`, - }); + try { + const result = await createRouteClientEntryArtifact({ + code: ` + export const clientAction = async () => {}; + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { default } from ${JSON.stringify(routeRequest)};`, + }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run split analysis for root route client entries', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + const rootResourcePath = '/app/root.tsx'; + + try { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function clientLoader() { return null; } + export function HydrateFallback() { return null; } + export default function Root() { return null; } + `, + resourcePath: rootResourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { HydrateFallback, clientLoader, default } from ${JSON.stringify( + `${rootResourcePath}?react-router-route` + )};`, + }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } }); }); @@ -145,7 +186,10 @@ describe('route artifact helpers', () => { export default function Route() { return null; } `; const cache: RouteChunkCache = new Map(); - const analysis = await getBundlerRouteAnalysis(source, resourcePath); + const analysis = await exportUtils.getBundlerRouteAnalysis( + source, + resourcePath + ); const expectedCode = await getRouteChunkIfEnabled( cache, routeChunkConfig, @@ -159,6 +203,21 @@ describe('route artifact helpers', () => { expect(result).toEqual({ code: expectedCode, map: null }); }); + it('skips ESM transforms for named chunks when no route chunk exports exist', async () => { + await expect( + createRouteChunkArtifact({ + code: `export default function Route() { return null; }`, + resource: getRouteChunkModuleId(resourcePath, 'clientLoader'), + resourcePath: '/app/routes/demo.cts', + routeChunkConfig, + isBuild: true, + }) + ).resolves.toEqual({ + code: emptyRouteChunkSnippet('No clientLoader chunk'), + map: null, + }); + }); + it('validates enforce-mode main chunks against generated chunk exports', async () => { await expect( createRouteChunk( diff --git a/tests/route-chunks-cache.test.ts b/tests/route-chunks-cache.test.ts index fd00f66..a84b4f0 100644 --- a/tests/route-chunks-cache.test.ts +++ b/tests/route-chunks-cache.test.ts @@ -120,16 +120,35 @@ describe('route chunk cache', () => { expect(Array.from(cache.keys()).sort()).toEqual([ 'routes/demo.tsx::analyzeCode', + 'routes/demo.tsx::getChunkableExportMap', 'routes/demo.tsx::getChunkedExport::HydrateFallback', 'routes/demo.tsx::getChunkedExport::clientAction', 'routes/demo.tsx::getChunkedExport::clientLoader', 'routes/demo.tsx::getChunkedExport::clientMiddleware', 'routes/demo.tsx::getExportDependencies', - 'routes/demo.tsx::hasChunkableExport::HydrateFallback', - 'routes/demo.tsx::hasChunkableExport::clientAction', - 'routes/demo.tsx::hasChunkableExport::clientLoader', - 'routes/demo.tsx::hasChunkableExport::clientMiddleware', 'routes/demo.tsx::omitChunkedExports::clientAction,clientLoader,clientMiddleware,HydrateFallback', ]); }); + + it('precomputes sibling named chunk entries for repeated chunk generation', async () => { + const cache = new Map(); + + await getRouteChunkIfEnabled( + cache, + config, + routeId, + 'clientAction', + chunkableCode + ); + + expect(Array.from(cache.keys()).sort()).toEqual([ + 'routes/demo.tsx::analyzeCode', + 'routes/demo.tsx::getChunkableExportMap', + 'routes/demo.tsx::getChunkedExport::HydrateFallback', + 'routes/demo.tsx::getChunkedExport::clientAction', + 'routes/demo.tsx::getChunkedExport::clientLoader', + 'routes/demo.tsx::getChunkedExport::clientMiddleware', + 'routes/demo.tsx::getExportDependencies', + ]); + }); }); diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index bfd19a0..643edca 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -35,6 +35,7 @@ const routeId = '/app/routes/demo.tsx'; const rootRouteId = '/app/root.tsx'; const emptyChunkInfo: RouteChunkInfo = { + exportNames: [], chunkedExports: [], hasRouteChunks: false, hasRouteChunkByExportName: { @@ -83,8 +84,11 @@ const expectOnlyChunkedExport = ( } }; -const expectNoRouteChunks = (result: RouteChunkInfo) => { - expect(result).toEqual(emptyChunkInfo); +const expectNoRouteChunks = ( + result: RouteChunkInfo, + exportNames: string[] = [] +) => { + expect(result).toEqual({ ...emptyChunkInfo, exportNames }); }; const expectExports = async ( @@ -143,6 +147,30 @@ describe('route chunks', () => { expect(result.chunkedExports).toEqual(routeChunkExportNames); }); + it('returns runtime export names from route chunk analysis', async () => { + const result = await detectRouteChunksIfEnabled( + new Map(), + config, + routeId, + ` + export type LoaderData = { value: string }; + export type * from './types'; + export * from './shared'; + export * as helpers from './helpers'; + export const clientAction = async () => {}; + export async function loader() { return null; } + export default function Route() { return null; } + ` + ); + + expect(result.exportNames).toEqual([ + 'helpers', + 'clientAction', + 'loader', + 'default', + ]); + }); + it('allows client exports to depend on imports', async () => { const code = ` import { json } from 'react-router'; @@ -164,13 +192,13 @@ describe('route chunks', () => { const result = await detect(code); - expectNoRouteChunks(result); + expectNoRouteChunks(result, ['clientAction', 'clientLoader']); }); it('does not split a client export that shares top-level code with the default export', async () => { const result = await detect(codeWithClientActionSharedWithDefault); - expectNoRouteChunks(result); + expectNoRouteChunks(result, ['default', 'clientAction']); }); it('splits a single-binding destructured client export', async () => { @@ -194,7 +222,7 @@ describe('route chunks', () => { const result = await detect(code); - expectNoRouteChunks(result); + expectNoRouteChunks(result, ['clientAction', 'foo', 'default']); }); it('splits an isolated client export while leaving a non-splittable sibling unsplit', async () => { @@ -264,6 +292,22 @@ describe('route chunks', () => { await expectExports(chunk, ['default', 'action'], ['clientAction']); }); + it('returns main chunk code without analysis when no route chunk exports exist', async () => { + const cache = new Map(); + const code = `export default function Route() { return null; }`; + + const chunk = await getRouteChunkIfEnabled( + cache, + config, + routeId, + 'main', + code + ); + + expect(chunk).toBe(code); + expect(cache.size).toBe(0); + }); + it('generates an individual client chunk with only that client export', async () => { const code = ` import { json } from 'react-router'; @@ -415,7 +459,7 @@ describe('route chunks', () => { const result = await detect(code); - expectNoRouteChunks(result); + expectNoRouteChunks(result, ['default']); }); it('returns null when route chunk generation is disabled', async () => { diff --git a/tests/setup.ts b/tests/setup.ts index d86c4b1..8df5a63 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -10,6 +10,16 @@ rstest.mock('jiti', () => ({ createJiti: () => ({ import: rstest.fn().mockImplementation((path) => { if (path.includes('routes.ts')) { + const routeCount = Number(process.env.RR_TEST_ROUTE_COUNT ?? 0); + if (routeCount > 0) { + return Promise.resolve( + Array.from({ length: routeCount }, (_, index) => ({ + id: `routes/route-${index}`, + file: `routes/route-${index}.tsx`, + index: index === 0, + })) + ); + } return Promise.resolve([ { id: 'routes/index', @@ -18,6 +28,13 @@ rstest.mock('jiti', () => ({ }, ]); } + if (process.env.RR_TEST_SPLIT_ROUTE_MODULES === 'true') { + return Promise.resolve({ + future: { + v8_splitRouteModules: true, + }, + }); + } return Promise.resolve({}); }), }), From 149325cf8ed7a154b779f6371678661780fea050 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:08:52 +0200 Subject: [PATCH 26/30] Tune split route transform worker cap --- src/parallel-route-transforms.ts | 2 +- tests/parallel-route-transforms.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 623b21b..4a25218 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -79,7 +79,7 @@ const DEFAULT_RESERVED_CORES = 2; const DEFAULT_MIN_PARALLEL_ROUTES = 128; const DEFAULT_MAX_WORKERS = 8; const DEFAULT_ROUTE_MAX_WORKERS = 6; -const DEFAULT_SPLIT_ROUTE_MAX_WORKERS = 2; +const DEFAULT_SPLIT_ROUTE_MAX_WORKERS = 6; const DEFAULT_LARGE_ROUTE_MIN_ROUTES = 1024; const DEFAULT_LARGE_ROUTE_MAX_WORKERS = 2; const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 503a308..05c3e3e 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -52,7 +52,7 @@ describe('parallel route transforms', () => { [24, {}, 8], [24, { routeCount: 48 }, 0], [24, { routeCount: 256 }, 6], - [24, { routeCount: 256, splitRouteModules: true }, 2], + [24, { routeCount: 256, splitRouteModules: true }, 6], [24, { routeCount: 1024 }, 2], [24, { routeCount: 1024, splitRouteModules: true }, 2], ])('chooses the default worker count', (cpus, options, workers) => { @@ -64,10 +64,10 @@ describe('parallel route transforms', () => { [2, 0], [3, 0], [4, 2], - [8, 2], - [10, 2], - [24, 2], - ])('caps medium split route module builds at two workers', (cpus, workers) => { + [8, 6], + [10, 6], + [24, 6], + ])('caps medium split route module builds at six workers', (cpus, workers) => { expect( getDefaultWorkerCount(cpus, { routeCount: 256, From 36143711deb30142148068ed5425c80c2a760be8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:39:12 +0200 Subject: [PATCH 27/30] Remove default route transform worker caps --- src/parallel-route-transforms.ts | 24 +++-------------- tests/parallel-route-transforms.test.ts | 36 ++++++++++++------------- 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 4a25218..d2a74f5 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -77,11 +77,7 @@ class WorkerStartupError extends Error { const DEFAULT_RESERVED_CORES = 2; const DEFAULT_MIN_PARALLEL_ROUTES = 128; -const DEFAULT_MAX_WORKERS = 8; -const DEFAULT_ROUTE_MAX_WORKERS = 6; -const DEFAULT_SPLIT_ROUTE_MAX_WORKERS = 6; -const DEFAULT_LARGE_ROUTE_MIN_ROUTES = 1024; -const DEFAULT_LARGE_ROUTE_MAX_WORKERS = 2; +const DEFAULT_SHARE_ROUTE_MODULE_BUILD_RESULTS_MIN_ROUTES = 1024; const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; @@ -92,10 +88,7 @@ const getAvailableCpuCount = (): number => export const getDefaultWorkerCount = ( cpuCount: number = getAvailableCpuCount(), - { - routeCount, - splitRouteModules = false, - }: Pick< + { routeCount }: Pick< RouteTransformExecutorOptions, 'routeCount' | 'splitRouteModules' > = {} @@ -107,20 +100,11 @@ export const getDefaultWorkerCount = ( return 0; } - const maxWorkers = - typeof routeCount === 'number' && - routeCount >= DEFAULT_LARGE_ROUTE_MIN_ROUTES - ? DEFAULT_LARGE_ROUTE_MAX_WORKERS - : splitRouteModules - ? DEFAULT_SPLIT_ROUTE_MAX_WORKERS - : typeof routeCount === 'number' - ? DEFAULT_ROUTE_MAX_WORKERS - : DEFAULT_MAX_WORKERS; const workerCount = Math.floor(cpuCount) - DEFAULT_RESERVED_CORES; if (workerCount < 2) { return 0; } - return Math.min(maxWorkers, workerCount); + return workerCount; }; const getConfiguredWorkerCount = ( @@ -435,7 +419,7 @@ export const createRouteTransformExecutor = ({ Boolean( splitRouteModules && typeof routeCount === 'number' && - routeCount >= DEFAULT_LARGE_ROUTE_MIN_ROUTES + routeCount >= DEFAULT_SHARE_ROUTE_MODULE_BUILD_RESULTS_MIN_ROUTES ) ); }; diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 05c3e3e..03e1df2 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -49,12 +49,12 @@ describe('parallel route transforms', () => { [4, {}, 2], [6, {}, 4], [8, {}, 6], - [24, {}, 8], + [24, {}, 22], [24, { routeCount: 48 }, 0], - [24, { routeCount: 256 }, 6], - [24, { routeCount: 256, splitRouteModules: true }, 6], - [24, { routeCount: 1024 }, 2], - [24, { routeCount: 1024, splitRouteModules: true }, 2], + [24, { routeCount: 256 }, 22], + [24, { routeCount: 256, splitRouteModules: true }, 22], + [24, { routeCount: 1024 }, 22], + [24, { routeCount: 1024, splitRouteModules: true }, 22], ])('chooses the default worker count', (cpus, options, workers) => { expect(getDefaultWorkerCount(cpus, options)).toBe(workers); }); @@ -65,9 +65,9 @@ describe('parallel route transforms', () => { [3, 0], [4, 2], [8, 6], - [10, 6], - [24, 6], - ])('caps medium split route module builds at six workers', (cpus, workers) => { + [10, 8], + [24, 22], + ])('uses cpu count minus two workers for split route module builds', (cpus, workers) => { expect( getDefaultWorkerCount(cpus, { routeCount: 256, @@ -78,10 +78,10 @@ describe('parallel route transforms', () => { it.each([ [4, 2], - [6, 2], - [10, 2], - [24, 2], - ])('caps very large route module builds at two workers', (cpus, workers) => { + [6, 4], + [10, 8], + [24, 22], + ])('uses cpu count minus two workers for very large route module builds', (cpus, workers) => { expect(getDefaultWorkerCount(cpus, { routeCount: 1024 })).toBe(workers); expect( getDefaultWorkerCount(cpus, { @@ -97,9 +97,9 @@ describe('parallel route transforms', () => { [3, 0], [4, 2], [6, 4], - [10, 6], - [24, 6], - ])('caps regular route builds at six workers', (cpus, workers) => { + [10, 8], + [24, 22], + ])('uses cpu count minus two workers for regular route builds', (cpus, workers) => { expect(getDefaultWorkerCount(cpus, { routeCount: 256 })).toBe(workers); }); @@ -150,9 +150,9 @@ describe('parallel route transforms', () => { [6, 4], [8, 6], [10, 8], - [12, 8], - [24, 8], - ])('defaults to cpu count minus two cores capped at eight workers', (cpus, workers) => { + [12, 10], + [24, 22], + ])('defaults to cpu count minus two cores', (cpus, workers) => { expect(getDefaultWorkerCount(cpus)).toBe(workers); }); From 061b9a0114c1c74cede929e063887debee999e2c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:45:43 +0200 Subject: [PATCH 28/30] Default concurrency to available cores minus two --- README.md | 12 +++++------ src/concurrency.ts | 12 +++++++++++ src/parallel-route-transforms.ts | 28 ++++--------------------- src/prerender.ts | 8 +++++-- src/types.ts | 2 +- tests/parallel-route-transforms.test.ts | 21 +++++++++++-------- tests/prerender.test.ts | 4 +++- 7 files changed, 44 insertions(+), 43 deletions(-) create mode 100644 src/concurrency.ts diff --git a/README.md b/README.md index 31d39ff..33b4587 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ pluginReactRouter({ /** * Run route transforms in a worker-thread pool. * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. - * @default true, inline for small route graphs or low-core CPUs; otherwise `available CPUs - 2`, capped at 8 workers, 6 workers for known large route graphs, or 2 workers for split builds and 1024+ route graphs. + * @default true, using `available CPUs - 2` workers. */ parallelTransforms?: boolean | { maxWorkers?: number }, @@ -277,7 +277,8 @@ export default { } satisfies Config; ``` -For large sites, you can tune prerender concurrency: +For large sites, prerendering defaults to `availableParallelism - 2` concurrent +paths. You can tune prerender concurrency: ```ts export default { @@ -313,10 +314,9 @@ If no configuration is provided, the following defaults will be used: } ``` -`parallelTransforms: true` uses worker threads for large route builds. The default -worker count is `availableParallelism - 2`, capped at 8 workers. Known large -route graphs cap at 6 workers; split builds and 1024+ route graphs cap at 2 -workers. +`parallelTransforms: true` uses worker threads for route builds. The default +worker count is `availableParallelism - 2`. Pass `{ maxWorkers }` to override +that count, or `false` to run route transforms inline. For builds with 256+ routes, detailed file-size reporting is compacted to totals by default to avoid gzipping and printing thousands of assets. Set diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 0000000..a6cb619 --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,12 @@ +import { availableParallelism, cpus } from 'node:os'; + +const DEFAULT_RESERVED_CORES = 2; + +const getAvailableCpuCount = (): number => + typeof availableParallelism === 'function' + ? availableParallelism() + : cpus().length; + +export const getDefaultConcurrency = ( + cpuCount: number = getAvailableCpuCount() +): number => Math.max(0, Math.floor(cpuCount) - DEFAULT_RESERVED_CORES); diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index d2a74f5..86b7b0d 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -1,6 +1,6 @@ -import { availableParallelism, cpus } from 'node:os'; import { Worker } from 'node:worker_threads'; import { SERVER_ONLY_ROUTE_EXPORTS } from './constants.js'; +import { getDefaultConcurrency } from './concurrency.js'; import { executeRouteTransformTask, type RouteTransformResult, @@ -75,37 +75,17 @@ class WorkerStartupError extends Error { } } -const DEFAULT_RESERVED_CORES = 2; -const DEFAULT_MIN_PARALLEL_ROUTES = 128; const DEFAULT_SHARE_ROUTE_MODULE_BUILD_RESULTS_MIN_ROUTES = 1024; const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; -const getAvailableCpuCount = (): number => - typeof availableParallelism === 'function' - ? availableParallelism() - : cpus().length; - export const getDefaultWorkerCount = ( - cpuCount: number = getAvailableCpuCount(), - { routeCount }: Pick< + cpuCount?: number, + _options: Pick< RouteTransformExecutorOptions, 'routeCount' | 'splitRouteModules' > = {} -): number => { - if ( - typeof routeCount === 'number' && - routeCount < DEFAULT_MIN_PARALLEL_ROUTES - ) { - return 0; - } - - const workerCount = Math.floor(cpuCount) - DEFAULT_RESERVED_CORES; - if (workerCount < 2) { - return 0; - } - return workerCount; -}; +): number => getDefaultConcurrency(cpuCount); const getConfiguredWorkerCount = ( parallelTransforms: ParallelTransformsConfig, diff --git a/src/prerender.ts b/src/prerender.ts index b0bf5d9..9fbde67 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -1,3 +1,4 @@ +import { getDefaultConcurrency } from './concurrency.js'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -134,7 +135,10 @@ export const resolvePrerenderPaths = async ( return pathsConfig ?? []; }; -export const getPrerenderConcurrency = (prerender: PrerenderConfig): number => { +export const getPrerenderConcurrency = ( + prerender: PrerenderConfig, + cpuCount?: number +): number => { if ( typeof prerender === 'object' && prerender !== null && @@ -145,7 +149,7 @@ export const getPrerenderConcurrency = (prerender: PrerenderConfig): number => { return value; } } - return 1; + return getDefaultConcurrency(cpuCount); }; const isValidPrerenderPathsConfig = ( diff --git a/src/types.ts b/src/types.ts index 8531ec4..ba40843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,7 @@ export type PluginOptions = { /** * Run route transforms in a worker-thread pool. * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. - * @default true, inline for small route graphs or low-core CPUs; otherwise `available CPUs - 2`, capped at 8 workers, 6 workers for known large route graphs, or 2 workers for split builds and 1024+ route graphs. + * @default true, using `available CPUs - 2` workers. */ parallelTransforms?: | boolean diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 03e1df2..5debe6f 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -45,12 +45,12 @@ describe('parallel route transforms', () => { it.each([ [1, {}, 0], [2, {}, 0], - [3, {}, 0], + [3, {}, 1], [4, {}, 2], [6, {}, 4], [8, {}, 6], [24, {}, 22], - [24, { routeCount: 48 }, 0], + [24, { routeCount: 48 }, 22], [24, { routeCount: 256 }, 22], [24, { routeCount: 256, splitRouteModules: true }, 22], [24, { routeCount: 1024 }, 22], @@ -62,7 +62,7 @@ describe('parallel route transforms', () => { it.each([ [1, 0], [2, 0], - [3, 0], + [3, 1], [4, 2], [8, 6], [10, 8], @@ -77,6 +77,7 @@ describe('parallel route transforms', () => { }); it.each([ + [3, 1], [4, 2], [6, 4], [10, 8], @@ -94,7 +95,7 @@ describe('parallel route transforms', () => { it.each([ [1, 0], [2, 0], - [3, 0], + [3, 1], [4, 2], [6, 4], [10, 8], @@ -105,8 +106,10 @@ describe('parallel route transforms', () => { it.each([ [1, 0], - [24, 0], - ])('runs small route builds inline by default', (cpus, workers) => { + [2, 0], + [3, 1], + [24, 22], + ])('uses cpu count minus two workers for small route builds', (cpus, workers) => { expect(getDefaultWorkerCount(cpus, { routeCount: 48 })).toBe(workers); }); @@ -126,9 +129,9 @@ describe('parallel route transforms', () => { } }); - it('runs small route builds inline when no worker pool is needed', async () => { + it('runs route builds inline when parallel transforms are disabled', async () => { const executor = createRouteTransformExecutor({ - parallelTransforms: true, + parallelTransforms: false, routeCount: 48, }); @@ -145,7 +148,7 @@ describe('parallel route transforms', () => { it.each([ [1, 0], [2, 0], - [3, 0], + [3, 1], [4, 2], [6, 4], [8, 6], diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index c2cdcc6..2f3171a 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -87,6 +87,8 @@ describe('prerender helpers', () => { expect( getPrerenderConcurrency({ paths: ['/'], unstable_concurrency: 3 }) ).toBe(3); - expect(getPrerenderConcurrency({ paths: ['/'] })).toBe(1); + expect(getPrerenderConcurrency({ paths: ['/'] }, 24)).toBe(22); + expect(getPrerenderConcurrency({ paths: ['/'] }, 3)).toBe(1); + expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(0); }); }); From adb6b5787a751232db6371dde2fd452c989f1235 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:12:19 +0200 Subject: [PATCH 29/30] chore: simplify bundling performance branch --- package.json | 3 - scripts/bench-client-entry-analysis.mjs | 417 ---------------------- scripts/benchmark-yuku.mjs | 307 ---------------- scripts/compare-client-entry-analysis.mjs | 159 --------- src/index.ts | 1 - src/parallel-route-transforms.ts | 33 +- tests/parallel-route-transforms.test.ts | 85 +---- 7 files changed, 12 insertions(+), 993 deletions(-) delete mode 100644 scripts/bench-client-entry-analysis.mjs delete mode 100644 scripts/benchmark-yuku.mjs delete mode 100644 scripts/compare-client-entry-analysis.mjs diff --git a/package.json b/package.json index 9445b0d..e2abf7e 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,7 @@ "scripts": { "build": "rslib build", "bench": "node scripts/bench-builds.mjs", - "bench:micro": "node scripts/bench-client-entry-analysis.mjs", "bench:compare": "node scripts/compare-benchmarks.mjs", - "bench:compare:micro": "node scripts/compare-client-entry-analysis.mjs", "bench:smoke": "node scripts/bench-builds.mjs --profile smoke --iterations 1 --warmup 0 --format both --out .benchmark/results/smoke", "bench:baseline": "node scripts/bench-builds.mjs --profile default --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/baseline", "bench:full": "node scripts/bench-builds.mjs --profile full --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/full", @@ -61,7 +59,6 @@ "test": "rstest run", "test:watch": "rstest watch", "test:coverage": "rstest run --coverage", - "bench:yuku": "node scripts/benchmark-yuku.mjs --compare-head", "test:core": "rstest run -c ./rstest.config.ts", "test:core:watch": "rstest watch -c ./rstest.config.ts", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", diff --git a/scripts/bench-client-entry-analysis.mjs b/scripts/bench-client-entry-analysis.mjs deleted file mode 100644 index e3bd66a..0000000 --- a/scripts/bench-client-entry-analysis.mjs +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env node -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { parseArgs as parseCliArgs } from 'node:util'; -import { createJiti } from 'jiti'; -import { generateSyntheticFixture, routeFile } from './benchmark/fixture.mjs'; - -const rootDir = process.cwd(); -const schemaVersion = 1; - -const parseArgs = argv => { - const { values } = parseCliArgs({ - args: argv, - allowPositionals: false, - strict: true, - options: { - routes: { type: 'string', default: '256' }, - variant: { type: 'string', default: 'ssr-esm-split' }, - fixture: { type: 'string', default: 'default' }, - iterations: { type: 'string', default: '50' }, - warmup: { type: 'string', default: '5' }, - out: { - type: 'string', - default: path.join( - '.benchmark', - 'results', - 'micro-client-entry-analysis.json' - ), - }, - 'fixture-root': { type: 'string' }, - 'reuse-fixture': { type: 'boolean', default: false }, - environment: { type: 'string', default: 'both' }, - cache: { type: 'string', default: 'cold' }, - format: { type: 'string', default: 'both' }, - }, - }); - - const args = { - routes: Number(values.routes), - variant: values.variant, - fixture: values.fixture, - iterations: Number(values.iterations), - warmup: Number(values.warmup), - out: values.out, - fixtureRoot: values['fixture-root'], - reuseFixture: values['reuse-fixture'], - environment: values.environment, - cache: values.cache, - format: values.format, - }; - - if (!Number.isInteger(args.routes) || args.routes < 1) { - throw new Error('--routes must be a positive integer.'); - } - if (!Number.isInteger(args.iterations) || args.iterations < 1) { - throw new Error('--iterations must be a positive integer.'); - } - if (!Number.isInteger(args.warmup) || args.warmup < 0) { - throw new Error('--warmup must be a non-negative integer.'); - } - if (!['client', 'server', 'both'].includes(args.environment)) { - throw new Error('--environment must be client, server, or both.'); - } - if (!['cold', 'warm'].includes(args.cache)) { - throw new Error('--cache must be cold or warm.'); - } - if (!['json', 'md', 'markdown', 'both'].includes(args.format)) { - throw new Error('--format must be json, md, markdown, or both.'); - } - - return args; -}; - -const summarizeMetric = values => { - const sorted = values - .filter(value => typeof value === 'number' && Number.isFinite(value)) - .sort((a, b) => a - b); - if (sorted.length === 0) { - return { min: null, mean: null, p95: null, stdev: null, max: null }; - } - const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; - const variance = - sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; - const p95Index = Math.min( - sorted.length - 1, - Math.ceil(sorted.length * 0.95) - 1 - ); - return { - min: sorted[0], - mean, - p95: sorted[p95Index], - stdev: Math.sqrt(variance), - max: sorted[sorted.length - 1], - }; -}; - -const timeAsync = async callback => { - const start = performance.now(); - const value = await callback(); - return { value, ms: performance.now() - start }; -}; - -const timeSync = callback => { - const start = performance.now(); - const value = callback(); - return { value, ms: performance.now() - start }; -}; - -const environmentNames = mode => { - if (mode === 'both') { - return ['client', 'server']; - } - return [mode]; -}; - -const shouldSplitRouteModules = variant => variant.includes('split'); - -const loadPluginInternals = async () => { - const jiti = createJiti(import.meta.url, { - interopDefault: true, - }); - const [exportUtils, routeArtifacts] = await Promise.all([ - jiti.import(path.join(rootDir, 'src/export-utils.ts')), - jiti.import(path.join(rootDir, 'src/route-artifacts.ts')), - ]); - return { - getBundlerRouteAnalysis: exportUtils.getBundlerRouteAnalysis, - buildRouteClientEntryCode: routeArtifacts.buildRouteClientEntryCode, - }; -}; - -const readRouteSources = async (fixtureRoot, routeCount) => - Promise.all( - Array.from({ length: routeCount }, async (_, routeIndex) => { - const index = routeIndex + 1; - const resourcePath = path.join(fixtureRoot, 'app', routeFile(index)); - return { - index, - resourcePath, - source: await readFile(resourcePath, 'utf8'), - }; - }) - ); - -const runRoute = async ({ - route, - iteration, - environment, - cacheMode, - splitRouteModules, - routeChunkCache, - routeChunkConfig, - internals, -}) => { - const isServer = environment === 'server'; - const benchmarkSource = - cacheMode === 'cold' - ? `${route.source}\nconst __clientEntryAnalysisBenchmarkSalt_${iteration}_${environment}_${route.index} = ${iteration + route.index};\n` - : route.source; - const benchmarkResourcePath = - cacheMode === 'cold' - ? path.join( - path.dirname(route.resourcePath), - `.micro-${iteration}-${environment}-${path.basename(route.resourcePath)}` - ) - : route.resourcePath; - - const transformExport = await timeAsync(async () => { - const analysis = await internals.getBundlerRouteAnalysis( - benchmarkSource, - benchmarkResourcePath - ); - const exportNames = analysis.exportNames; - return { analysis, exportNames }; - }); - - const routeChunk = await timeAsync(async () => { - if (isServer || !splitRouteModules) { - return { chunkedExports: [] }; - } - return transformExport.value.analysis.getRouteChunkInfo( - routeChunkCache, - routeChunkConfig - ); - }); - - const filterCodegen = timeSync(() => { - return internals.buildRouteClientEntryCode({ - exportNames: transformExport.value.exportNames, - chunkedExports: routeChunk.value.chunkedExports, - isServer, - resourcePath: route.resourcePath, - }); - }); - - const totalMs = transformExport.ms + routeChunk.ms + filterCodegen.ms; - return { - route: route.index, - environment, - timings: { - transformExportMs: transformExport.ms, - routeChunkInfoMs: routeChunk.ms, - filterCodegenMs: filterCodegen.ms, - totalMs, - }, - operations: { - exportNames: transformExport.value.exportNames.length, - reexports: filterCodegen.value.reexports.length, - chunkedExports: routeChunk.value.chunkedExports.length, - codegenBytes: Buffer.byteLength(filterCodegen.value.code), - }, - }; -}; - -const renderMarkdown = result => { - const lines = [ - '# Route Client-entry Analysis Microbenchmark', - '', - `- Schema version: ${result.schemaVersion}`, - `- Date: ${result.date}`, - `- Node: ${result.node}`, - `- Platform: ${result.platform}`, - `- Routes: ${result.routeCount}`, - `- Variant: ${result.variant}`, - `- Fixture: ${result.fixture}`, - `- Split route modules: ${result.splitRouteModules}`, - `- Cache mode: ${result.cacheMode}`, - `- Environments: ${result.environments.join(', ')}`, - `- Iterations: ${result.iterations}`, - `- Warmup: ${result.warmup}`, - '', - '## Phase timings per route', - '', - '| Phase | Mean | p95 | Stdev |', - '|---|---:|---:|---:|', - ]; - - for (const [phase, stats] of Object.entries(result.summary.phases)) { - lines.push( - `| ${phase} | ${stats.mean?.toFixed(3) ?? '-'}ms | ${stats.p95?.toFixed(3) ?? '-'}ms | ${stats.stdev?.toFixed(3) ?? '-'}ms |` - ); - } - - lines.push( - '', - '## Operation counts', - '', - '| Operation | Count |', - '|---|---:|' - ); - for (const [operation, count] of Object.entries(result.operationCounts)) { - lines.push(`| ${operation} | ${count} |`); - } - - lines.push(''); - return `${lines.join('\n')}\n`; -}; - -const writeOutputs = async (result, args) => { - const outPath = path.resolve(rootDir, args.out); - const format = args.format === 'markdown' ? 'md' : args.format; - const writeJson = format === 'json' || format === 'both'; - const writeMd = format === 'md' || format === 'both'; - const jsonPath = outPath.endsWith('.json') ? outPath : `${outPath}.json`; - const mdPath = outPath.endsWith('.json') - ? outPath.replace(/\.json$/, '.md') - : `${outPath}.md`; - - await mkdir(path.dirname(outPath), { recursive: true }); - if (writeJson) { - await writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`); - } - if (writeMd) { - await writeFile(mdPath, renderMarkdown(result)); - } - return { - jsonPath: writeJson ? jsonPath : null, - mdPath: writeMd ? mdPath : null, - }; -}; - -const main = async () => { - const args = parseArgs(process.argv.slice(2)); - const fixtureRoot = path.resolve( - rootDir, - args.fixtureRoot ?? - path.join( - '.benchmark', - 'fixtures', - `micro-client-entry-${args.routes}-${args.variant}-${args.fixture}` - ) - ); - - if (!args.reuseFixture) { - await generateSyntheticFixture({ - root: fixtureRoot, - routeCount: args.routes, - variant: args.variant, - fixture: args.fixture, - }); - } - - const internals = await loadPluginInternals(); - const routes = await readRouteSources(fixtureRoot, args.routes); - const environments = environmentNames(args.environment); - const splitRouteModules = shouldSplitRouteModules(args.variant); - const routeChunkConfig = { - splitRouteModules, - appDirectory: path.join(fixtureRoot, 'app'), - rootRouteFile: 'root.tsx', - }; - const routeChunkCache = args.cache === 'warm' ? new Map() : undefined; - - const measuredIterations = []; - const phaseSamples = { - transformExportMs: [], - routeChunkInfoMs: [], - filterCodegenMs: [], - totalMs: [], - }; - const operationCounts = { - routeExecutions: 0, - exportNames: 0, - reexports: 0, - chunkedExports: 0, - codegenBytes: 0, - }; - - const totalRuns = args.warmup + args.iterations; - for (let iteration = 0; iteration < totalRuns; iteration += 1) { - const measured = iteration >= args.warmup; - const heapBefore = process.memoryUsage().heapUsed; - const startedAt = performance.now(); - const routeResults = []; - - for (const environment of environments) { - for (const route of routes) { - const result = await runRoute({ - route, - iteration, - environment, - cacheMode: args.cache, - splitRouteModules, - routeChunkCache: args.cache === 'cold' ? undefined : routeChunkCache, - routeChunkConfig, - internals, - }); - routeResults.push(result); - } - } - - const heapAfter = process.memoryUsage().heapUsed; - if (measured) { - for (const result of routeResults) { - for (const [phase, value] of Object.entries(result.timings)) { - phaseSamples[phase].push(value); - } - operationCounts.routeExecutions += 1; - operationCounts.exportNames += result.operations.exportNames; - operationCounts.reexports += result.operations.reexports; - operationCounts.chunkedExports += result.operations.chunkedExports; - operationCounts.codegenBytes += result.operations.codegenBytes; - } - measuredIterations.push({ - iteration: measuredIterations.length + 1, - wallMs: performance.now() - startedAt, - heapDeltaBytes: heapAfter - heapBefore, - routeExecutions: routeResults.length, - }); - } - } - - const result = { - schema: 'rsbuild-plugin-react-router/client-entry-analysis-benchmark', - schemaVersion, - date: new Date().toISOString(), - node: process.version, - platform: `${os.platform()} ${os.release()} ${os.arch()}`, - routeCount: args.routes, - variant: args.variant, - fixture: args.fixture, - splitRouteModules, - environments, - cacheMode: args.cache, - iterations: args.iterations, - warmup: args.warmup, - fixtureRoot, - summary: { - phases: Object.fromEntries( - Object.entries(phaseSamples).map(([phase, samples]) => [ - phase, - summarizeMetric(samples), - ]) - ), - iterationWallMs: summarizeMetric( - measuredIterations.map(run => run.wallMs) - ), - heapDeltaBytes: summarizeMetric( - measuredIterations.map(run => run.heapDeltaBytes) - ), - }, - operationCounts, - runs: measuredIterations, - }; - - const outputs = await writeOutputs(result, args); - console.log( - `Wrote client-entry analysis benchmark${outputs.jsonPath ? ` JSON to ${path.relative(rootDir, outputs.jsonPath)}` : ''}${outputs.mdPath ? ` and markdown to ${path.relative(rootDir, outputs.mdPath)}` : ''}.` - ); -}; - -main().catch(error => { - console.error(error?.stack || error); - process.exitCode = 1; -}); diff --git a/scripts/benchmark-yuku.mjs b/scripts/benchmark-yuku.mjs deleted file mode 100644 index eb3d21d..0000000 --- a/scripts/benchmark-yuku.mjs +++ /dev/null @@ -1,307 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { mkdir, mkdtemp, readdir, symlink } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { createJiti } from 'jiti'; - -const iterations = Number(process.env.BENCH_ITERATIONS ?? 250); -const sampleCount = Number(process.env.BENCH_SAMPLES ?? 24); - -const exec = (cmd, args, options = {}) => { - const result = spawnSync(cmd, args, { - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - ...options, - }); - if (result.status !== 0) { - throw new Error( - [`Command failed: ${cmd} ${args.join(' ')}`, result.stdout, result.stderr] - .filter(Boolean) - .join('\n') - ); - } - return result.stdout; -}; - -const createOldCheckout = async repoRoot => { - const dir = await mkdtemp(path.join(tmpdir(), 'rr-yuku-before-')); - const archive = path.join(dir, 'head.tar'); - exec('git', ['archive', 'HEAD', '-o', archive], { cwd: repoRoot }); - const checkout = path.join(dir, 'repo'); - exec('mkdir', ['-p', checkout]); - exec('tar', ['-xf', archive, '-C', checkout]); - await linkNodeModules(repoRoot, checkout); - return checkout; -}; - -const linkNodeModules = async (repoRoot, checkout) => { - const sourceNodeModules = path.join(repoRoot, 'node_modules'); - const targetNodeModules = path.join(checkout, 'node_modules'); - await mkdir(targetNodeModules, { recursive: true }); - - for (const entry of await readdir(sourceNodeModules, { - withFileTypes: true, - })) { - if (entry.name === '.pnpm') { - continue; - } - const source = path.join(sourceNodeModules, entry.name); - const target = path.join(targetNodeModules, entry.name); - if (entry.name.startsWith('@') && entry.isDirectory()) { - await mkdir(target, { recursive: true }); - for (const scoped of await readdir(source)) { - const scopedTarget = path.join(target, scoped); - if (!existsSync(scopedTarget)) { - await symlink(path.join(source, scoped), scopedTarget); - } - } - continue; - } - if (!existsSync(target)) { - await symlink(source, target); - } - } - - const oldOnlyPackages = [ - '@babel/core', - '@babel/generator', - '@babel/parser', - '@babel/traverse', - '@babel/types', - 'babel-dead-code-elimination', - 'es-module-lexer', - 'esbuild', - ]; - for (const packageName of oldOnlyPackages) { - await linkPnpmPackage(sourceNodeModules, targetNodeModules, packageName); - } -}; - -const linkPnpmPackage = async ( - sourceNodeModules, - targetNodeModules, - packageName -) => { - const source = findPnpmPackage(sourceNodeModules, packageName); - if (!source) { - throw new Error(`Could not find ${packageName} in node_modules/.pnpm`); - } - const segments = packageName.split('/'); - const target = - segments.length === 1 - ? path.join(targetNodeModules, packageName) - : path.join(targetNodeModules, segments[0], segments[1]); - await mkdir(path.dirname(target), { recursive: true }); - if (!existsSync(target)) { - await symlink(source, target); - } -}; - -const findPnpmPackage = (sourceNodeModules, packageName) => { - const pnpmDir = path.join(sourceNodeModules, '.pnpm'); - const encodedName = packageName.replace('/', '+'); - const entries = spawnSync( - 'find', - [pnpmDir, '-maxdepth', '1', '-type', 'd', '-name', `${encodedName}@*`], - { - encoding: 'utf8', - } - ); - const dir = entries.stdout.split('\n').filter(Boolean).sort().at(-1); - if (!dir) { - return null; - } - return path.join(dir, 'node_modules', packageName); -}; - -const loadModules = async repoRoot => { - const jiti = createJiti(pathToFileURL(path.join(repoRoot, 'bench.mjs')).href); - return { - exportUtils: await jiti.import(path.join(repoRoot, 'src/export-utils.ts')), - compiler: await jiti.import(path.join(repoRoot, 'src/babel.ts')), - pluginUtils: await jiti.import(path.join(repoRoot, 'src/plugin-utils.ts')), - routeChunks: await jiti.import(path.join(repoRoot, 'src/route-chunks.ts')), - }; -}; - -const createSamples = () => - Array.from({ length: sampleCount }, (_, index) => { - const shared = - index % 3 === 0 - ? `const shared${index} = (value: number) => value + ${index};` - : ''; - return { - path: `/app/routes/bench-${index}.tsx`, - code: ` - import { helper${index} } from "./helpers"; - import { serverOnly${index} } from "./data.server"; - ${shared} - - type LoaderData${index} = { value: number }; - - export const loader = async () => { - return serverOnly${index}(); - }; - - export const action = async () => { - return serverOnly${index}(); - }; - - export const clientLoader = async () => { - const value = helper${index}(${index}); - return ${shared ? `shared${index}(value)` : 'value'}; - }; - - export const clientAction = async () => { - return helper${index}(${index + 1}); - }; - - export function HydrateFallback() { - return
Loading
; - } - - export function ErrorBoundary() { - return
Error
; - } - - export default function Route(props: LoaderData${index}) { - return
{props.value}
; - } - `, - }; - }); - -const hrtimeMs = start => Number(process.hrtime.bigint() - start) / 1e6; - -const measure = async fn => { - const start = process.hrtime.bigint(); - await fn(); - return hrtimeMs(start); -}; - -const runForRepo = async (label, repoRoot) => { - const { exportUtils, compiler, pluginUtils, routeChunks } = - await loadModules(repoRoot); - const samples = createSamples(); - - for (let i = 0; i < 20; i++) { - const sample = samples[i % samples.length]; - const code = await exportUtils.transformToEsm(sample.code, sample.path); - await exportUtils.getExportNames(code); - } - - const transformed = new Map(); - const transformMs = await measure(async () => { - for (let i = 0; i < iterations; i++) { - const sample = samples[i % samples.length]; - const code = await exportUtils.transformToEsm(sample.code, sample.path); - transformed.set(sample.path, code); - } - }); - - const exportScanMs = await measure(async () => { - for (let i = 0; i < iterations; i++) { - const sample = samples[i % samples.length]; - await exportUtils.getExportNames(transformed.get(sample.path)); - } - }); - - const routeTransformMs = await measure(async () => { - for (let i = 0; i < iterations; i++) { - const sample = samples[i % samples.length]; - const code = transformed.get(sample.path); - const ast = compiler.parse(code, { sourceType: 'module' }); - pluginUtils.removeExports(ast, [ - 'loader', - 'action', - 'middleware', - 'headers', - ]); - pluginUtils.transformRoute(ast); - pluginUtils.removeUnusedImports(ast); - compiler.generate(ast, { sourceMaps: true, filename: sample.path }); - } - }); - - const routeChunkMs = await measure(async () => { - const cache = new Map(); - const config = { - splitRouteModules: true, - appDirectory: '/app', - rootRouteFile: 'root.tsx', - }; - for (let i = 0; i < iterations; i++) { - const sample = samples[i % samples.length]; - const code = transformed.get(sample.path); - await routeChunks.detectRouteChunksIfEnabled( - cache, - config, - sample.path, - code - ); - await routeChunks.getRouteChunkIfEnabled( - cache, - config, - sample.path, - 'main', - code - ); - await routeChunks.getRouteChunkIfEnabled( - cache, - config, - sample.path, - 'clientLoader', - code - ); - } - }); - - return { - label, - transformMs, - exportScanMs, - routeTransformMs, - routeChunkMs, - totalMs: transformMs + exportScanMs + routeTransformMs + routeChunkMs, - }; -}; - -const format = value => value.toFixed(2).padStart(10); - -const printComparison = (before, after) => { - const rows = [ - ['transform', before.transformMs, after.transformMs], - ['export scan', before.exportScanMs, after.exportScanMs], - ['route transform', before.routeTransformMs, after.routeTransformMs], - ['route chunks', before.routeChunkMs, after.routeChunkMs], - ['total', before.totalMs, after.totalMs], - ]; - console.log( - `Benchmark: ${iterations} iterations across ${sampleCount} TSX route samples` - ); - console.log(`Node: ${process.version}`); - console.log(''); - console.log('metric before ms after ms speedup'); - for (const [name, oldMs, newMs] of rows) { - const speedup = oldMs / newMs; - console.log( - `${name.padEnd(18)}${format(oldMs)}${format(newMs)}${`${speedup.toFixed(2)}x`.padStart(10)}` - ); - } -}; - -const repoRoot = process.cwd(); -const compareHead = process.argv.includes('--compare-head'); - -if (compareHead) { - const oldRepo = await createOldCheckout(repoRoot); - const before = await runForRepo('before', oldRepo); - const after = await runForRepo('after', repoRoot); - printComparison(before, after); -} else { - const result = await runForRepo('current', repoRoot); - console.log(JSON.stringify(result, null, 2)); -} diff --git a/scripts/compare-client-entry-analysis.mjs b/scripts/compare-client-entry-analysis.mjs deleted file mode 100644 index 9370d94..0000000 --- a/scripts/compare-client-entry-analysis.mjs +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -import { readFile } from 'node:fs/promises'; -import { parseArgs } from 'node:util'; - -const expectedSchema = - 'rsbuild-plugin-react-router/client-entry-analysis-benchmark'; -const expectedSchemaVersion = 1; - -const { values } = parseArgs({ - allowPositionals: false, - strict: true, - options: { - before: { type: 'string' }, - after: { type: 'string' }, - }, -}); - -if (!values.before || !values.after) { - throw new Error( - 'Usage: node scripts/compare-client-entry-analysis.mjs --before --after ' - ); -} - -const readJson = async file => JSON.parse(await readFile(file, 'utf8')); - -const validateResult = (result, label) => { - if (result.schema !== expectedSchema) { - throw new Error( - `${label} has unsupported schema ${JSON.stringify(result.schema)}; expected ${JSON.stringify(expectedSchema)}.` - ); - } - if (result.schemaVersion !== expectedSchemaVersion) { - throw new Error( - `${label} has unsupported schemaVersion ${JSON.stringify(result.schemaVersion)}; expected ${expectedSchemaVersion}.` - ); - } -}; - -const percentDelta = (beforeValue, afterValue) => { - if (beforeValue == null || afterValue == null || beforeValue === 0) { - return '-'; - } - return `${(((afterValue - beforeValue) / beforeValue) * 100).toFixed(1)}%`; -}; - -const formatMs = value => (value == null ? '-' : `${value.toFixed(3)}ms`); -const formatBytes = value => - value == null ? '-' : `${Math.round(value / 1024).toLocaleString()} KiB`; -const formatCount = value => - value == null ? '-' : Math.round(value).toLocaleString(); - -const metric = (result, path) => - path.split('.').reduce((value, key) => value?.[key], result); - -const sameConfigKeys = [ - 'routeCount', - 'variant', - 'fixture', - 'splitRouteModules', - 'cacheMode', - 'iterations', - 'warmup', -]; - -const before = await readJson(values.before); -const after = await readJson(values.after); -validateResult(before, 'before'); -validateResult(after, 'after'); - -const mismatches = sameConfigKeys.filter( - key => JSON.stringify(before[key]) !== JSON.stringify(after[key]) -); -if (mismatches.length > 0) { - throw new Error( - `Cannot compare benchmark files with different ${mismatches.join(', ')} values.` - ); -} -if ( - JSON.stringify(before.environments) !== JSON.stringify(after.environments) -) { - throw new Error( - 'Cannot compare benchmark files with different environments.' - ); -} - -const rows = [ - { - label: 'transform/export-info mean', - before: metric(before, 'summary.phases.transformExportMs.mean'), - after: metric(after, 'summary.phases.transformExportMs.mean'), - format: formatMs, - }, - { - label: 'transform/export-info p95', - before: metric(before, 'summary.phases.transformExportMs.p95'), - after: metric(after, 'summary.phases.transformExportMs.p95'), - format: formatMs, - }, - { - label: 'route-chunk-info mean', - before: metric(before, 'summary.phases.routeChunkInfoMs.mean'), - after: metric(after, 'summary.phases.routeChunkInfoMs.mean'), - format: formatMs, - }, - { - label: 'filter/codegen-string mean', - before: metric(before, 'summary.phases.filterCodegenMs.mean'), - after: metric(after, 'summary.phases.filterCodegenMs.mean'), - format: formatMs, - }, - { - label: 'total per-route mean', - before: metric(before, 'summary.phases.totalMs.mean'), - after: metric(after, 'summary.phases.totalMs.mean'), - format: formatMs, - }, - { - label: 'iteration wall mean', - before: metric(before, 'summary.iterationWallMs.mean'), - after: metric(after, 'summary.iterationWallMs.mean'), - format: formatMs, - }, - { - label: 'heap delta mean', - before: metric(before, 'summary.heapDeltaBytes.mean'), - after: metric(after, 'summary.heapDeltaBytes.mean'), - format: formatBytes, - }, - { - label: 'route executions', - before: metric(before, 'operationCounts.routeExecutions'), - after: metric(after, 'operationCounts.routeExecutions'), - format: formatCount, - }, - { - label: 'export names scanned', - before: metric(before, 'operationCounts.exportNames'), - after: metric(after, 'operationCounts.exportNames'), - format: formatCount, - }, - { - label: 'generated reexports', - before: metric(before, 'operationCounts.reexports'), - after: metric(after, 'operationCounts.reexports'), - format: formatCount, - }, -]; - -console.log( - `Client-entry analysis comparison: ${before.routeCount} routes, ${before.variant}, ${before.fixture}, environments=${before.environments.join(',')}` -); -console.log(''); -console.log('| Metric | Before | After | Delta |'); -console.log('|---|---:|---:|---:|'); -for (const row of rows) { - console.log( - `| ${row.label} | ${row.format(row.before)} | ${row.format(row.after)} | ${percentDelta(row.before, row.after)} |` - ); -} diff --git a/src/index.ts b/src/index.ts index 7b31d19..fcd7560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -421,7 +421,6 @@ export const pluginReactRouter = ( const routeTransformExecutor = createRouteTransformExecutor({ parallelTransforms: pluginOptions.parallelTransforms, routeChunkCache, - routeCount, splitRouteModules: Boolean(splitRouteModules), }); const routeChunkOptions = { diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 86b7b0d..e09cf9f 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -16,7 +16,6 @@ export type ParallelTransformsConfig = export type RouteTransformExecutorOptions = RouteTransformTaskOptions & { parallelTransforms?: PluginOptions['parallelTransforms']; - routeCount?: number; splitRouteModules?: boolean; }; @@ -75,32 +74,22 @@ class WorkerStartupError extends Error { } } -const DEFAULT_SHARE_ROUTE_MODULE_BUILD_RESULTS_MIN_ROUTES = 1024; const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; -export const getDefaultWorkerCount = ( - cpuCount?: number, - _options: Pick< - RouteTransformExecutorOptions, - 'routeCount' | 'splitRouteModules' - > = {} -): number => getDefaultConcurrency(cpuCount); +export const getDefaultWorkerCount = (cpuCount?: number): number => + getDefaultConcurrency(cpuCount); const getConfiguredWorkerCount = ( - parallelTransforms: ParallelTransformsConfig, - options: Pick< - RouteTransformExecutorOptions, - 'routeCount' | 'splitRouteModules' - > + parallelTransforms: ParallelTransformsConfig ): number => { if (parallelTransforms === true) { - return getDefaultWorkerCount(undefined, options); + return getDefaultWorkerCount(); } const configured = parallelTransforms.maxWorkers; if (configured === undefined) { - return getDefaultWorkerCount(undefined, options); + return getDefaultWorkerCount(); } if (!Number.isFinite(configured) || configured < 1) { throw new Error( @@ -369,7 +358,6 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { export const createRouteTransformExecutor = ({ parallelTransforms, routeChunkCache, - routeCount, splitRouteModules, }: RouteTransformExecutorOptions = {}): RouteTransformExecutor => { const options = { routeChunkCache }; @@ -381,10 +369,7 @@ export const createRouteTransformExecutor = ({ }; } - const workerCount = getConfiguredWorkerCount(effectiveParallelTransforms, { - routeCount, - splitRouteModules, - }); + const workerCount = getConfiguredWorkerCount(effectiveParallelTransforms); if (workerCount < 1) { return { run: task => executeRouteTransformTask(task, options), @@ -396,10 +381,6 @@ export const createRouteTransformExecutor = ({ workerCount, options, Boolean(splitRouteModules), - Boolean( - splitRouteModules && - typeof routeCount === 'number' && - routeCount >= DEFAULT_SHARE_ROUTE_MODULE_BUILD_RESULTS_MIN_ROUTES - ) + Boolean(splitRouteModules) ); }; diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 5debe6f..6120941 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -42,81 +42,23 @@ const createRouteModuleTask = ( }); describe('parallel route transforms', () => { - it.each([ - [1, {}, 0], - [2, {}, 0], - [3, {}, 1], - [4, {}, 2], - [6, {}, 4], - [8, {}, 6], - [24, {}, 22], - [24, { routeCount: 48 }, 22], - [24, { routeCount: 256 }, 22], - [24, { routeCount: 256, splitRouteModules: true }, 22], - [24, { routeCount: 1024 }, 22], - [24, { routeCount: 1024, splitRouteModules: true }, 22], - ])('chooses the default worker count', (cpus, options, workers) => { - expect(getDefaultWorkerCount(cpus, options)).toBe(workers); - }); - - it.each([ - [1, 0], - [2, 0], - [3, 1], - [4, 2], - [8, 6], - [10, 8], - [24, 22], - ])('uses cpu count minus two workers for split route module builds', (cpus, workers) => { - expect( - getDefaultWorkerCount(cpus, { - routeCount: 256, - splitRouteModules: true, - }) - ).toBe(workers); - }); - - it.each([ - [3, 1], - [4, 2], - [6, 4], - [10, 8], - [24, 22], - ])('uses cpu count minus two workers for very large route module builds', (cpus, workers) => { - expect(getDefaultWorkerCount(cpus, { routeCount: 1024 })).toBe(workers); - expect( - getDefaultWorkerCount(cpus, { - routeCount: 1024, - splitRouteModules: true, - }) - ).toBe(workers); - }); - it.each([ [1, 0], [2, 0], [3, 1], [4, 2], [6, 4], + [8, 6], [10, 8], + [12, 10], [24, 22], - ])('uses cpu count minus two workers for regular route builds', (cpus, workers) => { - expect(getDefaultWorkerCount(cpus, { routeCount: 256 })).toBe(workers); - }); - - it.each([ - [1, 0], - [2, 0], - [3, 1], - [24, 22], - ])('uses cpu count minus two workers for small route builds', (cpus, workers) => { - expect(getDefaultWorkerCount(cpus, { routeCount: 48 })).toBe(workers); + ])('defaults to cpu count minus two workers', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus)).toBe(workers); }); - it('honors explicit maxWorkers for small route builds', async () => { + it('honors explicit maxWorkers', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, - routeCount: 48, }); try { @@ -132,7 +74,6 @@ describe('parallel route transforms', () => { it('runs route builds inline when parallel transforms are disabled', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: false, - routeCount: 48, }); try { @@ -145,20 +86,6 @@ describe('parallel route transforms', () => { } }); - it.each([ - [1, 0], - [2, 0], - [3, 1], - [4, 2], - [6, 4], - [8, 6], - [10, 8], - [12, 10], - [24, 22], - ])('defaults to cpu count minus two cores', (cpus, workers) => { - expect(getDefaultWorkerCount(cpus)).toBe(workers); - }); - it('executes route client entry tasks through the shared task executor', async () => { await expect( executeRouteTransformTask({ @@ -297,7 +224,6 @@ describe('parallel route transforms', () => { it('shares build route module results across environments when output is identical', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, - routeCount: 1024, splitRouteModules: true, }); const task = createRouteModuleTask({ @@ -325,7 +251,6 @@ describe('parallel route transforms', () => { it('does not share build route module results when web removes server-only exports', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, - routeCount: 1024, splitRouteModules: true, }); const task = createRouteModuleTask({ From 13dd70559cf66062f29263183739226e1ebed814 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:20 +0200 Subject: [PATCH 30/30] chore: share bounded cache helper --- src/bounded-cache.ts | 14 ++++++ src/export-utils.ts | 63 ++++++++++++++------------ src/parallel-route-transform-worker.ts | 18 +++----- src/parallel-route-transforms.ts | 40 +++++++--------- tests/bounded-cache.test.ts | 23 ++++++++++ 5 files changed, 95 insertions(+), 63 deletions(-) create mode 100644 src/bounded-cache.ts create mode 100644 tests/bounded-cache.test.ts diff --git a/src/bounded-cache.ts b/src/bounded-cache.ts new file mode 100644 index 0000000..b7d6df0 --- /dev/null +++ b/src/bounded-cache.ts @@ -0,0 +1,14 @@ +export const setBoundedCacheEntry = ( + cache: Map, + key: Key, + value: Value, + maxEntries: number +): void => { + if (!cache.has(key) && cache.size >= maxEntries) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(key, value); +}; diff --git a/src/export-utils.ts b/src/export-utils.ts index d3047bd..3bdfcb6 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,6 +1,7 @@ import { readFile, stat } from 'node:fs/promises'; import { strip } from 'yuku-codegen'; import { langFromPath, parse } from 'yuku-parser'; +import { setBoundedCacheEntry } from './bounded-cache.js'; import { detectRouteChunksIfEnabled, type RouteChunkCache, @@ -61,20 +62,6 @@ const MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048; type AnyNode = Record; -const setBoundedCacheEntry = ( - cache: Map, - key: Key, - value: Value -) => { - if (!cache.has(key) && cache.size >= MAX_EXPORT_UTILS_CACHE_ENTRIES) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); - } - } - cache.set(key, value); -}; - const cachePromiseOnReject = ( promise: Promise, invalidate: () => void @@ -261,10 +248,15 @@ const getTransformedModule = async ( } ); - setBoundedCacheEntry(transformCache, resourcePath, { - source: code, - transformed, - }); + setBoundedCacheEntry( + transformCache, + resourcePath, + { + source: code, + transformed, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); return transformed; }; @@ -334,10 +326,15 @@ export const getBundlerRouteAnalysis = async ( } }); - setBoundedCacheEntry(bundlerRouteAnalysisCache, resourcePath, { - source, - analysis: trackedAnalysis, - }); + setBoundedCacheEntry( + bundlerRouteAnalysisCache, + resourcePath, + { + source, + analysis: trackedAnalysis, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); return trackedAnalysis; }; @@ -364,7 +361,12 @@ export const getExportNamesAndExportAll = async ( } }); - setBoundedCacheEntry(exportInfoCache, code, trackedExportInfo); + setBoundedCacheEntry( + exportInfoCache, + code, + trackedExportInfo, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); return trackedExportInfo; }; @@ -396,10 +398,15 @@ export const getRouteModuleAnalysis = async ( } }); - setBoundedCacheEntry(routeModuleAnalysisCache, resourcePath, { - mtimeMs: stats.mtimeMs, - size: stats.size, - analysis: trackedAnalysis, - }); + setBoundedCacheEntry( + routeModuleAnalysisCache, + resourcePath, + { + mtimeMs: stats.mtimeMs, + size: stats.size, + analysis: trackedAnalysis, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); return trackedAnalysis; }; diff --git a/src/parallel-route-transform-worker.ts b/src/parallel-route-transform-worker.ts index 513ae4d..e9c0ad3 100644 --- a/src/parallel-route-transform-worker.ts +++ b/src/parallel-route-transform-worker.ts @@ -1,4 +1,5 @@ import { parentPort } from 'node:worker_threads'; +import { setBoundedCacheEntry } from './bounded-cache.js'; import { executeRouteTransformTask, type RouteTransformResult, @@ -53,16 +54,6 @@ if (!parentPort) { const MAX_SOURCE_CACHE_ENTRIES = 2048; const sourceCache = new Map(); -const setSourceCacheEntry = (key: string, code: string) => { - if (!sourceCache.has(key) && sourceCache.size >= MAX_SOURCE_CACHE_ENTRIES) { - const oldestKey = sourceCache.keys().next().value; - if (oldestKey !== undefined) { - sourceCache.delete(oldestKey); - } - } - sourceCache.set(key, code); -}; - const hydrateTaskSource = ({ task, sourceCacheKey, @@ -72,7 +63,12 @@ const hydrateTaskSource = ({ } if (typeof task.code === 'string') { - setSourceCacheEntry(sourceCacheKey, task.code); + setBoundedCacheEntry( + sourceCache, + sourceCacheKey, + task.code, + MAX_SOURCE_CACHE_ENTRIES + ); return task as RouteTransformTask; } diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index e09cf9f..6eddf6d 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -1,4 +1,5 @@ import { Worker } from 'node:worker_threads'; +import { setBoundedCacheEntry } from './bounded-cache.js'; import { SERVER_ONLY_ROUTE_EXPORTS } from './constants.js'; import { getDefaultConcurrency } from './concurrency.js'; import { @@ -246,16 +247,6 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { return cached.result; } - if ( - !this.#routeModuleResultCache.has(cacheKey) && - this.#routeModuleResultCache.size >= MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES - ) { - const oldestKey = this.#routeModuleResultCache.keys().next().value; - if (oldestKey !== undefined) { - this.#routeModuleResultCache.delete(oldestKey); - } - } - const result = this.#runInWorker(task).catch(error => { if (this.#routeModuleResultCache.get(cacheKey)?.result === result) { this.#routeModuleResultCache.delete(cacheKey); @@ -265,10 +256,15 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { } throw error; }); - this.#routeModuleResultCache.set(cacheKey, { - source: task.code, - result, - }); + setBoundedCacheEntry( + this.#routeModuleResultCache, + cacheKey, + { + source: task.code, + result, + }, + MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES + ); return result; } @@ -310,16 +306,12 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { return cachedTask; } - if ( - !state.sourceCache.has(sourceCacheKey) && - state.sourceCache.size >= MAX_WORKER_SOURCE_CACHE_ENTRIES - ) { - const oldestKey = state.sourceCache.keys().next().value; - if (oldestKey !== undefined) { - state.sourceCache.delete(oldestKey); - } - } - state.sourceCache.set(sourceCacheKey, task.code); + setBoundedCacheEntry( + state.sourceCache, + sourceCacheKey, + task.code, + MAX_WORKER_SOURCE_CACHE_ENTRIES + ); return task; } diff --git a/tests/bounded-cache.test.ts b/tests/bounded-cache.test.ts new file mode 100644 index 0000000..b26eeb2 --- /dev/null +++ b/tests/bounded-cache.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@rstest/core'; +import { setBoundedCacheEntry } from '../src/bounded-cache'; + +describe('bounded cache helpers', () => { + it('evicts the oldest entry only when inserting past the maximum size', () => { + const cache = new Map([ + ['first', 1], + ['second', 2], + ]); + + setBoundedCacheEntry(cache, 'second', 22, 2); + expect([...cache.entries()]).toEqual([ + ['first', 1], + ['second', 22], + ]); + + setBoundedCacheEntry(cache, 'third', 3, 2); + expect([...cache.entries()]).toEqual([ + ['second', 22], + ['third', 3], + ]); + }); +});