From b3f5945a72719b9a2ed85937b84c3f70e0c495b2 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/64] Add React Router plugin performance benchmarks --- src/export-utils.ts | 78 +++- src/index.ts | 874 +++++++++++++++++++++----------------- src/manifest.ts | 225 +++++----- src/performance.ts | 140 ++++++ src/types.ts | 7 + tests/performance.test.ts | 76 ++++ 6 files changed, 893 insertions(+), 507 deletions(-) create mode 100644 src/performance.ts create mode 100644 tests/performance.test.ts 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/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 edaa055906762c6f1f54bd902c8fb7d11590e72b 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 02/64] Optimize route export analysis caches --- src/export-utils.ts | 49 ++++++++++++++++++++++++++++++++++++++------- src/index.ts | 11 +--------- src/performance.ts | 16 +++++++++++++-- 3 files changed, 57 insertions(+), 19 deletions(-) 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 596b7c854d85900d3d90fdec7e3f7df93561d2bc 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 03/64] perf: reduce benchmark and route analysis overhead --- src/export-utils.ts | 4 ++-- src/index.ts | 4 +--- src/performance.ts | 15 ++++++++++++--- tests/performance.test.ts | 10 ++++++++++ 4 files changed, 25 insertions(+), 8 deletions(-) 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 3bd311d68f4ab968fdf9231e0b7ea88c3f57a8fe 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 04/64] perf: reduce route analysis overhead --- benchmarks/chunk-precompute-methodology.md | 370 +++++++ .../manifest-performance-methodology.md | 352 +++++++ performance-timing-semantics-analysis.md | 149 +++ route-analysis-duplication-audit.md | 356 +++++++ route-chunk-parse-traverse-analysis.md | 244 +++++ scripts/compare-benchmarks.mjs | 38 +- 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 ++++++++-- 21 files changed, 4814 insertions(+), 675 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 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/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/compare-benchmarks.mjs b/scripts/compare-benchmarks.mjs index 89e663f..31192ac 100644 --- a/scripts/compare-benchmarks.mjs +++ b/scripts/compare-benchmarks.mjs @@ -9,18 +9,23 @@ const { values } = parseArgs({ 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 ]' + '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); @@ -35,6 +40,19 @@ const findBenchmark = (result, id) => { 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 '-'; @@ -42,6 +60,7 @@ const percentDelta = (beforeValue, afterValue) => { 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 => @@ -81,6 +100,23 @@ const rows = [ }, ]; +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 |'); 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 4d206daa69be558abd816d9e9445e64e3e4c92b1 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 05/64] 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 9d4a65cc30153848b469f04d3d211fda5099bec4 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 06/64] 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 ec952eab0d0056804939681798f0ad626d76a5e8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:41:04 +0200 Subject: [PATCH 07/64] perf: reduce route artifact build overhead --- package.json | 5 +- pnpm-lock.yaml | 10 - scripts/bench-client-entry-analysis.mjs | 417 ++++++++++++++++++++++ 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/features.test.ts | 49 ++- tests/route-artifacts.test.ts | 181 ++++++++++ tests/setup.ts | 2 +- 17 files changed, 1191 insertions(+), 282 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 7bfba50..7b0ce99 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "build": "rslib build", "bench": "node scripts/bench-builds.mjs", "bench:ci-report": "node scripts/report-benchmark-ci.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", @@ -87,8 +89,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 994cb95..512a3ac 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 @@ -8337,9 +8334,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==} @@ -16513,10 +16507,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/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/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 4575df661645a067abca05913b3a377d67dd9c52 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 08/64] perf: simplify route artifact helpers --- scripts/bench-client-entry-analysis.mjs | 2 +- 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 +++------ 6 files changed, 42 insertions(+), 44 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/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 0d955c823de325415314d9b7565f896c8c971dad 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 09/64] 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 5d46be90498fadd0e05f01e28d400d9d1dd5d064 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:10:54 +0800 Subject: [PATCH 10/64] Replace Babel and esbuild with Yuku --- config/rslib.config.ts | 12 +- package.json | 19 +- pnpm-lock.yaml | 445 +++++++++++--- scripts/benchmark-yuku.mjs | 307 ++++++++++ src/babel.ts | 69 ++- src/export-utils.ts | 378 +++++------- src/index.ts | 923 +++++++++++++++-------------- src/plugin-utils.ts | 734 ++++++++++++++--------- src/route-chunks.ts | 1146 ++++++++++++++---------------------- 9 files changed, 2266 insertions(+), 1767 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 7b0ce99..832c605 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,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}\"", @@ -73,23 +74,20 @@ "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", "jiti": "^2.6.1", "jsesc": "^3.1.0", "pathe": "^2.0.3", - "react-refresh": "^0.18.0" + "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" }, "devDependencies": { "@changesets/cli": "^2.29.8", @@ -99,12 +97,9 @@ "@rsbuild/plugin-react": "2.0.1", "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 512a3ac..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 @@ -62,6 +41,18 @@ 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 + 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 @@ -75,12 +66,9 @@ 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)(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,15 +96,9 @@ 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 - pkg-pr-new: - specifier: ^0.0.75 - version: 0.0.75 playwright: specifier: ^1.58.0 version: 1.58.0 @@ -167,7 +140,7 @@ importers: dependencies: '@react-router/express': specifier: ^7.13.0 - version: 7.13.0(express@5.2.1)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 7.13.0(express@4.22.1)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@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) @@ -3667,112 +3640,96 @@ 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 @@ -3786,21 +3743,18 @@ 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 @@ -3839,7 +3793,6 @@ 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 @@ -5164,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==} @@ -6513,7 +6634,6 @@ 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: @@ -7734,10 +7854,6 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pkg-pr-new@0.0.75: - resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} - hasBin: true - pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -7799,7 +7915,6 @@ 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: @@ -8334,6 +8449,9 @@ 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==} @@ -9149,7 +9267,6 @@ 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: @@ -9495,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==} @@ -10407,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': @@ -12127,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) @@ -12253,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: @@ -13256,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: @@ -15297,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' @@ -15985,8 +16223,6 @@ snapshots: pify@4.0.1: {} - pkg-pr-new@0.0.75: {} - pkg-types@2.3.0: dependencies: confbox: 0.2.2 @@ -16498,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 @@ -16507,6 +16750,10 @@ 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 @@ -16801,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 @@ -17820,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 e4ff510..f7b0743 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,266 +1,180 @@ -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'; -import { - detectRouteChunksIfEnabled, - type RouteChunkCache, - type RouteChunkConfig, - type RouteChunkInfo, -} from './route-chunks.js'; +import { readFile } from 'node:fs/promises'; +import { langFromPath, parse } from 'yuku-parser'; +import { strip } from 'yuku-codegen'; -type TransformCacheEntry = { - source: string; - transformed: Promise; -}; +type AnyNode = Record; -export type BundlerRouteAnalysis = { - code: string; - getExportNames: () => Promise; - getRouteChunkInfo: ( - cache: RouteChunkCache | undefined, - config: RouteChunkConfig - ) => Promise; +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; }; -type BundlerRouteAnalysisCacheEntry = { - source: string; - analysis: Promise; +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; }; -type RouteModuleAnalysis = { - code: string; - exports: string[]; - exportAllModules: string[]; +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; }; -type RouteModuleAnalysisCacheEntry = { - mtimeMs: number; - size: number; - analysis: Promise; -}; +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; + } -const transformCache = new Map(); -const exportInfoCache = new Map< - string, - Promise<{ exportNames: string[]; exportAllModules: string[] }> ->(); -const bundlerRouteAnalysisCache = new Map< - string, - BundlerRouteAnalysisCacheEntry ->(); -const routeModuleAnalysisCache = new Map< - string, - RouteModuleAnalysisCacheEntry ->(); + if (statement.type === 'ExportDefaultDeclaration') { + exportNames.add('default'); + continue; + } -const MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048; + 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; + } -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); + for (const specifier of statement.specifiers ?? []) { + if (specifier.exportKind === 'type') { + continue; + } + const exported = getExportedName(specifier.exported); + if (exported) { + exportNames.add(exported); + } } } - cache.set(key, value); + return Array.from(exportNames); }; -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'; +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; }; -const getRouteChunkConfigCacheKey = (config: RouteChunkConfig) => - `${String(config.splitRouteModules ?? false)}\0${config.appDirectory}\0${config.rootRouteFile}`; - export const transformToEsm = async ( code: string, resourcePath: string ): Promise => { - const cached = transformCache.get(resourcePath); - if (cached?.source === code) { - return cached.transformed; - } - - 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); - } - } - ); - - setBoundedCacheEntry(transformCache, resourcePath, { - source: code, - transformed, + const result = parse(code, { + sourceType: 'module', + lang: langFromPath(resourcePath), + preserveParens: false, }); - return transformed; + 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 => { - return (await getExportNamesAndExportAll(code)).exportNames; -}; - -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; - } - - let routeChunkInfo: Promise; - routeChunkInfo = cachePromiseOnReject( - detectRouteChunksIfEnabled(cache, config, resourcePath, code), - () => { - if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { - routeChunkInfoCache.delete(cacheKey); - } - } - ); - - routeChunkInfoCache.set(cacheKey, routeChunkInfo); - return routeChunkInfo; - }, - }; - })(); - - let trackedAnalysis: Promise; - trackedAnalysis = cachePromiseOnReject(analysis, () => { - if ( - bundlerRouteAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis - ) { - bundlerRouteAnalysisCache.delete(resourcePath); - } - }); - - setBoundedCacheEntry(bundlerRouteAnalysisCache, resourcePath, { - source, - analysis: trackedAnalysis, - }); - return trackedAnalysis; + return collectExportNames(parseProgram(code)); }; export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - const cached = exportInfoCache.get(code); - if (cached) { - return cached; - } - - 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 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 }; - })(); - - let trackedExportInfo: Promise<{ - exportNames: string[]; - exportAllModules: string[]; - }>; - trackedExportInfo = cachePromiseOnReject(exportInfo, () => { - if (exportInfoCache.get(code) === trackedExportInfo) { - exportInfoCache.delete(code); - } - }); - - setBoundedCacheEntry(exportInfoCache, code, trackedExportInfo); - return trackedExportInfo; + const program = parseProgram(code); + return { + exportNames: collectExportNames(program), + exportAllModules: collectExportAllModules(program), + }; }; -export const getRouteModuleAnalysis = async ( +export const getRouteModuleExports = 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 { exportNames, exportAllModules } = - await getExportNamesAndExportAll(code); - return { code, exports: exportNames, exportAllModules }; - })(); - - let trackedAnalysis: Promise; - trackedAnalysis = cachePromiseOnReject(analysis, () => { - if ( - routeModuleAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis - ) { - routeModuleAnalysisCache.delete(resourcePath); - } - }); - - setBoundedCacheEntry(routeModuleAnalysisCache, resourcePath, { - mtimeMs: stats.mtimeMs, - size: stats.size, - analysis: trackedAnalysis, - }); - return trackedAnalysis; +): Promise => { + const source = await readFile(resourcePath, 'utf8'); + const code = await transformToEsm(source, resourcePath); + return getExportNames(code); }; diff --git a/src/index.ts b/src/index.ts index f8e7030..67ae7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,22 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, 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'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; +import type { RsbuildPlugin, 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, JS_EXTENSIONS, PLUGIN_NAME, SERVER_ONLY_ROUTE_EXPORTS, - SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { @@ -44,28 +44,27 @@ 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, - getRouteModuleAnalysis, + getRouteModuleExports, transformToEsm, } from './export-utils.js'; import { + detectRouteChunksIfEnabled, 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, @@ -74,11 +73,6 @@ 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, - roundMs, -} from './performance.js'; -import { mapVirtualModules } from './virtual-modules.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -133,12 +127,6 @@ 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())]) @@ -399,6 +387,7 @@ export const pluginReactRouter = ( const isBuild = api.context.action === 'build'; const splitRouteModules = future?.v8_splitRouteModules ?? false; + const enforceSplitRouteModules = splitRouteModules === 'enforce'; const routeChunkConfig: RouteChunkConfig = { splitRouteModules, appDirectory, @@ -425,6 +414,15 @@ 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) => { @@ -481,11 +479,6 @@ export const pluginReactRouter = ( fsExtra.copySync(serverBuildDir, ssrDir); } } - if (logPerformance) { - performanceProfiler.flush(environment.name, { - compilerLifecycleMs: roundMs(performance.now() - setupStartMs), - }); - } }); // Determine prerender paths from config @@ -758,7 +751,11 @@ export const pluginReactRouter = ( ); } - const routeExports = getRouteManifestModuleExports(manifest); + const routeExports: Record = {}; + for (const route of Object.values(routes)) { + const filePath = resolve(appDirectory, route.file); + routeExports[route.id] = await getRouteModuleExports(filePath); + } const errors: string[] = []; for (const [routeId, route] of Object.entries(manifest.routes)) { @@ -1007,10 +1004,10 @@ 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. + // Create virtual modules for React Router + const vmodTempDir = `rspack-virtual-module-${process.pid}-${Math.random() + .toString(16) + .slice(2)}`; const createVirtualModulePlugin = (publicPath: string) => { const bundleVirtualModules = Object.fromEntries( Object.entries(routesByServerBundleId).map( @@ -1045,8 +1042,8 @@ export const pluginReactRouter = ( ]) ); - return new rspack.experiments.VirtualModulesPlugin( - mapVirtualModules({ + return new RspackVirtualModulePlugin( + { 'virtual/react-router/browser-manifest': 'export default {};', 'virtual/react-router/server-manifest': 'export default {};', 'virtual/react-router/server-build': generateServerBuild(routes, { @@ -1065,7 +1062,8 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), - }) + }, + vmodTempDir ); }; @@ -1175,35 +1173,42 @@ 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. - 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', + ...(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', + // }, + }, + }, }, - }, - }, - }, + } + : {}), }, }); }); @@ -1251,38 +1256,28 @@ export const pluginReactRouter = ( { future, onManifest: (manifest, sri) => { - 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 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, + }; + } }, } ) @@ -1316,423 +1311,445 @@ export const pluginReactRouter = ( { test: /virtual\/react-router\/(browser|server)-manifest/, }, - 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";`, - }; - } + 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";`, + }; + } - 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 => - performanceProfiler.record( - args.environment?.name, - 'route:client-entry', - args.resource, - async () => { - return createRouteClientEntryArtifact({ - code: args.code, - resourcePath: args.resourcePath, - environmentName: args.environment?.name, - isBuild, - routeChunkCache, - routeChunkConfig, - }); + 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; } - ) + 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=/, - environments: ['web'], }, - async args => - performanceProfiler.record( - args.environment?.name, - 'route:chunk', - args.resource, - async () => { - return createRouteChunkArtifact({ - code: args.code, - resource: args.resource, - resourcePath: args.resourcePath, - isBuild, - routeChunkCache, - routeChunkConfig, - }); - } - ) + 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)});`; + + 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( + 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, + }; + } ); 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 }; - } + 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 analysis = await getBundlerRouteAnalysis( - args.code, - args.resourcePath - ); - const { hasRouteChunks, chunkedExports } = - await analysis.getRouteChunkInfo( - routeChunkCache, - routeChunkConfig - ); - if (!hasRouteChunks) { - 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 }; + } - const sourceExports = await analysis.getExportNames(); - 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, - }; - } - ) + 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?)?$/, - environments: ['web'], }, - async args => - performanceProfiler.record( - args.environment?.name, - 'module:server-only-guard', - args.resource, - async () => { - const relativePath = relative(process.cwd(), args.resourcePath); - throw new Error( - `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` - ); - } - ) + async args => { + 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}` + ); + } ); api.transform( { test: /[\\/]\.client[\\/]|\.client(\.[cm]?[jt]sx?)?$/, - environments: ['node'], }, - async args => - performanceProfiler.record( - args.environment?.name, - 'module:client-only-stub', - args.resource, - async () => { - 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; - } - } - 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. - } - } + async args => { + if (args.environment?.name !== 'node') { + return { code: args.code, map: null }; + } - for (const ext of JS_EXTENSIONS) { - const candidate = `${basePath}${ext}`; - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; - } + 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; + } + } + return null; + }; - 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 resolvePathWithExtensions = (basePath: string): string | null => { + if (existsSync(basePath)) { + try { + const stats = statSync(basePath); + if (stats.isFile()) { + return basePath; } - - try { - const resolver = createRequire( - pathToFileURL(importerPath).href - ); - return resolver.resolve(specifier); - } catch { - return null; + if (stats.isDirectory()) { + return resolveIndexFile(basePath); } - }; + } catch { + // Ignore invalid paths and fall back to extension probing. + } + } - 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 ext of JS_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; } - }; + } catch { + continue; + } + } - for (const specifier of exportAllModules) { - const resolvedPath = resolveExportAllModule( - specifier, - args.resourcePath - ); - if (!resolvedPath) { - unresolvedExportAll.add(specifier); - continue; - } - await collectExportNamesFromModule(resolvedPath); + 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; } + } - 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)}\`.` - ); + 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); } - return { - code: Array.from(exportNames) - .map(name => - name === 'default' - ? 'export default undefined;' - : `export const ${name} = undefined;` - ) - .join('\n'), - map: null, - }; } - ) + 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, + }; + } ); api.transform( { resourceQuery: /\?react-router-route/, }, - async args => - performanceProfiler.record( - 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 = 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); - }); + async args => { + let code: string; + try { + code = await transformToEsm(args.code, args.resourcePath); + } catch (error) { + console.error(args.resourcePath); + throw error; + } - 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.` - ); - } + // 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. + // + // 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); + + 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 ( - !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.` - ); - } - } + 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 defaultExportMatch = code.match( - /\n\s{0,}([\w\d_]+)\sas default,?/ + 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 ( - 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); - } + 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]};`; + } - return generate(ast, { - sourceMaps: true, - filename: args.resource, - sourceFileName: args.resourcePath, - }); - } - ) + 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, + }); + } ); }, }); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 065b26e..836a733 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,28 +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 { - NAMED_COMPONENT_EXPORTS, - NAMED_COMPONENT_EXPORTS_SET, - JS_EXTENSIONS, -} from './constants.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, - exportsToRemove: readonly string[] + 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) @@ -30,7 +25,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.name); } - // [ ...foo ] if ( element.type === 'RestElement' && element.argument.type === 'Identifier' && @@ -39,8 +33,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.argument.name); } - // [ [...] ] - // [ {...} ] if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { validateDestructuredExports(element, exportsToRemove); } @@ -48,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) @@ -65,8 +53,6 @@ export function validateDestructuredExports( throw invalidDestructureError(property.value.name); } - // { foo: [...] } - // { foo: {...} } if ( property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' @@ -75,7 +61,6 @@ export function validateDestructuredExports( } } - // { ...foo } if ( property.type === 'RestElement' && property.argument.type === 'Identifier' && @@ -91,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 { @@ -177,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, - exportsToRemove: readonly string[] + 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' + ) ); } }; @@ -456,5 +664,5 @@ export const transformRoute = (ast: ParseResult): void => { function isNamedComponentExport( name: string ): name is (typeof NAMED_COMPONENT_EXPORTS)[number] { - return NAMED_COMPONENT_EXPORTS_SET.has(name); + return (NAMED_COMPONENT_EXPORTS as readonly string[]).includes(name); } diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 120a15f..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' @@ -29,20 +36,6 @@ 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', @@ -55,16 +48,6 @@ export const routeChunkNames: RouteChunkName[] = [ ...routeChunkExportNames, ]; -const createRouteChunkExportMap = ( - getValue: (exportName: RouteChunkExportName) => boolean -): Record => - Object.fromEntries( - 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 = { @@ -109,227 +92,102 @@ const getOrSetFromCache = ( return value; }; -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 isNodePathWithNode = (path: unknown): path is NodePath => { - if (!path || typeof path !== 'object' || Array.isArray(path)) { - return false; - } - if (!('node' in path)) { - return false; - } - return Boolean((path as { node?: unknown }).node); +type AnalyzedModule = { + module: Module; + program: AnyNode; }; -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 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 analyzeCode = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): 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 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 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 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; + } + invariant(parent?.type === 'Program', 'Expected node to be within Program'); + return current; }; -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 }); - } +const addTopLevelStatement = ( + module: Module, + dependencies: ExportDependencies, + node: AnyNode +) => { + const statement = getTopLevelStatementForNode(module, node); + dependencies.topLevelStatements.add(statement); if ( - withinExportStatement && - path.isIdentifier() && - (t.isPattern(path.parentPath.node) || - t.isPattern(path.parentPath.parentPath?.node)) + statement.type !== 'ImportDeclaration' && + !statement.type.startsWith('Export') ) { - const variableDeclarator = path.findParent(p => p.isVariableDeclarator()); - if (variableDeclarator) { - assertNodePath(variableDeclarator); - getDependentIdentifiersForPath(variableDeclarator, { - visited, - identifiers, - }); - } + dependencies.topLevelNonModuleStatements.add(statement); } - return identifiers; }; -const getTopLevelStatementPathForPath = (path: NodePath) => { - const ancestry = path.getAncestry(); - const topLevelStatement = ancestry[ancestry.length - 2]; - assertNodePathIsStatement(topLevelStatement); - return topLevelStatement; -}; - -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) { - indices.add(getTopLevelStatementIndexForPath(path, topLevel)); +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 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}`; + return null; }; -const getExportedVariableDeclaratorKeyForIndex = ( - statementIndex: number, - declarationIndex: number -) => `${statementIndex}:${declarationIndex}`; - -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); - } - } - } - } else if (currentPath.isRestElement()) { - const argumentPath = currentPath.get('argument'); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } +const getExportedName = (exported: AnyNode): string => { + if (exported.type === 'Identifier') { + return exported.name; } - walk(patternPath); - return identifiers; + return String(exported.value); }; -const getExportedName = (exported: t.Identifier | t.StringLiteral) => { - return t.isIdentifier(exported) ? exported.name : exported.value; -}; +const sameNode = (left: AnyNode, right: AnyNode): boolean => + left.type === right.type && + left.start === right.start && + left.end === right.end; -const setsIntersect = (set1: ReadonlySet, set2: ReadonlySet) => { +const setsIntersect = (set1: Set, set2: Set) => { let smallerSet = set1; let largerSet = set2; if (set1.size > set2.size) { @@ -344,39 +202,124 @@ const setsIntersect = (set1: ReadonlySet, set2: ReadonlySet) => { return false; }; -const getChunkableExports = ( - exportDependencies: ReadonlyMap -) => { - const chunkableExports = new Set(); +const getExportDependencies = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): Map => { + return getOrSetFromCache( + cache, + `${cacheKey}::getExportDependencies`, + code, + () => { + const { module } = analyzeCode(code, cache, cacheKey); + const exportDependencies = new Map(); + + const handleExport = ( + exportName: string, + 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; + } + scannedStatements.add(statement); + walk(statement as any, { + Identifier(node: AnyNode) { + const reference = module.referenceOf(node as never); + if (reference?.symbol) { + visitSymbol(reference.symbol); + } + }, + }); + }; - for (const exportName of routeChunkExportNames) { - const dependencies = exportDependencies.get(exportName); - if (!dependencies) { - continue; - } + const visitSymbol = (symbol: YukuSymbol) => { + if (visitedSymbols.has(symbol)) { + return; + } + visitedSymbols.add(symbol); - let isChunkable = true; - for (const [currentExportName, currentDependencies] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.topLevelNonModuleStatementIndices, - dependencies.topLevelNonModuleStatementIndices - ) - ) { - isChunkable = false; - break; + 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); + } + const declarator = getVariableDeclaratorForNode( + module, + declaration + ); + if ( + declarator && + getTopLevelStatementForNode(module, declarator).type === + 'ExportNamedDeclaration' + ) { + dependencies.exportedVariableDeclarators.add(declarator); + } + scanStatement(statement); + } + + for (const reference of symbol.references as any[]) { + const statement = getTopLevelStatementForNode( + module, + reference.node + ); + addTopLevelStatement(module, dependencies, reference.node); + scanStatement(statement); + } + }; + + addTopLevelStatement(module, dependencies, exportNode); + + if (localSymbol) { + visitSymbol(localSymbol); + } else { + const statement = getTopLevelStatementForNode(module, exportNode); + scanStatement(statement); + } + + exportDependencies.set(exportName, dependencies); + }; + + 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); } + + return exportDependencies; } - if (!isChunkable) { - continue; - } - if (dependencies.exportedVariableDeclaratorKeys.size > 1) { - continue; - } - if (dependencies.exportedVariableDeclaratorKeys.size > 0) { + ); +}; + +const hasChunkableExport = ( + code: string, + exportName: string, + cache: RouteChunkCache | undefined, + cacheKey: string +) => { + 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, @@ -386,472 +329,277 @@ const getChunkableExports = ( } if ( setsIntersect( - currentDependencies.exportedVariableDeclaratorKeys, - dependencies.exportedVariableDeclaratorKeys + currentDependencies.topLevelNonModuleStatements, + dependencies.topLevelNonModuleStatements ) ) { - isChunkable = false; - break; + return false; } } - } - if (isChunkable) { - chunkableExports.add(exportName); - } - } - - return chunkableExports; -}; - -const analyzeRouteModule = ( - code: string, - cache: RouteChunkCache | undefined, - cacheKey: string -): 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) - ); - }) - ); - const importedIdentifierNames = new Set(); - for (const identifier of identifiers) { - if ( - t.isIdentifier(identifier.node) && - identifier.parentPath?.parentPath?.isImportDeclaration() - ) { - importedIdentifierNames.add(identifier.node.name); - } + if (dependencies.exportedVariableDeclarators.size > 1) { + return false; } - 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 (dependencies.exportedVariableDeclarators.size > 0) { + for (const [ + currentExportName, + currentDependencies, + ] of exportDependencies) { + if (currentExportName === exportName) { continue; } - } - const isWithinExportDestructuring = Boolean( - identifier.findParent(path => - Boolean( - path.isPattern() && - path.parentPath?.isVariableDeclarator() && - path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() + if ( + setsIntersect( + currentDependencies.exportedVariableDeclarators, + dependencies.exportedVariableDeclarators ) - ) - ); - if (isWithinExportDestructuring) { - let currentPath: NodePath | null = identifier; - while (currentPath) { - if ( - currentPath.parentPath?.isVariableDeclarator() && - currentPath.parentKey === 'id' - ) { - exportedVariableDeclaratorKeys.add( - getExportedVariableDeclaratorKey( - currentPath.parentPath, - topLevel - ) - ); - break; - } - currentPath = currentPath.parentPath; + ) { + return false; } } } - exportDependencies.set(exportName, { - topLevelStatementIndices, - topLevelNonModuleStatementIndices, - importedIdentifierNames, - exportedVariableDeclaratorKeys, - }); - } - - 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'); - }, - }); - - if (process.env.NODE_ENV !== 'production') { - Object.freeze(topLevel); + return true; } + ); +}; - return { - ast, - exports: exportDependencies, - topLevel, - chunkableExports: getChunkableExports(exportDependencies), - }; - }); +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 assertAnalysisBodyLengthUnchanged = ( - analysis: RouteChunkAnalysis, - expectedLength: number +const filterImportSpecifiers = ( + node: AnyNode, + shouldKeep: (importedName: string) => boolean ) => { - invariant( - analysis.ast.program.body.length === expectedLength, - 'Expected route chunk analysis program body length to remain unchanged' + 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 createProgramCode = ( - body: t.Statement[], - generateOptions: Record -) => generate(t.file(t.program(body)), generateOptions).code; +const getChunkedExport = ( + code: string, + exportName: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): string | undefined => { + return getOrSetFromCache( + cache, + `${cacheKey}::getChunkedExport::${exportName}`, + 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 cloneImportForNames = ( - node: t.ImportDeclaration, - importedIdentifierNames: ReadonlySet -) => { - 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 topLevelStatementsArray = Array.from( + dependencies.topLevelStatements + ); + const exportedVariableDeclaratorsArray = Array.from( + dependencies.exportedVariableDeclarators + ); -const cloneVariableExportForKeys = ( - node: t.ExportNamedDeclaration, - statementIndex: number, - declaratorKeys: ReadonlySet -) => { - invariant( - t.isVariableDeclaration(node.declaration), - 'Expected export declaration to contain variable declarations' - ); - 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 + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + topLevelStatementsArray.some(statement => sameNode(node, statement)) ) - ) - ); - if (clonedDeclaration.declarations.length === 0) { - return null; - } - clonedNode.declaration = clonedDeclaration; - return clonedNode; -}; + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { + return node; + } + if (dependencies.importedIdentifierNames.size === 0) { + return null; + } + return filterImportSpecifiers(node, importedName => + dependencies.importedIdentifierNames.has(importedName) + ); + }) + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { + return node; + } + if (node.type === 'ExportAllDeclaration') { + return null; + } + if (node.type === 'ExportDefaultDeclaration') { + return exportName === 'default' ? node : null; + } + const { declaration } = node; + if (declaration?.type === 'VariableDeclaration') { + declaration.declarations = declaration.declarations.filter( + (declarationNode: AnyNode) => + exportedVariableDeclaratorsArray.some(declarator => + sameNode(declarationNode, declarator) + ) + ); + return declaration.declarations.length > 0 ? node : null; + } + if ( + declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration' + ) { + return declaration.id?.name === exportName ? node : null; + } + if (node.type === 'ExportNamedDeclaration') { + node.specifiers = node.specifiers.filter( + (specifier: AnyNode) => + getExportedName(specifier.exported) === exportName + ); + return node.specifiers.length > 0 ? node : null; + } + throw new Error('Unknown export node type'); + }) + .filter(Boolean) as AnyNode[]; -const detectRouteChunksFromAnalysis = ( - analysis: RouteChunkAnalysis -): RouteChunkInfo => { - const hasRouteChunkByExportName = createRouteChunkExportMap(exportName => - analysis.chunkableExports.has(exportName) - ); - const chunkedExports = routeChunkExportNames.filter( - exportName => hasRouteChunkByExportName[exportName] + return generateCode(program); + } ); - return { - hasRouteChunks: chunkedExports.length > 0, - hasRouteChunkByExportName, - chunkedExports, - }; }; -const getChunkedExportFromAnalysis = ( - analysis: RouteChunkAnalysis, - exportName: RouteChunkExportName, - generateOptions: Record = {} +const omitChunkedExports = ( + code: string, + exportNames: string[], + cache: RouteChunkCache | undefined, + cacheKey: string ): 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 + return getOrSetFromCache( + cache, + `${cacheKey}::omitChunkedExports::${exportNames.join(',')}`, + 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}` ); - } - 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; + for (const statement of dependencies.topLevelNonModuleStatements) { + omittedStatements.add(statement); } - const clonedNode = t.cloneNode(node, false); - clonedNode.specifiers = node.specifiers.filter( - specifier => getExportedName(specifier.exported) === exportName - ); - if (clonedNode.specifiers.length === 0) { - return null; + for (const declarator of dependencies.exportedVariableDeclarators) { + omittedExportedVariableDeclarators.add(declarator); } - 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 omittedStatementsArray = Array.from(omittedStatements); + const omittedExportedVariableDeclaratorsArray = Array.from( + omittedExportedVariableDeclarators + ); - 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); - } - 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; - } + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + omittedStatementsArray.every(statement => !sameNode(node, statement)) + ) + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { + return node; } - for (const omittedExportName of omittedExportNames) { - const dependencies = analysis.exports.get(omittedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { - return false; + 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; + } } + return true; + }); + }) + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { + return node; } - 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); + if (node.type === 'ExportAllDeclaration') { + return node; } - } - 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); - } - 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); -}; + if (node.type === 'ExportDefaultDeclaration') { + 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; + } + if ( + node.declaration?.type === 'FunctionDeclaration' || + node.declaration?.type === 'ClassDeclaration' + ) { + return isOmitted(node.declaration.id.name) ? null : node; + } + if (node.type === 'ExportNamedDeclaration') { + node.specifiers = node.specifiers.filter((specifier: AnyNode) => { + const exportedName = getExportedName(specifier.exported); + return !isOmitted(exportedName); + }); + return node.specifiers.length > 0 || node.declaration ? node : null; + } + throw new Error('Unknown node type'); + }) + .filter(Boolean) as AnyNode[]; -const getRouteChunkCodeFromAnalysis = ( - analysis: RouteChunkAnalysis, - chunkName: RouteChunkName -) => { - if (chunkName === 'main') { - return omitChunkedExportsFromAnalysis(analysis, routeChunkExportNames, {}); - } - return getChunkedExportFromAnalysis(analysis, chunkName, {}); + return generateCode(program); + } + ); }; export const detectRouteChunks = ( code: string, cache: RouteChunkCache | undefined, cacheKey: string -): RouteChunkInfo => - detectRouteChunksFromAnalysis(analyzeRouteModule(code, cache, cacheKey)); +): RouteChunkInfo => { + const hasRouteChunkByExportName = Object.fromEntries( + routeChunkExportNames.map(exportName => [ + exportName, + hasChunkableExport(code, exportName, cache, cacheKey), + ]) + ) as Record; + const chunkedExports = Object.entries(hasRouteChunkByExportName) + .filter(([, isChunked]) => isChunked) + .map(([exportName]) => exportName as RouteChunkExportName); + const hasRouteChunks = chunkedExports.length > 0; + return { + hasRouteChunks, + hasRouteChunkByExportName, + chunkedExports, + }; +}; export const getRouteChunkCode: ( code: string, @@ -864,10 +612,10 @@ export const getRouteChunkCode: ( cache: RouteChunkCache | undefined, cacheKey: string ) => { - return getRouteChunkCodeFromAnalysis( - analyzeRouteModule(code, cache, cacheKey), - chunkName - ); + if (chunkName === 'main') { + return omitChunkedExports(code, routeChunkExportNames, cache, cacheKey); + } + return getChunkedExport(code, chunkName, cache, cacheKey); }; export const getRouteChunkModuleId = ( @@ -905,29 +653,6 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; -export const createEmptyRouteChunkByExportName = (): 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 buildManifestChunkValidity = ( - exportNames: ReadonlySet, - hasRouteChunkByExportName: Readonly> -): Record => - createRouteChunkExportMap( - exportName => - !exportNames.has(exportName) || hasRouteChunkByExportName[exportName] - ); - export const detectRouteChunksIfEnabled: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig, @@ -942,7 +667,12 @@ export const detectRouteChunksIfEnabled: ( const noRouteChunks = (): RouteChunkInfo => ({ chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, - hasRouteChunkByExportName: createEmptyRouteChunkByExportName(), + hasRouteChunkByExportName: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + } as Record, }); if (!config.splitRouteModules) { From 83efe60659b7b6ef92edf7b97cc194d4a4a564e7 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:29:15 +0800 Subject: [PATCH 11/64] Preserve parens in Yuku transforms --- src/babel.ts | 2 +- src/export-utils.ts | 4 ++-- src/route-chunks.ts | 2 +- tests/export-utils.test.ts | 25 ++++++++++++++++++++----- 4 files changed, 24 insertions(+), 9 deletions(-) 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 index 50a7c40..95d6e7e 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@rstest/core'; -import { getBundlerRouteAnalysis } from '../src/export-utils'; +import { parse } from '../src/babel'; +import { getBundlerRouteAnalysis, transformToEsm } from '../src/export-utils'; const routeChunkConfig = { splitRouteModules: true as const, @@ -25,10 +26,7 @@ describe('getBundlerRouteAnalysis', () => { first.getRouteChunkInfo(undefined, routeChunkConfig) ); - expect(await first.getExportNames()).toEqual([ - 'clientAction', - 'default', - ]); + expect(await first.getExportNames()).toEqual(['clientAction', 'default']); await expect( first.getRouteChunkInfo(undefined, routeChunkConfig) ).resolves.toMatchObject({ @@ -53,3 +51,20 @@ describe('getBundlerRouteAnalysis', () => { await expect(updated.getExportNames()).resolves.toEqual(['clientLoader']); }); }); + +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 1ae7d7aacce9d7d4f9dadfb4b1361a25f16aa069 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:43:58 +0800 Subject: [PATCH 12/64] 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: Thu, 18 Jun 2026 06:22:53 +0200 Subject: [PATCH 13/64] perf: simplify yuku route analysis cleanup --- config/rslib.config.ts | 2 - src/export-utils.ts | 250 ++++++++- src/index.ts | 923 +++++++++++++++---------------- src/plugin-utils.ts | 7 +- src/route-artifacts.ts | 6 + src/route-chunks.ts | 2 +- tests/route-chunks-cache.test.ts | 3 +- 7 files changed, 689 insertions(+), 504 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 2fa300d..e796496 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,9 +1,88 @@ -import { readFile } from 'node:fs/promises'; -import { langFromPath, parse } from 'yuku-parser'; +import { readFile, stat } from 'node:fs/promises'; import { strip } from 'yuku-codegen'; +import { langFromPath, parse } from 'yuku-parser'; +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 = { + code: string; + exports: string[]; + exportAllModules: string[]; +}; + +type RouteModuleAnalysisCacheEntry = { + mtimeMs: number; + size: number; + analysis: Promise; +}; + +const transformCache = new Map(); +const exportInfoCache = new Map< + string, + Promise<{ exportNames: string[]; exportAllModules: string[] }> +>(); +const bundlerRouteAnalysisCache = new Map< + string, + BundlerRouteAnalysisCacheEntry +>(); +const routeModuleAnalysisCache = new Map< + string, + RouteModuleAnalysisCacheEntry +>(); + +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 +): Promise => + promise.catch(error => { + invalidate(); + throw error; + }); + +const getRouteChunkConfigCacheKey = (config: RouteChunkConfig) => + `${String(config.splitRouteModules ?? false)}\0${config.appDirectory}\0${config.rootRouteFile}`; + const parseProgram = (code: string, resourcePath?: string) => { const result = parse(code, { sourceType: 'module', @@ -16,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 = ( @@ -145,36 +224,161 @@ export const transformToEsm = async ( code: string, resourcePath: string ): Promise => { - const result = parse(code, { - sourceType: 'module', - lang: langFromPath(resourcePath), - preserveParens: true, - }); - const transformed = strip(result.program, { comments: 'some' }); - if (transformed.errors.length > 0) { - throw new Error(transformed.errors.map(error => error.message).join('\n')); + const cached = transformCache.get(resourcePath); + if (cached?.source === code) { + return cached.transformed; } - return transformed.code; + + let transformed: Promise; + transformed = cachePromiseOnReject( + (async () => { + 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')); + } + return stripped.code; + })(), + () => { + if (transformCache.get(resourcePath)?.transformed === transformed) { + transformCache.delete(resourcePath); + } + } + ); + + setBoundedCacheEntry(transformCache, resourcePath, { + source: code, + transformed, + }); + return transformed; }; export const getExportNames = async (code: string): Promise => { - return collectExportNames(parseProgram(code)); + return (await getExportNamesAndExportAll(code)).exportNames; +}; + +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; + } + + let routeChunkInfo: Promise; + routeChunkInfo = cachePromiseOnReject( + detectRouteChunksIfEnabled(cache, config, resourcePath, code), + () => { + if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { + routeChunkInfoCache.delete(cacheKey); + } + } + ); + + routeChunkInfoCache.set(cacheKey, routeChunkInfo); + return routeChunkInfo; + }, + }; + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + bundlerRouteAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { + bundlerRouteAnalysisCache.delete(resourcePath); + } + }); + + setBoundedCacheEntry(bundlerRouteAnalysisCache, resourcePath, { + source, + analysis: trackedAnalysis, + }); + return trackedAnalysis; }; export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - const program = parseProgram(code); - return { - exportNames: collectExportNames(program), - exportAllModules: collectExportAllModules(program), - }; + const cached = exportInfoCache.get(code); + if (cached) { + return cached; + } + + const exportInfo = (async () => { + const program = parseProgram(code); + return { + exportNames: collectExportNames(program), + exportAllModules: collectExportAllModules(program), + }; + })(); + + let trackedExportInfo: Promise<{ + exportNames: string[]; + exportAllModules: string[]; + }>; + trackedExportInfo = cachePromiseOnReject(exportInfo, () => { + if (exportInfoCache.get(code) === trackedExportInfo) { + exportInfoCache.delete(code); + } + }); + + setBoundedCacheEntry(exportInfoCache, code, trackedExportInfo); + return trackedExportInfo; }; -export const getRouteModuleExports = async ( +export const getRouteModuleAnalysis = async ( resourcePath: string -): Promise => { - const source = await readFile(resourcePath, 'utf8'); - const code = await transformToEsm(source, resourcePath); - return getExportNames(code); +): 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 { exportNames, exportAllModules } = + await getExportNamesAndExportAll(code); + return { code, exports: exportNames, exportAllModules }; + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + routeModuleAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { + routeModuleAnalysisCache.delete(resourcePath); + } + }); + + setBoundedCacheEntry(routeModuleAnalysisCache, resourcePath, { + mtimeMs: stats.mtimeMs, + size: stats.size, + analysis: trackedAnalysis, + }); + return trackedAnalysis; }; diff --git a/src/index.ts b/src/index.ts index 67ae7d9..6eb58ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,22 @@ 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'; 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, JS_EXTENSIONS, PLUGIN_NAME, SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { @@ -44,27 +44,28 @@ 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 { - getExportNames, + getBundlerRouteAnalysis, getExportNamesAndExportAll, - getRouteModuleExports, + getRouteModuleAnalysis, transformToEsm, } from './export-utils.js'; import { - detectRouteChunksIfEnabled, 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, @@ -73,6 +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, + roundMs, +} from './performance.js'; +import { mapVirtualModules } from './virtual-modules.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -127,6 +133,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())]) @@ -387,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, @@ -414,15 +425,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) => { @@ -479,6 +481,11 @@ export const pluginReactRouter = ( fsExtra.copySync(serverBuildDir, ssrDir); } } + if (logPerformance) { + performanceProfiler.flush(environment.name, { + compilerLifecycleMs: roundMs(performance.now() - setupStartMs), + }); + } }); // Determine prerender paths from config @@ -751,11 +758,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)) { @@ -1004,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( @@ -1042,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, { @@ -1062,8 +1065,7 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), - }, - vmodTempDir + }) ); }; @@ -1173,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', }, - } - : {}), + }, + }, + }, }, }); }); @@ -1256,28 +1251,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, - }; - } + ); }, } ) @@ -1306,450 +1311,426 @@ export const pluginReactRouter = ( } ); - // Add manifest transformations api.transform( { 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 () => { + 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 () => { + return createRouteClientEntryArtifact({ + code: args.code, + resourcePath: args.resourcePath, + environmentName: args.environment?.name, + isBuild, + routeChunkCache, + routeChunkConfig, + }); } - 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=/, + environments: ['web'], }, - 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)});`; - - 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( - 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, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:chunk', + args.resource, + async () => { + return createRouteChunkArtifact({ + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + isBuild, + routeChunkCache, + routeChunkConfig, + }); + } + ) ); api.transform( { test: /\.[cm]?[jt]sx?$/, + environments: ['web'], }, - 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 (!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 analysis = await getBundlerRouteAnalysis( + args.code, + args.resourcePath + ); + const { hasRouteChunks, chunkedExports } = + await analysis.getRouteChunkInfo( + routeChunkCache, + routeChunkConfig + ); + 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 analysis.getExportNames(); + 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?)?$/, + environments: ['web'], }, - async args => { - 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}` - ); - } + async args => + performanceProfiler.record( + args.environment?.name, + 'module:server-only-guard', + args.resource, + async () => { + 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?)?$/, + environments: ['node'], }, - 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; + async args => + performanceProfiler.record( + args.environment?.name, + 'module:client-only-stub', + args.resource, + async () => { + 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; + } + } + 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 { - continue; - } - } - return null; - }; - const resolvePathWithExtensions = (basePath: string): string | null => { - if (existsSync(basePath)) { - try { - const stats = statSync(basePath); - if (stats.isFile()) { - return basePath; + for (const ext of JS_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + if (!existsSync(candidate)) { + continue; + } + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + continue; + } } - if (stats.isDirectory()) { - 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; + } } - } 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; + try { + const resolver = createRequire( + pathToFileURL(importerPath).href + ); + return resolver.resolve(specifier); + } catch { + return null; } - } catch { - continue; - } - } + }; - return resolveIndexFile(basePath); - }; + 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); + } + }; - 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; + for (const specifier of exportAllModules) { + const resolvedPath = resolveExportAllModule( + specifier, + args.resourcePath + ); + if (!resolvedPath) { + unresolvedExportAll.add(specifier); + continue; + } + await collectExportNamesFromModule(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 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; + 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)}\`.` + ); } - await collectExportNamesFromModule(nestedPath); - } - }; - - for (const specifier of exportAllModules) { - const resolvedPath = resolveExportAllModule( - specifier, - args.resourcePath - ); - if (!resolvedPath) { - unresolvedExportAll.add(specifier); - continue; + 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; - } - - // 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. - // - // 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); - - 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 + async args => + performanceProfiler.record( + 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 = 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 (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 ` + + `\`${relativePath}\`: ${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 && + 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 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') { + 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, - }); - } + return generate(ast, { + sourceMaps: true, + filename: args.resource, + sourceFileName: args.resourcePath, + }); + } + ) ); }, }); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 4c9ab61..f963c18 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -105,11 +105,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}`; @@ -117,7 +112,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 442a82f..e9c6061 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -120,6 +120,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 1b76c59..cfd09ba 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/route-chunks-cache.test.ts b/tests/route-chunks-cache.test.ts index 7737c6f..69f13e9 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 4b25340a63b834d45adf93c6bc9aaa4103e95179 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 14/64] perf: clear build request stream timeouts --- src/index.ts | 260 +++++++++++++++++++-------------- src/templates/entry.server.tsx | 11 +- src/types.ts | 11 ++ tests/index.test.ts | 21 +++ 4 files changed, 190 insertions(+), 113 deletions(-) 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 34ee38fd1d672b5d0b9b3cfcf1234e43b4b3091f 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 15/64] 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 5fa79f412ae058d4eb5b12607d6e530dc1cedfc2 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 16/64] 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 | 164 +++++++------- tests/export-utils.test.ts | 80 ++++++- tests/remove-exports.test.ts | 207 ++++++++++++++++++ 11 files changed, 693 insertions(+), 209 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 f963c18..c3272eb 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -284,7 +284,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; @@ -292,9 +306,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)) { @@ -318,8 +332,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); } }, @@ -341,66 +361,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 = ( @@ -408,8 +537,24 @@ export const removeExports = ( exportsToRemove: 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') { @@ -421,9 +566,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; } @@ -441,8 +587,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; @@ -463,7 +611,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); } } @@ -472,12 +623,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); } } @@ -490,15 +645,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 e9c6061..4e374ec 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -82,7 +82,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 = @@ -92,7 +91,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 cfd09ba..3e92cd4 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -48,6 +48,16 @@ export const routeChunkNames: RouteChunkName[] = [ ...routeChunkExportNames, ]; +const createRouteChunkExportMap = ( + getValue: (exportName: RouteChunkExportName) => boolean +): Record => + Object.fromEntries( + 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 = { @@ -75,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; @@ -94,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, () => { @@ -119,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; @@ -182,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; @@ -204,7 +202,7 @@ const setsIntersect = (set1: Set, set2: Set) => { const getExportDependencies = ( code: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): Map => { return getOrSetFromCache( @@ -307,7 +305,7 @@ const getExportDependencies = ( const hasChunkableExport = ( code: string, exportName: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ) => { return getOrSetFromCache( @@ -380,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( @@ -404,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; @@ -439,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' || @@ -454,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 }); } ); }; @@ -472,7 +464,7 @@ const getChunkedExport = ( const omitChunkedExports = ( code: string, exportNames: string[], - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): string | undefined => { return getOrSetFromCache( @@ -508,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; @@ -549,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' || @@ -564,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 }); } ); }; @@ -584,12 +574,10 @@ export const detectRouteChunks = ( cache: RouteChunkCache | undefined, cacheKey: string ): RouteChunkInfo => { - const hasRouteChunkByExportName = Object.fromEntries( - routeChunkExportNames.map(exportName => [ - exportName, - hasChunkableExport(code, exportName, cache, cacheKey), - ]) - ) as Record; + const analysisCache = cache ?? new Map(); + const hasRouteChunkByExportName = createRouteChunkExportMap(exportName => + hasChunkableExport(code, exportName, analysisCache, cacheKey) + ); const chunkedExports = Object.entries(hasRouteChunkByExportName) .filter(([, isChunked]) => isChunked) .map(([exportName]) => exportName as RouteChunkExportName); @@ -612,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 = ( @@ -653,6 +647,29 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; +export const createEmptyRouteChunkByExportName = (): 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 buildManifestChunkValidity = ( + exportNames: ReadonlySet, + hasRouteChunkByExportName: Readonly> +): Record => + createRouteChunkExportMap( + exportName => + !exportNames.has(exportName) || hasRouteChunkByExportName[exportName] + ); + export const detectRouteChunksIfEnabled: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig, @@ -667,12 +684,7 @@ export const detectRouteChunksIfEnabled: ( const noRouteChunks = (): RouteChunkInfo => ({ chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, - hasRouteChunkByExportName: { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - } as Record, + hasRouteChunkByExportName: createEmptyRouteChunkByExportName(), }); if (!config.splitRouteModules) { diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index 95d6e7e..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,12 +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({ @@ -48,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 a283d566b89f8cc38b7435f18260514dc00f3e40 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 17/64] perf: enable lazy compilation by default --- src/index.ts | 1 + src/types.ts | 4 ++-- tests/index.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) 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 ae8487cf2094ad29491d521434bada809d703901 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 18/64] 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 a1184c2b25dded2f210469f854fda116167f3f88 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 19/64] 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 e80b318d8387c0c03de3451be1fde8794ef2cec1 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 20/64] fix: harden route export splitting --- package.json | 1 - pnpm-lock.yaml | 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 +++++ 13 files changed, 219 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 832c605..9b52182 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,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 716ed84..72b63b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -8449,9 +8446,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==} @@ -16750,10 +16744,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/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 c3272eb..47d7a57 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -12,12 +12,29 @@ export function validateDestructuredExports( id: AnyNode, exportsToRemove: 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) @@ -54,6 +71,7 @@ export function validateDestructuredExports( } if ( + property.value.type === 'AssignmentPattern' || property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' ) { @@ -84,6 +102,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(/^\/+/, '')}` @@ -557,6 +583,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( @@ -765,26 +802,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]); } @@ -811,19 +867,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 fd1a4ba51d552c8c91151de2025f05bf0f89fe76 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 21/64] feat: add parallel route transform executor --- rslib.config.ts | 2 + 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/parallel-route-transforms.test.ts | 66 +++++ tests/setup.ts | 1 + 8 files changed, 778 insertions(+), 290 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/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/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 a4f2326bf73295a068a64e109330394f781192b4 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 22/64] 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 +- performance-timing-semantics-analysis.md | 149 ----- pnpm-lock.yaml | 50 +- route-analysis-duplication-audit.md | 356 ----------- route-chunk-parse-traverse-analysis.md | 244 ------- 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 | 190 +++++- 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/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 | 35 +- tests/route-chunks.test.ts | 56 +- tests/setup.ts | 17 + 40 files changed, 2362 insertions(+), 3294 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 c5defff..1a15002 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -69,6 +69,20 @@ 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. +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/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 72b63b0..0508e45 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) @@ -3637,96 +3640,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 @@ -3740,18 +3759,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 @@ -3790,6 +3812,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 @@ -6631,6 +6654,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: @@ -7912,6 +7936,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: @@ -9261,6 +9286,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: @@ -12247,17 +12273,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) @@ -12384,7 +12399,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: @@ -16728,13 +16743,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/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 47d7a57..1ba9166 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,7 +1,11 @@ import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; import { walk, type ParseResult } from 'yuku-parser'; -import { NAMED_COMPONENT_EXPORTS, JS_EXTENSIONS } from './constants.js'; +import { + NAMED_COMPONENT_EXPORTS, + NAMED_COMPONENT_EXPORTS_SET, + JS_EXTENSIONS, +} from './constants.js'; type AnyNode = Record; @@ -10,7 +14,7 @@ const getProgram = (ast: ParseResult | AnyNode): AnyNode => export function validateDestructuredExports( id: AnyNode, - exportsToRemove: string[] + exportsToRemove: readonly string[] ): void { if (id.type === 'Identifier') { if (exportsToRemove.includes(id.name)) { @@ -558,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: 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, @@ -587,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); } @@ -602,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); @@ -623,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); @@ -646,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); @@ -658,7 +727,7 @@ export const removeExports = ( if ( statement.type === 'ExportDefaultDeclaration' && - exportsToRemove.includes('default') + exportsToRemoveSet.has('default') ) { exportsChanged = true; const declaration = statement.declaration; @@ -696,6 +765,8 @@ export const removeExports = ( removedExportReferencedNames ); } + + return exportsChanged; }; export const removeUnusedImports = (ast: ParseResult | AnyNode): void => { @@ -788,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); @@ -839,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]); @@ -878,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); @@ -916,5 +1062,5 @@ export const transformRoute = (ast: ParseResult | AnyNode): 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 index 4e374ec..65bdc7c 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, @@ -81,17 +79,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, @@ -126,13 +132,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/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 69f13e9..a84b4f0 100644 --- a/tests/route-chunks-cache.test.ts +++ b/tests/route-chunks-cache.test.ts @@ -113,11 +113,42 @@ describe('route chunk cache', () => { expect(uncached).toEqual(cached); }); - it('stores only the shared route chunk analysis entry', async () => { + it('stores the Yuku route chunk analysis entries for repeated chunk generation', async () => { const cache = new Map(); await collectRouteChunkOracle(cache); - expect(Array.from(cache.keys())).toEqual(['routes/demo.tsx::analysis']); + 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::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 19d3393a9ddab4109ea34ad0a354850e9d613ef9 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 23/64] 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 3cecc9f4f45b618d8d6f6367dd297910c4a944f6 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 24/64] 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 ccd528348b3cd437730da512455c45fa36cfe45a 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 25/64] 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 54fb8bca39c12d57a422c8f045cbf1b020167b6a 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 26/64] 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 9b52182..8b1ce9d 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,7 @@ "build": "rslib build", "bench": "node scripts/bench-builds.mjs", "bench:ci-report": "node scripts/report-benchmark-ci.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", @@ -63,7 +61,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 7413ccff384151ea5d9e3825dde5c7020be5d1b9 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 27/64] 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], + ]); + }); +}); From 474145e6d64a00fd0292f43766d78196144039e9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:06:29 +0200 Subject: [PATCH 28/64] chore: simplify route restart marker read --- src/route-watch.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/route-watch.ts b/src/route-watch.ts index 153a0c7..d57cc81 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, watch, type FSWatcher } from 'node:fs'; +import { 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'; @@ -49,10 +49,6 @@ 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; From 895ce2bf9073259bd37e43d6c9dcd2f9d190cf66 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:04 +0200 Subject: [PATCH 29/64] chore: update yuku printer --- package.json | 6 +- pnpm-lock.yaml | 925 +++++++++++++++++++++++++++----------------- src/babel.ts | 1 - src/export-utils.ts | 1 - src/route-chunks.ts | 1 - 5 files changed, 580 insertions(+), 354 deletions(-) diff --git a/package.json b/package.json index 8b1ce9d..1e20e80 100644 --- a/package.json +++ b/package.json @@ -81,9 +81,9 @@ "jsesc": "^3.1.0", "pathe": "^2.0.3", "react-refresh": "^0.18.0", - "yuku-analyzer": "0.5.38", - "yuku-codegen": "0.5.38", - "yuku-parser": "0.5.38" + "yuku-analyzer": "0.5.39", + "yuku-codegen": "0.5.39", + "yuku-parser": "0.5.39" }, "devDependencies": { "@changesets/cli": "^2.29.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0508e45..b4f30b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,21 +42,21 @@ importers: specifier: ^0.18.0 version: 0.18.0 yuku-analyzer: - specifier: 0.5.38 - version: 0.5.38 + specifier: 0.5.39 + version: 0.5.39 yuku-codegen: - specifier: 0.5.38 - version: 0.5.38 + specifier: 0.5.39 + version: 0.5.39 yuku-parser: - specifier: 0.5.38 - version: 0.5.38 + specifier: 0.5.39 + version: 0.5.39 devDependencies: '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@25.0.10) '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/config': specifier: workspace:* version: link:config @@ -96,9 +96,15 @@ 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 + pkg-pr-new: + specifier: ^0.0.75 + version: 0.0.75 playwright: specifier: ^1.58.0 version: 1.58.0 @@ -165,7 +171,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) @@ -189,7 +195,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) examples/cloudflare: dependencies: @@ -223,7 +229,7 @@ importers: version: 7.13.0(@cloudflare/workers-types@4.20260127.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) @@ -284,7 +290,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.9.4)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) @@ -293,7 +299,7 @@ importers: 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)) '@rsdoctor/rspack-plugin': specifier: ^1.5.13 - version: 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + version: 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -348,13 +354,13 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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-less': specifier: ^1.6.4 - version: 1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + version: 1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@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)) @@ -378,13 +384,13 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -396,10 +402,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) examples/epic-stack: dependencies: @@ -474,7 +480,7 @@ importers: 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) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -621,14 +627,14 @@ importers: version: 4.0.2(tailwindcss@4.1.18) vite-env-only: specifier: 3.0.3 - version: 3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) zod: specifier: 3.25.76 version: 3.25.76 devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -637,7 +643,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -715,7 +721,7 @@ importers: version: 0.5.10 '@vitejs/plugin-react': specifier: 5.1.2 - version: 5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) autoprefixer: specifier: 10.4.23 version: 10.4.23(postcss@8.5.15) @@ -763,7 +769,7 @@ importers: version: 5.9.3 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) examples/federation: devDependencies: @@ -802,13 +808,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -856,7 +862,7 @@ importers: 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) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -1007,7 +1013,7 @@ importers: devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -1016,7 +1022,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -1166,13 +1172,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -1220,7 +1226,7 @@ importers: 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) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -1371,7 +1377,7 @@ importers: devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -1380,7 +1386,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -1528,7 +1534,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) @@ -1552,7 +1558,7 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. @@ -1561,7 +1567,7 @@ importers: version: 14.2.5 string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1573,10 +1579,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) examples/spa-mode: dependencies: @@ -1607,7 +1613,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) @@ -1631,7 +1637,7 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. @@ -1640,7 +1646,7 @@ importers: version: 14.2.5 string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1652,10 +1658,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) packages: @@ -1879,6 +1885,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -4788,6 +4798,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -4825,8 +4838,8 @@ packages: '@types/node@25.0.10': resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} - '@types/node@25.1.0': - resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -5137,168 +5150,168 @@ 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==} + '@yuku-analyzer/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-+C9QJxMZ1ujKhhBf//NQTDYZ+es94VgekoULPBV4mHSUDNfmuVW2XB7FmV6RJv4NBrdZRQQ/WcbNiuz7M54VpA==} cpu: [arm64] os: [darwin] - '@yuku-analyzer/binding-darwin-x64@0.5.38': - resolution: {integrity: sha512-aG4J3j+rQ7UDR+BfY2ku3iurq2wCFJC3edbZCTztbkHkB6L7ScdwpVlJdIczDsxI6+U2J+nwB50VtW3asZlQow==} + '@yuku-analyzer/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-GHO4pYMTm2W+tNxwM9bforEggO3qQ2V8qQ/+CxVLLJSFk2UwtwjDUmAzoCmydC2sxX6kS4B2C+UxR3IiGkvbIg==} cpu: [x64] os: [darwin] - '@yuku-analyzer/binding-freebsd-x64@0.5.38': - resolution: {integrity: sha512-3syI009LnQBNC+RdpEZZAP7P+rVe6Q2FQY3ZHlX5ejRb+eNjWqHoZbOSlqaON7WAOf1JQe5iLlEkMKZVBK5OiQ==} + '@yuku-analyzer/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-LWMPzuXL7QlSeiw43wgBZsBTEkcRs3mAshisQVztYCpeAhh9USznXmlo201rcE47X1V7hc8gc7FXRsBD9gs60Q==} cpu: [x64] os: [freebsd] - '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': - resolution: {integrity: sha512-DjHt+fDqYnptWXLGCbIL4QW2pDTGzmzi45qMz063h+2PoY7xhUmGCkqTm8Teh3IZmnm2J2ZIz9HJNHm+VOujmg==} + '@yuku-analyzer/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-W5jhcSUUf3iirM5qeZCQVHhnDfUUUSM2jqLvWhuWfnEbXlVECmr7PUZOX6k92FlKINrLpgkUgF+Qa3TJ7UV6pg==} cpu: [arm] os: [linux] - '@yuku-analyzer/binding-linux-arm-musl@0.5.38': - resolution: {integrity: sha512-4FJgBfmZuUWHzx/OTiBjk6ZmniacT9CawHeOaQBwEgBYmvjBtaOOUtvBqD5lrJKR3VTyLAsW/DF0zHmJvkAPBA==} + '@yuku-analyzer/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-m0JlZRU9UMmHhgHJSAHzruc7/5RuxahLo1U9hrTEeePe3UtQfC6uNsJS+Oktd7K/HWhE5vJDfe2Jn8nn4Tc0PA==} cpu: [arm] os: [linux] - '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': - resolution: {integrity: sha512-YMfGT7QSFe9QXwbwmGasuXN62coW6u+TC1jb/NroGaOHsDB79cKeRz+rG+HDppTmNHlYRJfzkg2QucmvpGWTHg==} + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-18tLRh0LUcwHqrXB31/xvxiiOZBCKQ4uTvAsltAfB9p+l1WDVYwogI3vkbvP/xkqqgWFlPYeV09wPmttdrelUw==} cpu: [arm64] os: [linux] - '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': - resolution: {integrity: sha512-ujtqERprsVGIIQihk6wtsECSdE9XWe5Eij4/2aRbf/v0MilNECIqccC8cFElFNlJxOkZBNAL8UbF2IeCXU5+Tw==} + '@yuku-analyzer/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-v7bikc1uuy+Wqd7EHV9hAgQacsUMgLYmfm+QArs0RfsotNHOe7HRlEhQ1SUw6qgnOg2WU6QZDndoUio8CVr+uQ==} cpu: [arm64] os: [linux] - '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': - resolution: {integrity: sha512-hDDWq+CDLxuntgGoitQAaCw/ueDXNhGV0yIaEbZDEv5AvwGPgh09gBF5+yx/5wBdNNRACeYeFyukeJuIFAndZQ==} + '@yuku-analyzer/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-jV97sJa+Hf7EcGAbj3NSvYP7VX0LlhpDkAO9vNVHjro0Aj9suwW20o4+fRPuzYHxZKUOJf0Hf3mmW7MUoCgb0A==} cpu: [x64] os: [linux] - '@yuku-analyzer/binding-linux-x64-musl@0.5.38': - resolution: {integrity: sha512-k8koEM7OacjdoAHDg0T0ZMtHoBAcRqyE8zIvYhjQnZfInyJ0t+WT4oeq/l//YMbcsF4wOg+QvuS+3OutyRcLTA==} + '@yuku-analyzer/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-r1PCUYBtcrnX5xwsEK1LY7QXty65Vh8y6JprItNqpMssRiOHCyLj8RW9omrV//9jGsEHco3PACXkZqAE94pvmw==} cpu: [x64] os: [linux] - '@yuku-analyzer/binding-win32-arm64@0.5.38': - resolution: {integrity: sha512-oLlk9JWH+E0qK+wc/jW2PN0ZdwNdGa0cMPN24hMmhiK6yPSFZNJUG6138XyynYq6iV//TkSzsYbweAjwtzmEAw==} + '@yuku-analyzer/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-WdFhMS8j8w5r85hD0hcieXF3le0EOMAv/iJYOVvvoVgupn2k+T//jyJjkuuzbAVIfq6sVv/IJPWa0vobhk3pfQ==} cpu: [arm64] os: [win32] - '@yuku-analyzer/binding-win32-x64@0.5.38': - resolution: {integrity: sha512-DthuKpARL1lciV1XQod2r68YgFqZ+JM+oeZ+/umNJ166+HuaO2UwKpQ5h5IO8tC0MPvlLDO+J+er3aNMMrsVTQ==} + '@yuku-analyzer/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-qFH6vAxClP8BNpztHFt3r/Qkf5O/bMVlSPd9T+jyv+dSHlVgnbXgz5cbI3Ozd28FPbjmsxbGM9VCZq8GsMywHA==} cpu: [x64] os: [win32] - '@yuku-codegen/binding-darwin-arm64@0.5.38': - resolution: {integrity: sha512-2nOLgv6h5pDda+Ykqg2N+tcm+lYEdSIoStVxpUV2IlbNWSg7/q2iCtEl0qWo42o9H6Oivk6c6BftDJdZNsMIbg==} + '@yuku-codegen/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-zBgmr0X0IQ5jr+lUsm0pK9EVcea4xOr/gTw/sFAZBkl5PrK6k+jhXCLcYTFnRZ7XCV+7XjZZeEBI3bOcTTb/Ug==} cpu: [arm64] os: [darwin] - '@yuku-codegen/binding-darwin-x64@0.5.38': - resolution: {integrity: sha512-P1gUksBlW+q7MmOoLKwEkBcJ7sxlO8e4C00dWvuTFrrygTJZJfalN8WZ0DOrjkV06DLy4FIBn7FXdDy+yPbeaw==} + '@yuku-codegen/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-DKKCXwLyRXquicGQODwt3oHUAl10Kcc9wFsuw9mqyR+vqLeHNsIPgO37zfzHGiTvDehwBcy6YfNIyS+bKxQhpA==} cpu: [x64] os: [darwin] - '@yuku-codegen/binding-freebsd-x64@0.5.38': - resolution: {integrity: sha512-jsF0g3FYkzeuGijBfTWuPVQo6xMjCWPrQihNjNJdrhObPeMFRW/jCcPsjre20aZBLF5gbzRH9BFaEiGJtXvo0Q==} + '@yuku-codegen/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-IN+r1J1wrq8llBvvCx9O4/hZq6uMtYufDY1DyclnnVexuZ6oxTO+4Owg2PvHX1oeJWa0eWbvXyF75uscuX6FXw==} cpu: [x64] os: [freebsd] - '@yuku-codegen/binding-linux-arm-gnu@0.5.38': - resolution: {integrity: sha512-h5jGyr8fQ+zwmf3VzwyS+ltHfzm7iYXzkAy5TWOTT1x5GHGH5tVTBljPAmc/o+T19uj42aQgL5z3pzcMJ9g+MQ==} + '@yuku-codegen/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-CBmdviCmf/mw+AgU54OgA+oxgHiGgowYdz/AIPyQICKOo+F/bgeAGJcOdPvsGxAyLoylp8IdPXAqMzcmsaXdkA==} cpu: [arm] os: [linux] - '@yuku-codegen/binding-linux-arm-musl@0.5.38': - resolution: {integrity: sha512-7w5OxDdSBantw40nFOa/5lejp3IPoAgAg3u3uTW95ipL9/cCHSyq8cKtPxSGr+8LHVx0fCO4a2tEB8SFLy8PXQ==} + '@yuku-codegen/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-g34mpy2JG+rl5PaQmPARtf7BHHvHYhk3jMlwlfshUO+2frLLDCaSVTGzEb8jw5bqqiwHe/pMNveq9PYY1w/R+w==} cpu: [arm] os: [linux] - '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': - resolution: {integrity: sha512-ZW4WJm4ygtzMv0VWwVvDa2TbkOAywggb6FGD+ZSYUNafGflMhlDvOsRTnNzlEsFqw3uU3RLEU795Ql0o2xrTxA==} + '@yuku-codegen/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-EG6bv5an/4hx6Sy/ikDg7raRjtJgVoDLPfoDFULF9dJKlb7bVcGdhyAJUXy20HF5uQUMCtwtw+Zygduhvsajdw==} cpu: [arm64] os: [linux] - '@yuku-codegen/binding-linux-arm64-musl@0.5.38': - resolution: {integrity: sha512-jlZRCKfElmGMFNUweknPdGiqukSi9N4XiS10jJ/ROFg8ND6fk9NOI2r+pr6qN0U2GAkfu2mgT7RFSHHtgtLCIg==} + '@yuku-codegen/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-OTD73F2RMyxY9LTANkJZg+nFGq4VILqDQ/vtp8OdXxDls/cBIFGUHKHtWyZhXOBCtmtgVeJlWtiXsodmA2ZC9Q==} cpu: [arm64] os: [linux] - '@yuku-codegen/binding-linux-x64-gnu@0.5.38': - resolution: {integrity: sha512-RxrCajqnaeJqNzs9RM8Jze/RSk2PQFDRBEkxUovpJPNhiDj5Q4JbBz5ruZBmi7bgwHliFohhqzdReyovRLsvEg==} + '@yuku-codegen/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-mZLLkl9wa+4V4poXBCjjMB4Qu2UXVvRQSgP1rLuzi7FUCkK5+Cy16Eawy8NgmWEtd82tpfe9Jmqt7rhl55LzJg==} cpu: [x64] os: [linux] - '@yuku-codegen/binding-linux-x64-musl@0.5.38': - resolution: {integrity: sha512-QzUiOPUICxzM46Au7f2T0rUE7b+gxuDyOETL1Iz8o0JKYLjt04m6dqpo9Ln2tF4jN8RW7flolzDN7gdVQXGQsw==} + '@yuku-codegen/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-KFkWzyUQaa7tRy5tmX5w//WAO58YiOh/GFsePeu7nuPfXueQFbKEa+9/5Jy0GoLCmaacg4quYwr3drGmY7PTew==} cpu: [x64] os: [linux] - '@yuku-codegen/binding-win32-arm64@0.5.38': - resolution: {integrity: sha512-NknjHAtzzJKawpMzmJ/XVi/BNk6rGs00GWCBclQUk8XNN0fJ/1urZ8iCibBZwlUjd6Z77GOYsovNU4a/Rh8nBQ==} + '@yuku-codegen/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-GAnElqWLv2tbmL/k0wYRrozqMozrEWhiWUSM6NpPOdQkEg4l9zfC+SqGJirNZQj7i+Oy+j3rIN0oMdz5hBsAow==} cpu: [arm64] os: [win32] - '@yuku-codegen/binding-win32-x64@0.5.38': - resolution: {integrity: sha512-OFRc/vNo/3nsX0ARyxpwZPsVzbDA71YyCMhKYqnyeD5OZek0O88PAjtYCg8YrmIuNGLWYE0fvMpsZe51AjePTg==} + '@yuku-codegen/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-uaF7BD+MkXj+9RFVih2Kv6UOg/Q0+jytSVYP5YRJfTrXcEFyEqNLMrZZav6dPGj9xOTSQUGgxvHNQS0XPf1R5Q==} cpu: [x64] os: [win32] - '@yuku-parser/binding-darwin-arm64@0.5.38': - resolution: {integrity: sha512-Y6hexHekLYsOyPXJwYmLUhbwawYrHx4YfFNB72vyej/CkMtG0RLHpzJKTqAwn6JTR2zdvLx6sV8gx47dAmjWNg==} + '@yuku-parser/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-MYd/uTmmKRc9dyefCwprRYwqrXFbL97oW2+ANGqDpc5E/wezfsyF/aD+Xxb3pNrc/0bjhIPM76EO2a3ne7f0oQ==} cpu: [arm64] os: [darwin] - '@yuku-parser/binding-darwin-x64@0.5.38': - resolution: {integrity: sha512-/Y/GOsBUwLgcHdxzDZ6JoO4iH2NK94wDileNz8h1hPyUEAYPUo6x3+4JXMT7MHJRyoPuHIrQ/p2JZmPdDtjguQ==} + '@yuku-parser/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-8qyPmKObZ9ro9gJWIhoUz3WZCDzf3OC1K9arLHb6zjvJWQPcQ64hxkuZXscGRX8p0elleZLx7sTy9HSbcuVS/A==} cpu: [x64] os: [darwin] - '@yuku-parser/binding-freebsd-x64@0.5.38': - resolution: {integrity: sha512-OLUvZAx1g+nDg0cPk/QEkOdc6d49DLCkEhsGjqyd3uit69CngK9Fs8154pOZc/3Y2QAy4jQAs8HnnediyIc5Bw==} + '@yuku-parser/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-ZP4Iksc54bBmVPVXg/8b9i+KzkQo+vGQE9BElnc+WRBQRos+xrH6pWLjWdm70AhJS+QLDDqEWpDValG22Mb4BA==} cpu: [x64] os: [freebsd] - '@yuku-parser/binding-linux-arm-gnu@0.5.38': - resolution: {integrity: sha512-LcyGYaBuBm1VYKH0qURqKRcMkW0/PaZdfFdTyHaStLNJzYrHzJLBE/wI5dm2q6NEc56NMnFMSrRzw/BUXv4V0g==} + '@yuku-parser/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-T9T/QrDM4c7BKoJcLu2fDLF2ZaXIn+ZBPhXOVKM/EJvjvVe3TluIla1xXLlDdXjxU5M1G5q4ngcXz65qz1tvEA==} cpu: [arm] os: [linux] - '@yuku-parser/binding-linux-arm-musl@0.5.38': - resolution: {integrity: sha512-d6fd0z96mmq85rm1w8+AUURQoW/R7JxNXx61oWusVAC+JdJmK6KUty5r1hTXiLGRAzdkZXeG3ZlnqlzjvC0wnQ==} + '@yuku-parser/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-/5FrXOu/M/m/91kdVxcZJWO8CYsvA93a9ihhRYlPEeTJ3iu8HWeZhRhlbCAVVulGpvpSifak/1UdIPjZDsumvg==} cpu: [arm] os: [linux] - '@yuku-parser/binding-linux-arm64-gnu@0.5.38': - resolution: {integrity: sha512-fz9emPmTQsupJR0H5s/oMHf9JrIMo6qaNXVR9ljY9PFICW0+FD7TMdAwoIN7pK/vZ3AgOKWMZcDjjpoA6qaEZQ==} + '@yuku-parser/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-hl6zf9VT6krdaM7kTjYmXElf9fbEtgjecLUJn/YmTc10IQYvxolwgSETKWNDouaq2mYPl3YG9xg4m/atZJXJYA==} cpu: [arm64] os: [linux] - '@yuku-parser/binding-linux-arm64-musl@0.5.38': - resolution: {integrity: sha512-jvFgjPgoUo9kOebQD4mZUyQ2xMrsuOcTuzJ2rWLApqUTpnrOoVwWyL1MKNw3CdkZUh0h/nMoIPQmQXObmSRxNw==} + '@yuku-parser/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-PVCU2JLondIFkTIuuZAaqnGIfL2H1AvugTQHMim1F+EWXqVvDQRMzlPXkvahLtB/qy5iegYmRSbfr5J8LUY45w==} cpu: [arm64] os: [linux] - '@yuku-parser/binding-linux-x64-gnu@0.5.38': - resolution: {integrity: sha512-DFmydzH7fHMRlFC82dVbIcPugN8eq83B/t2Zjy3HRLnQWMQXhFvZNiv9NNinT3ccjzGeFKo/V3+N9/tGc98sGQ==} + '@yuku-parser/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-gVqZR/I4fKotvK3Wcg17Mvvm75wzB6ySW2UbIIbUKU9CPvEE66wGzu/Q3uHv08EKnM/k4KhB0NrmQR5/d37GYQ==} cpu: [x64] os: [linux] - '@yuku-parser/binding-linux-x64-musl@0.5.38': - resolution: {integrity: sha512-PTwAGbC5I5Fj6VI38HtI4C3BDrNgpXZdtcK9Um3i/2Tv2R1AintQhIDMyl5ir6NI7AweWl83sHAkO9xAG7cEEw==} + '@yuku-parser/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-quABxCeXY8Yu6Dljp08kNogcRQz3glGx4/voQ9Ee87kCZge9bzEo3Vfr+H2B/Z5xazR3Rsz65LEUTZNiuf6mcA==} cpu: [x64] os: [linux] - '@yuku-parser/binding-win32-arm64@0.5.38': - resolution: {integrity: sha512-3gxfBDo1G70Y1q2Ec8lAYQ2+BV3bA9i74lovmIVRmv6C55aiXfBzrJHwSvsBU/Js1r0MtzT13vdxUdx83BPsuw==} + '@yuku-parser/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-h9qTGty+oxY/4rb3R18IgNfOwh6D8MLKfoIyQ2UJU35AVKksqKjMGGPlenG9eqFm0ohvsxqgs6AMHEdfSyrxxg==} cpu: [arm64] os: [win32] - '@yuku-parser/binding-win32-x64@0.5.38': - resolution: {integrity: sha512-CDZz3v7M6+PyZQJjAJFkZURbDmZtaJeOTgrvDudAk5FzMemcfuTJBKZ0hYp3OI/u0va0kDsre3eCRIwg5eMVbA==} + '@yuku-parser/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-geGQZsNoBnjJ3EGUphmy/39Ajm3jBkPqjY0qvQVlgpE8V5yUrDc7dVWdcJ5Pkv/UhOMS3FzSgJFQtRFgajbZsA==} cpu: [x64] os: [win32] @@ -5335,6 +5348,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + address@2.0.3: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} @@ -5372,6 +5390,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -5517,6 +5538,11 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + baseline-browser-mapping@2.9.18: resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} hasBin: true @@ -5603,6 +5629,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5651,6 +5682,9 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -6134,6 +6168,9 @@ packages: electron-to-chromium@1.5.279: resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==} + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6169,6 +6206,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -6735,6 +6776,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -6907,6 +6952,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -7303,8 +7352,8 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} loader-utils@2.0.4: @@ -7599,6 +7648,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + node-schedule@2.1.1: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} @@ -7662,8 +7715,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} @@ -7862,6 +7916,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -7875,6 +7933,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkg-pr-new@0.0.75: + resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} + hasBin: true + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -8142,9 +8204,6 @@ packages: resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} engines: {node: '>=0.12'} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.0: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} engines: {node: '>= 0.6'} @@ -8415,6 +8474,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -8669,17 +8733,14 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - - seroval-plugins@1.5.0: - resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.0: - resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} serve-handler@6.1.6: @@ -9032,24 +9093,51 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terser-webpack-plugin@5.3.16: - resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} peerDependencies: + '@minify-html/node': '*' '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' uglify-js: '*' webpack: ^5.1.0 peerDependenciesMeta: + '@minify-html/node': + optional: true '@swc/core': optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true esbuild: optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true uglify-js: optional: true - terser@5.46.0: - resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} hasBin: true @@ -9059,20 +9147,24 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tldts-core@7.0.19: @@ -9205,6 +9297,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.18.2: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} @@ -9403,8 +9498,8 @@ packages: warning@3.0.0: resolution: {integrity: sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==} - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} wcwidth@1.0.1: @@ -9421,6 +9516,10 @@ packages: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} @@ -9589,8 +9688,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} yaml@2.7.0: @@ -9632,14 +9731,14 @@ 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-analyzer@0.5.39: + resolution: {integrity: sha512-ifxMHDDo3OM2LVdgN18yMurZYMbZkM51MBjR59rBLd/BOowqkAU3zvlLKWZoH2xW0shVWRjXXlDnfHPvhxG2oA==} - yuku-codegen@0.5.38: - resolution: {integrity: sha512-oaWapF6EiMec8UndXkxVrHiYrDhKEywNTKLoYHLBkbIOxopKd9jBKpLOiYu89NNszuuglGkpQ1z+iuGWYytLPQ==} + yuku-codegen@0.5.39: + resolution: {integrity: sha512-SGvKDXn0I7MyQEYpDASCPGXIA6+hv6mhxoABicLa1EPhyKFp9vrfgKiXtrR0QHhlTYwlWPtCa1W2hHHTfysoDQ==} - yuku-parser@0.5.38: - resolution: {integrity: sha512-u2+4Vv948JFl+AiXWcKNoagrmZDL1jSvwBuRDoZq4pMTO/ZYJZp3lI2PuIXcLW1eL9eGxvBNvB+X5NgWgoFb0A==} + yuku-parser@0.5.39: + resolution: {integrity: sha512-uOddu+b5QhhH5+7gOb4mkNG8O4ExOyrG5q0UPuugy7I495eN4fuQlu20fp0F2lx6My/Th01XxtHjpHscIH9UFw==} zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -9900,6 +9999,9 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': + optional: true + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -10182,10 +10284,10 @@ snapshots: '@epic-web/client-hints@1.3.8': {} - '@epic-web/config@1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@epic-web/config@1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@total-typescript/ts-reset': 0.6.1 - '@vitest/eslint-plugin': 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@vitest/eslint-plugin': 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jest-dom: 5.5.0(@testing-library/dom@10.4.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-playwright: 2.5.1(eslint@9.39.2(jiti@2.6.1)) @@ -10715,7 +10817,7 @@ snapshots: - node-fetch - utf-8-validate - '@module-federation/enhanced@2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/enhanced@2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/cli': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -10733,7 +10835,7 @@ snapshots: upath: 2.0.1 optionalDependencies: typescript: 5.9.3 - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10768,16 +10870,16 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/node@2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/runtime': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) encoding: 0.1.13 node-fetch: 2.7.0(encoding@0.1.13) tapable: 2.3.0 optionalDependencies: - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10785,10 +10887,10 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rsbuild-plugin@2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/rsbuild-plugin@2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) - '@module-federation/node': 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + '@module-federation/node': 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) optionalDependencies: '@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) @@ -11847,7 +11949,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': + '@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -11877,8 +11979,8 @@ snapshots: semver: 7.7.3 tinyglobby: 0.2.15 valibot: 1.2.0(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) - vite-node: 3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) + vite-node: 3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@react-router/serve': 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) typescript: 5.9.3 @@ -11898,7 +12000,7 @@ snapshots: - tsx - yaml - '@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': + '@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.9.4)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -11928,8 +12030,8 @@ snapshots: semver: 7.7.3 tinyglobby: 0.2.15 valibot: 1.2.0(typescript@5.9.3) - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) - vite-node: 3.2.4(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) + vite-node: 3.2.4(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@react-router/serve': 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) typescript: 5.9.3 @@ -11972,9 +12074,9 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@react-router/remix-routes-option-adapter@7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3)': + '@react-router/remix-routes-option-adapter@7.13.0(@react-router/dev@7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3)': dependencies: - '@react-router/dev': 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + '@react-router/dev': 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) optionalDependencies: typescript: 5.9.3 @@ -12139,11 +12241,11 @@ snapshots: optionalDependencies: '@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-less@1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsbuild/plugin-less@1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: deepmerge: 4.3.1 less: 4.6.6 - less-loader: 12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(esbuild@0.27.2)) + less-loader: 12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)) reduce-configs: 1.1.2 optionalDependencies: '@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) @@ -12172,13 +12274,13 @@ snapshots: '@rsdoctor/client@1.5.13': {} - '@rsdoctor/core@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/core@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@rspack/resolver': 0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) browserslist-load-config: 1.0.3 es-toolkit: 1.47.1 @@ -12196,10 +12298,10 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/graph@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/graph@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) es-toolkit: 1.47.1 path-browserify: 1.0.1 source-map: 0.7.6 @@ -12207,13 +12309,13 @@ snapshots: - '@rspack/core' - webpack - '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/core': 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/core': 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) 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) transitivePeerDependencies: @@ -12225,12 +12327,12 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/sdk@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/sdk@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@rsdoctor/client': 1.5.13 - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) launch-editor: 2.14.1 safer-buffer: 2.1.2 socket.io: 4.8.1 @@ -12242,7 +12344,7 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/types@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/types@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@types/connect': 3.4.38 '@types/estree': 1.0.5 @@ -12250,12 +12352,12 @@ snapshots: source-map: 0.7.6 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) - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) - '@rsdoctor/utils@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/utils@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@babel/code-frame': 7.26.2 - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@types/estree': 1.0.5 acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) @@ -12820,7 +12922,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@tanstack/devtools-vite@0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -12832,7 +12934,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.12.0 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - bufferutil - supports-color @@ -12978,7 +13080,7 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/eslint@9.6.1': dependencies: @@ -12989,6 +13091,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 25.0.10 @@ -13035,10 +13139,9 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.1.0': + '@types/node@25.9.4': dependencies: - undici-types: 7.16.0 - optional: true + undici-types: 7.24.6 '@types/parse-json@4.0.2': optional: true @@ -13250,7 +13353,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -13258,18 +13361,18 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -13280,22 +13383,22 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true - '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@25.0.10)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optional: true '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true '@vitest/runner@4.0.18': @@ -13317,7 +13420,7 @@ snapshots: '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true '@web3-storage/multipart-parser@1.0.0': {} @@ -13402,103 +13505,103 @@ snapshots: '@xtuc/long@4.2.2': {} - '@yuku-analyzer/binding-darwin-arm64@0.5.38': + '@yuku-analyzer/binding-darwin-arm64@0.5.39': optional: true - '@yuku-analyzer/binding-darwin-x64@0.5.38': + '@yuku-analyzer/binding-darwin-x64@0.5.39': optional: true - '@yuku-analyzer/binding-freebsd-x64@0.5.38': + '@yuku-analyzer/binding-freebsd-x64@0.5.39': optional: true - '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': + '@yuku-analyzer/binding-linux-arm-gnu@0.5.39': optional: true - '@yuku-analyzer/binding-linux-arm-musl@0.5.38': + '@yuku-analyzer/binding-linux-arm-musl@0.5.39': optional: true - '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.39': optional: true - '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': + '@yuku-analyzer/binding-linux-arm64-musl@0.5.39': optional: true - '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': + '@yuku-analyzer/binding-linux-x64-gnu@0.5.39': optional: true - '@yuku-analyzer/binding-linux-x64-musl@0.5.38': + '@yuku-analyzer/binding-linux-x64-musl@0.5.39': optional: true - '@yuku-analyzer/binding-win32-arm64@0.5.38': + '@yuku-analyzer/binding-win32-arm64@0.5.39': optional: true - '@yuku-analyzer/binding-win32-x64@0.5.38': + '@yuku-analyzer/binding-win32-x64@0.5.39': optional: true - '@yuku-codegen/binding-darwin-arm64@0.5.38': + '@yuku-codegen/binding-darwin-arm64@0.5.39': optional: true - '@yuku-codegen/binding-darwin-x64@0.5.38': + '@yuku-codegen/binding-darwin-x64@0.5.39': optional: true - '@yuku-codegen/binding-freebsd-x64@0.5.38': + '@yuku-codegen/binding-freebsd-x64@0.5.39': optional: true - '@yuku-codegen/binding-linux-arm-gnu@0.5.38': + '@yuku-codegen/binding-linux-arm-gnu@0.5.39': optional: true - '@yuku-codegen/binding-linux-arm-musl@0.5.38': + '@yuku-codegen/binding-linux-arm-musl@0.5.39': optional: true - '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': + '@yuku-codegen/binding-linux-arm64-gnu@0.5.39': optional: true - '@yuku-codegen/binding-linux-arm64-musl@0.5.38': + '@yuku-codegen/binding-linux-arm64-musl@0.5.39': optional: true - '@yuku-codegen/binding-linux-x64-gnu@0.5.38': + '@yuku-codegen/binding-linux-x64-gnu@0.5.39': optional: true - '@yuku-codegen/binding-linux-x64-musl@0.5.38': + '@yuku-codegen/binding-linux-x64-musl@0.5.39': optional: true - '@yuku-codegen/binding-win32-arm64@0.5.38': + '@yuku-codegen/binding-win32-arm64@0.5.39': optional: true - '@yuku-codegen/binding-win32-x64@0.5.38': + '@yuku-codegen/binding-win32-x64@0.5.39': optional: true - '@yuku-parser/binding-darwin-arm64@0.5.38': + '@yuku-parser/binding-darwin-arm64@0.5.39': optional: true - '@yuku-parser/binding-darwin-x64@0.5.38': + '@yuku-parser/binding-darwin-x64@0.5.39': optional: true - '@yuku-parser/binding-freebsd-x64@0.5.38': + '@yuku-parser/binding-freebsd-x64@0.5.39': optional: true - '@yuku-parser/binding-linux-arm-gnu@0.5.38': + '@yuku-parser/binding-linux-arm-gnu@0.5.39': optional: true - '@yuku-parser/binding-linux-arm-musl@0.5.38': + '@yuku-parser/binding-linux-arm-musl@0.5.39': optional: true - '@yuku-parser/binding-linux-arm64-gnu@0.5.38': + '@yuku-parser/binding-linux-arm64-gnu@0.5.39': optional: true - '@yuku-parser/binding-linux-arm64-musl@0.5.38': + '@yuku-parser/binding-linux-arm64-musl@0.5.39': optional: true - '@yuku-parser/binding-linux-x64-gnu@0.5.38': + '@yuku-parser/binding-linux-x64-gnu@0.5.39': optional: true - '@yuku-parser/binding-linux-x64-musl@0.5.38': + '@yuku-parser/binding-linux-x64-musl@0.5.39': optional: true - '@yuku-parser/binding-win32-arm64@0.5.38': + '@yuku-parser/binding-win32-arm64@0.5.39': optional: true - '@yuku-parser/binding-win32-x64@0.5.38': + '@yuku-parser/binding-win32-x64@0.5.39': optional: true '@yuku-toolchain/types@0.5.37': {} @@ -13529,6 +13632,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.17.0: {} + address@2.0.3: {} adm-zip@0.5.10: {} @@ -13545,9 +13650,9 @@ snapshots: optionalDependencies: ajv: 8.17.1 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: - ajv: 6.12.6 + ajv: 6.15.0 ajv-keywords@5.1.0(ajv@8.17.1): dependencies: @@ -13561,6 +13666,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.12.0: dependencies: fast-deep-equal: 3.1.3 @@ -13717,9 +13829,9 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 - resolve: 1.22.11 + resolve: 1.22.12 optional: true balanced-match@1.0.2: {} @@ -13734,6 +13846,8 @@ snapshots: base64id@2.0.0: {} + baseline-browser-mapping@2.10.38: {} + baseline-browser-mapping@2.9.18: {} basic-auth@2.0.1: @@ -13852,6 +13966,14 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} buffer@5.7.1: @@ -13895,6 +14017,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + caniuse-lite@1.0.30001799: {} + chai@6.2.2: optional: true @@ -14082,7 +14206,7 @@ snapshots: import-fresh: 3.3.1 parse-json: 5.2.0 path-type: 4.0.0 - yaml: 1.10.2 + yaml: 1.10.3 optional: true cosmiconfig@8.3.6(typescript@5.9.3): @@ -14335,6 +14459,8 @@ snapshots: electron-to-chromium@1.5.279: {} + electron-to-chromium@1.5.376: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -14376,6 +14502,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -14581,8 +14712,8 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + is-core-module: 2.16.2 + resolve: 1.22.12 transitivePeerDependencies: - supports-color optional: true @@ -14730,7 +14861,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optional: true esutils@2.0.3: {} @@ -14891,6 +15022,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + optional: true + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -15166,6 +15302,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + he@1.2.0: {} headers-polyfill@4.0.3: {} @@ -15340,6 +15481,11 @@ snapshots: dependencies: hasown: 2.0.2 + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + optional: true + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -15505,7 +15651,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.0.10 + '@types/node': 25.9.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -15618,12 +15764,12 @@ snapshots: leac@0.6.0: {} - less-loader@12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(esbuild@0.27.2)): + less-loader@12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)): dependencies: less: 4.6.6 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) - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) less@4.6.6: dependencies: @@ -15707,7 +15853,7 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 - loader-runner@4.3.1: {} + loader-runner@4.3.2: {} loader-utils@2.0.4: dependencies: @@ -15958,6 +16104,8 @@ snapshots: node-releases@2.0.27: {} + node-releases@2.0.48: {} + node-schedule@2.1.1: dependencies: cron-parser: 4.9.0 @@ -16043,7 +16191,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obug@2.1.1: + obug@2.1.3: optional: true on-finished@2.3.0: @@ -16226,12 +16374,17 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: + optional: true + pidtree@0.3.1: {} pify@3.0.0: {} pify@4.0.1: {} + pkg-pr-new@0.0.75: {} + pkg-types@2.3.0: dependencies: confbox: 0.2.2 @@ -16390,10 +16543,6 @@ snapshots: discontinuous-range: 1.0.0 ret: 0.1.15 - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.0: {} range-parser@1.2.1: {} @@ -16472,7 +16621,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 - react-router-devtools@6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + react-router-devtools@6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -16482,7 +16631,7 @@ snapshots: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/devtools-client': 0.0.5 '@tanstack/devtools-event-client': 0.4.0 - '@tanstack/devtools-vite': 0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@tanstack/devtools-vite': 0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@tanstack/react-devtools': 0.9.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -16496,7 +16645,7 @@ snapshots: react-hotkeys-hook: 5.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-tooltip: 5.30.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.13 '@rollup/rollup-darwin-arm64': 4.57.0 @@ -16675,6 +16824,14 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + optional: true + resolve@1.22.8: dependencies: is-core-module: 2.16.1 @@ -16893,8 +17050,8 @@ snapshots: schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) schema-utils@4.3.0: dependencies: @@ -16958,15 +17115,11 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - - seroval-plugins@1.5.0(seroval@1.5.0): + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: - seroval: 1.5.0 + seroval: 1.5.4 - seroval@1.5.0: {} + seroval@1.5.4: {} serve-handler@6.1.6: dependencies: @@ -17171,8 +17324,8 @@ snapshots: solid-js@1.9.11: dependencies: csstype: 3.2.3 - seroval: 1.5.0 - seroval-plugins: 1.5.0(seroval@1.5.0) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -17243,10 +17396,10 @@ snapshots: strict-event-emitter@0.5.1: {} - string-replace-loader@3.3.0(webpack@5.97.1(esbuild@0.27.2)): + string-replace-loader@3.3.0(webpack@5.97.1(lightningcss@1.30.2)): dependencies: schema-utils: 4.3.3 - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) string-width@4.2.3: dependencies: @@ -17400,21 +17553,33 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.97.1(esbuild@0.27.2)): + terser-webpack-plugin@5.6.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.46.0 - webpack: 5.97.1(esbuild@0.27.2) + terser: 5.48.0 + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) optionalDependencies: esbuild: 0.27.2 + lightningcss: 1.30.2 + postcss: 8.5.15 + optional: true - terser@5.46.0: + terser-webpack-plugin@5.6.1(lightningcss@1.30.2)(webpack@5.97.1(lightningcss@1.30.2)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.97.1(lightningcss@1.30.2) + optionalDependencies: + lightningcss: 1.30.2 + + terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -17423,7 +17588,7 @@ snapshots: tinybench@2.9.0: optional: true - tinyexec@1.0.2: + tinyexec@1.2.4: optional: true tinyglobby@0.2.15: @@ -17431,9 +17596,15 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + optional: true + tinypool@1.1.1: {} - tinyrainbow@3.0.3: + tinyrainbow@3.1.0: optional: true tldts-core@7.0.19: {} @@ -17567,6 +17738,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.24.6: {} + undici@7.18.2: {} undici@7.24.7: {} @@ -17624,6 +17797,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + update-check@1.5.4: dependencies: registry-auth-token: 3.3.2 @@ -17667,7 +17846,7 @@ snapshots: vary@1.1.2: {} - vite-env-only@3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + vite-env-only@3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -17676,17 +17855,17 @@ snapshots: '@babel/types': 7.28.6 babel-dead-code-elimination: 1.0.12 micromatch: 4.0.8 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - vite-node@3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite-node@3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -17701,13 +17880,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite-node@3.2.4(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -17722,17 +17901,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -17748,11 +17927,11 @@ snapshots: lightningcss: 1.30.2 sass: 1.100.0 sass-embedded: 1.100.0 - terser: 5.46.0 + terser: 5.48.0 tsx: 4.21.0 yaml: 2.7.0 - vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -17761,21 +17940,21 @@ snapshots: rollup: 4.57.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.1.0 + '@types/node': 25.9.4 fsevents: 2.3.3 jiti: 2.6.1 less: 4.6.6 lightningcss: 1.30.2 sass: 1.100.0 sass-embedded: 1.100.0 - terser: 5.46.0 + terser: 5.48.0 tsx: 4.21.0 yaml: 2.7.0 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17784,15 +17963,15 @@ snapshots: es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17820,9 +17999,8 @@ snapshots: dependencies: loose-envify: 1.4.0 - watchpack@2.5.1: + watchpack@2.5.2: dependencies: - glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 wcwidth@1.0.1: @@ -17835,36 +18013,87 @@ snapshots: webpack-sources@3.3.3: {} + webpack-sources@3.5.0: {} + webpack-virtual-modules@0.5.0: {} - webpack@5.97.1(esbuild@0.27.2): + webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15): dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - browserslist: 4.28.1 + acorn: 8.17.0 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.24.0 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 + loader-runner: 4.3.2 mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.3 - terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.97.1(esbuild@0.27.2)) - watchpack: 2.5.1 - webpack-sources: 3.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + optional: true + + webpack@5.97.1(lightningcss@1.30.2): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(lightningcss@1.30.2)(webpack@5.97.1(lightningcss@1.30.2)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 transitivePeerDependencies: + - '@minify-html/node' - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso - esbuild + - html-minifier-terser + - lightningcss + - postcss - uglify-js whatwg-mimetype@4.0.0: {} @@ -18009,7 +18238,7 @@ snapshots: yallist@3.1.1: {} - yaml@1.10.2: + yaml@1.10.3: optional: true yaml@2.7.0: @@ -18065,52 +18294,52 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - yuku-analyzer@0.5.38: + yuku-analyzer@0.5.39: 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: + '@yuku-analyzer/binding-darwin-arm64': 0.5.39 + '@yuku-analyzer/binding-darwin-x64': 0.5.39 + '@yuku-analyzer/binding-freebsd-x64': 0.5.39 + '@yuku-analyzer/binding-linux-arm-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-arm-musl': 0.5.39 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-arm64-musl': 0.5.39 + '@yuku-analyzer/binding-linux-x64-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-x64-musl': 0.5.39 + '@yuku-analyzer/binding-win32-arm64': 0.5.39 + '@yuku-analyzer/binding-win32-x64': 0.5.39 + + yuku-codegen@0.5.39: 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: + '@yuku-codegen/binding-darwin-arm64': 0.5.39 + '@yuku-codegen/binding-darwin-x64': 0.5.39 + '@yuku-codegen/binding-freebsd-x64': 0.5.39 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.39 + '@yuku-codegen/binding-linux-arm-musl': 0.5.39 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.39 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.39 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.39 + '@yuku-codegen/binding-linux-x64-musl': 0.5.39 + '@yuku-codegen/binding-win32-arm64': 0.5.39 + '@yuku-codegen/binding-win32-x64': 0.5.39 + + yuku-parser@0.5.39: 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 + '@yuku-parser/binding-darwin-arm64': 0.5.39 + '@yuku-parser/binding-darwin-x64': 0.5.39 + '@yuku-parser/binding-freebsd-x64': 0.5.39 + '@yuku-parser/binding-linux-arm-gnu': 0.5.39 + '@yuku-parser/binding-linux-arm-musl': 0.5.39 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.39 + '@yuku-parser/binding-linux-arm64-musl': 0.5.39 + '@yuku-parser/binding-linux-x64-gnu': 0.5.39 + '@yuku-parser/binding-linux-x64-musl': 0.5.39 + '@yuku-parser/binding-win32-arm64': 0.5.39 + '@yuku-parser/binding-win32-x64': 0.5.39 zod@3.25.76: {} diff --git a/src/babel.ts b/src/babel.ts index 72b11cc..d14a254 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -14,7 +14,6 @@ export const parse = ( const result = yukuParse(code, { sourceType: options.sourceType ?? 'module', lang: options.lang ?? 'tsx', - preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/src/export-utils.ts b/src/export-utils.ts index 3bdfcb6..7dac8e9 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -78,7 +78,6 @@ const parseProgram = (code: string, resourcePath?: string) => { const result = parse(code, { sourceType: 'module', lang: resourcePath ? langFromPath(resourcePath) : 'tsx', - preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/src/route-chunks.ts b/src/route-chunks.ts index ef7eee9..59f1b4c 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -126,7 +126,6 @@ const analyzeCode = ( const module = analyzer.addFile(cacheKey, code, { lang: 'tsx', sourceType: 'module', - preserveParens: true, }); const errors = module.diagnostics.filter( diagnostic => diagnostic.severity === 'error' From eae79ba804eb53ab9559b17fddbc50bb78ca077d Mon Sep 17 00:00:00 2001 From: Matthew Davis Date: Mon, 22 Jun 2026 12:02:51 -0400 Subject: [PATCH 30/64] fix: harden route transforms and dev lifecycle --- README.md | 28 +- .../manifest-performance-methodology.md | 10 +- examples/default-template/app/dev-routes.ts | 5 + examples/default-template/app/routes.ts | 3 + .../default-template/playwright.config.ts | 4 +- .../tests/e2e/dev-route-watch.test.ts | 102 +- rslib.config.ts | 32 +- src/export-utils.ts | 158 +-- src/index.ts | 105 +- src/manifest.ts | 28 +- src/modify-browser-manifest.ts | 30 +- src/parallel-route-transform-protocol.ts | 35 + src/parallel-route-transform-worker.ts | 34 +- src/parallel-route-transforms.ts | 185 ++- src/performance.ts | 16 +- src/plugin-utils.ts | 1000 +---------------- src/prerender.ts | 7 +- src/route-chunks.ts | 5 +- src/route-component-transform.ts | 429 +++++++ src/route-export-pruning.ts | 704 ++++++++++++ src/route-transform-tasks.ts | 7 +- src/route-watch.ts | 109 +- src/types.ts | 11 +- src/{babel.ts => yuku.ts} | 12 +- tests/export-utils.test.ts | 139 +-- tests/index.test.ts | 51 +- tests/manifest.test.ts | 29 +- tests/modify-browser-manifest.test.ts | 140 ++- tests/parallel-route-transforms.test.ts | 248 ++-- tests/plugin-utils.test.ts | 54 +- tests/prerender.test.ts | 10 +- tests/remove-exports.test.ts | 27 +- tests/route-artifacts.test.ts | 95 +- tests/route-watch.test.ts | 96 +- 34 files changed, 2011 insertions(+), 1937 deletions(-) create mode 100644 examples/default-template/app/dev-routes.ts create mode 100644 src/parallel-route-transform-protocol.ts create mode 100644 src/route-component-transform.ts create mode 100644 src/route-export-pruning.ts rename src/{babel.ts => yuku.ts} (82%) diff --git a/README.md b/README.md index 33b4587..482b790 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,17 @@ pluginReactRouter({ /** * Run route transforms in a worker-thread pool. * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. - * @default true, using `available CPUs - 2` workers. + * @default Automatically enabled for 256+ resolved routes. The automatic + * pool is capped at 8 workers. */ parallelTransforms?: boolean | { maxWorkers?: number }, + /** + * Route-topology notification for programmatic/custom dev servers. + * Recreate the Rsbuild server when this fires. + */ + onRouteTopologyChange?: () => void | Promise, + /** * Enable experimental support for module federation * @default false @@ -277,8 +284,8 @@ export default { } satisfies Config; ``` -For large sites, prerendering defaults to `availableParallelism - 2` concurrent -paths. You can tune prerender concurrency: +Prerendering defaults to one path at a time, matching React Router. You can opt +into concurrent prerendering for large sites: ```ts export default { @@ -302,7 +309,7 @@ If no configuration is provided, the following defaults will be used: federation: false, lazyCompilation: false, logPerformance: false, - parallelTransforms: true // adaptive worker pool + parallelTransforms: undefined // adaptive: workers for 256+ resolved routes } // Router defaults (react-router.config.ts) @@ -314,9 +321,10 @@ If no configuration is provided, the following defaults will be used: } ``` -`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. +Route transforms run inline for fewer than 256 resolved routes and use worker +threads for larger route graphs. The automatic worker count is capped at 8. +Pass `true` to force workers, `{ maxWorkers }` (up to 32) to override that +count, or `false` to force inline transforms. For builds with 256+ routes, detailed file-size reporting is compacted to totals by default to avoid gzipping and printing thousands of assets. Set @@ -437,6 +445,12 @@ export default defineConfig(() => { }); ``` +If the server is created programmatically with `createDevServer()`, pass +`onRouteTopologyChange` and use it to recreate that server. Rsbuild's +`reload-server` watcher is owned by the CLI and is not installed by the +programmatic API. The callback is a notification and is not awaited, so it can +safely close the current server as part of the replacement. + When using a custom server, you'll need to: 1. Create a server handler (`server/index.ts`): diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md index 6535646..3ce1f6f 100644 --- a/benchmarks/manifest-performance-methodology.md +++ b/benchmarks/manifest-performance-methodology.md @@ -50,15 +50,17 @@ 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; +4. keeps plugin instrumentation disabled for canonical end-to-end A/B runs; + pass `--log-performance` for a separate diagnostic run that emits structured + `[react-router:performance]` 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 +Use end-to-end wall time, process CPU, and RSS as the primary comparison +signals. Plugin `logPerformance` reports are diagnostic because their timers +include queueing and add observer overhead. 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 diff --git a/examples/default-template/app/dev-routes.ts b/examples/default-template/app/dev-routes.ts new file mode 100644 index 0000000..42c5f72 --- /dev/null +++ b/examples/default-template/app/dev-routes.ts @@ -0,0 +1,5 @@ +import type { RouteConfig } from '@react-router/dev/routes'; + +// Kept separate so the dev-route-watch E2E covers route-config dependencies, +// not the direct reload-server watch on app/routes.ts. +export default [] satisfies RouteConfig; diff --git a/examples/default-template/app/routes.ts b/examples/default-template/app/routes.ts index 7ee17d8..463e452 100644 --- a/examples/default-template/app/routes.ts +++ b/examples/default-template/app/routes.ts @@ -5,6 +5,7 @@ import { prefix, route, } from '@react-router/dev/routes'; +import devRoutes from './dev-routes'; export default [ // Index route for the home page @@ -19,6 +20,8 @@ export default [ // Client loader/action example route('client-features', 'routes/client-features.tsx'), + ...devRoutes, + // Docs section with nested routes ...prefix('docs', [ layout('routes/docs/layout.tsx', [ diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts index d3c3690..1029bdc 100644 --- a/examples/default-template/playwright.config.ts +++ b/examples/default-template/playwright.config.ts @@ -5,9 +5,9 @@ export default defineConfig({ // Maximum time one test can run for timeout: 30 * 1000, expect: { - timeout: 5000 + timeout: 5000, }, - // Keep this example serial because dev-route-watch mutates routes.ts and + // Keep this example serial because dev-route-watch mutates route config and // restarts the shared dev server. fullyParallel: false, workers: 1, diff --git a/examples/default-template/tests/e2e/dev-route-watch.test.ts b/examples/default-template/tests/e2e/dev-route-watch.test.ts index 32035c7..56dceef 100644 --- a/examples/default-template/tests/e2e/dev-route-watch.test.ts +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -1,5 +1,11 @@ import { expect, test, type Page } from '@playwright/test'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,20 +15,28 @@ const restartMarkerPath = join( __dirname, '../../build/client/.react-router/route-watch' ); -const routesConfigPath = join(appDirectory, 'routes.ts'); +const devRoutesConfigPath = join(appDirectory, 'dev-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 emptyDevRoutesConfig = `import type { RouteConfig } from '@react-router/dev/routes'; + +// Kept separate so the dev-route-watch E2E covers route-config dependencies, +// not the direct reload-server watch on app/routes.ts. +export default [] satisfies RouteConfig; +`; +const populatedDevRoutesConfig = `import { route, type RouteConfig } from '@react-router/dev/routes'; + +export default [ + route('dev-added-route', 'routes/dev-added-route.tsx'), +] satisfies RouteConfig; +`; const removeAddedRouteConfig = (): boolean => { - const routesConfig = readFileSync(routesConfigPath, 'utf8'); - if (routesConfig.includes(addedRouteConfigEntry)) { - writeFileSync( - routesConfigPath, - routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '') - ); + const routesConfig = readFileSync(devRoutesConfigPath, 'utf8'); + if (routesConfig !== emptyDevRoutesConfig) { + writeFileSync(devRoutesConfigPath, emptyDevRoutesConfig); return true; } return false; @@ -36,10 +50,20 @@ const removeAddedRouteFile = (): boolean => { return false; }; -const readRestartMarker = (): string | null => - existsSync(restartMarkerPath) - ? readFileSync(restartMarkerPath, 'utf8') - : null; +const readRestartMarkerVersion = (): string | null => { + try { + if (!existsSync(restartMarkerPath)) { + return null; + } + const { mtimeNs } = statSync(restartMarkerPath, { bigint: true }); + return `${readFileSync(restartMarkerPath, 'utf8')}:${mtimeNs}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +}; const expectRestartMarkerStable = async ( expectedMarker: string | null, @@ -49,7 +73,7 @@ const expectRestartMarkerStable = async ( await expect .poll( () => { - const marker = readRestartMarker(); + const marker = readRestartMarkerVersion(); if (marker !== expectedMarker) { return `changed:${marker ?? 'missing'}`; } @@ -60,11 +84,7 @@ const expectRestartMarkerStable = async ( .toBe('stable'); }; -const waitForRouteText = async ( - page: Page, - url: string, - text: string -) => { +const waitForRouteText = async (page: Page, url: string, text: string) => { await expect .poll( async () => { @@ -86,25 +106,37 @@ const waitForRouteText = async ( .toBe('ready'); }; +const waitForRouteToBeRemoved = async (page: Page, url: string) => { + await expect + .poll( + async () => { + try { + const response = await page.request.get(url, { timeout: 2000 }); + return response.status() === 404 ? 'removed' : response.status(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + { timeout: 60000 } + ) + .toBe('removed'); +}; + 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'); + await waitForRouteToBeRemoved(page, addedRouteUrl); } + removeAddedRouteFile(); }); test.afterEach(async ({ page }) => { if (removeAddedRouteConfig()) { - await waitForRouteText(page, '/', 'Welcome to React Router'); - } - if (removeAddedRouteFile()) { - await waitForRouteText(page, '/', 'Welcome to React Router'); + await waitForRouteToBeRemoved(page, addedRouteUrl); } + removeAddedRouteFile(); }); test('serves a route added after the dev server starts without restarting on later edits', async ({ @@ -112,6 +144,7 @@ test.describe('dev route watch', () => { }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Welcome to React Router'); + const restartMarkerBeforeAdd = readRestartMarkerVersion(); writeFileSync( addedRoutePath, @@ -121,22 +154,17 @@ test.describe('dev route watch', () => { ` ); - const routesConfig = readFileSync(routesConfigPath, 'utf8'); - writeFileSync( - routesConfigPath, - routesConfig.replace( - ' // Docs section with nested routes', - `${addedRouteConfigEntry}\n\n // Docs section with nested routes` - ) - ); + writeFileSync(devRoutesConfigPath, populatedDevRoutesConfig); 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(); + await expect + .poll(readRestartMarkerVersion, { timeout: 10000 }) + .not.toBe(restartMarkerBeforeAdd); + const restartMarkerBefore = readRestartMarkerVersion(); writeFileSync( addedRoutePath, `export default function DevAddedRoute() { diff --git a/rslib.config.ts b/rslib.config.ts index f00f09c..566bf25 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -5,16 +5,30 @@ import { } from '@rsbuild/config/rslib.config.js'; import { defineConfig } from '@rslib/core'; 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', + lib: [ + { + ...esmConfig, + 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', + }, + }, }, - }, - lib: [esmConfig, cjsConfig], + { + ...cjsConfig, + source: { + entry: { + index: './src/index.ts', + 'templates/entry.server': './src/templates/entry.server.tsx', + 'templates/entry.client': './src/templates/entry.client.tsx', + }, + }, + }, + ], tools: { rspack: { externals: [ diff --git a/src/export-utils.ts b/src/export-utils.ts index 7dac8e9..403eb0f 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,40 +1,12 @@ 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, - type RouteChunkConfig, - type RouteChunkInfo, -} from './route-chunks.js'; - -type TransformCacheEntry = { - source: string; - transformed: 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 - ) => Promise; -}; - -type BundlerRouteAnalysisCacheEntry = { - source: string; - analysis: Promise; -}; - type RouteModuleAnalysis = { readonly code: string; readonly exports: readonly string[]; @@ -47,12 +19,7 @@ type RouteModuleAnalysisCacheEntry = { analysis: Promise; }; -const transformCache = new Map(); const exportInfoCache = new Map>(); -const bundlerRouteAnalysisCache = new Map< - string, - BundlerRouteAnalysisCacheEntry ->(); const routeModuleAnalysisCache = new Map< string, RouteModuleAnalysisCacheEntry @@ -71,9 +38,6 @@ const cachePromiseOnReject = ( throw error; }); -const getRouteChunkConfigCacheKey = (config: RouteChunkConfig) => - `${String(config.splitRouteModules ?? false)}\0${config.appDirectory}\0${config.rootRouteFile}`; - const parseProgram = (code: string, resourcePath?: string) => { const result = parse(code, { sourceType: 'module', @@ -217,126 +181,12 @@ const collectExportAllModules = (program: AnyNode): string[] => { return modules; }; -const getTransformedModule = async ( - code: string, - resourcePath: string -): Promise => { - const cached = transformCache.get(resourcePath); - if (cached?.source === code) { - return cached.transformed; - } - - let transformed: Promise; - transformed = cachePromiseOnReject( - (async () => { - 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')); - } - return { - code: stripped.code, - exportNames: collectProgramExportNames(program), - exportAllModules: collectExportAllModules(program), - }; - })(), - () => { - if (transformCache.get(resourcePath)?.transformed === transformed) { - transformCache.delete(resourcePath); - } - } - ); - - setBoundedCacheEntry( - transformCache, - resourcePath, - { - source: code, - transformed, - }, - MAX_EXPORT_UTILS_CACHE_ENTRIES - ); - return transformed; -}; - -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; }; -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 program = parseProgram(source, resourcePath); - const sourceInfo: TransformedModule = { - code: source, - exportNames: collectProgramExportNames(program), - exportAllModules: collectExportAllModules(program), - }; - const routeChunkInfoCache = new Map>(); - - return { - ...sourceInfo, - getRouteChunkInfo: ( - cache: RouteChunkCache | undefined, - config: RouteChunkConfig - ) => { - const cacheKey = getRouteChunkConfigCacheKey(config); - const cachedRouteChunkInfo = routeChunkInfoCache.get(cacheKey); - if (cachedRouteChunkInfo) { - return cachedRouteChunkInfo; - } - - let routeChunkInfo: Promise; - routeChunkInfo = cachePromiseOnReject( - detectRouteChunksIfEnabled(cache, config, resourcePath, source), - () => { - if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { - routeChunkInfoCache.delete(cacheKey); - } - } - ); - - routeChunkInfoCache.set(cacheKey, routeChunkInfo); - return routeChunkInfo; - }, - }; - })(); - - let trackedAnalysis: Promise; - trackedAnalysis = cachePromiseOnReject(analysis, () => { - if ( - bundlerRouteAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis - ) { - bundlerRouteAnalysisCache.delete(resourcePath); - } - }); - - setBoundedCacheEntry( - bundlerRouteAnalysisCache, - resourcePath, - { - source, - analysis: trackedAnalysis, - }, - MAX_EXPORT_UTILS_CACHE_ENTRIES - ); - return trackedAnalysis; -}; - export const getExportNamesAndExportAll = async ( code: string ): Promise => { @@ -380,11 +230,11 @@ export const getRouteModuleAnalysis = async ( const analysis = (async () => { const source = await readFile(resourcePath, 'utf8'); - const transformed = await getTransformedModule(source, resourcePath); + const program = parseProgram(source, resourcePath); return { - code: transformed.code, - exports: transformed.exportNames, - exportAllModules: transformed.exportAllModules, + code: source, + exports: collectProgramExportNames(program), + exportAllModules: collectExportAllModules(program), }; })(); diff --git a/src/index.ts b/src/index.ts index fcd7560..b2c79a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ 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 { ResultPromise } from 'execa'; import { rspack, type RsbuildEntryDescription, @@ -38,10 +39,11 @@ import { } from './react-router-config.js'; import { getReactRouterManifestForDev, - getRouteManifestModuleExports, + generateReactRouterManifestForDev, configRoutesToRouteManifest, createReactRouterManifestStats, type ReactRouterManifestStats, + type RouteManifestModuleExports, } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; @@ -52,11 +54,13 @@ import { type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; -import { createRouteTransformExecutor } from './parallel-route-transforms.js'; +import { + createRouteTransformExecutor, + shouldParallelizeRouteTransforms, +} from './parallel-route-transforms.js'; import { createRouteTopologyWatcher, createRouteManifestSnapshot, - emitRouteRestartMarkerAsset, ensureDevRestartMarker, getRouteRestartMarkerPath, mergeWatchFiles, @@ -167,11 +171,16 @@ export const pluginReactRouter = ( warnOnClientSourceMaps(normalized, msg => api.logger.warn(msg), 'web'); }); + let typegenProcess: ResultPromise | undefined; + // Run typegen on build/dev api.onBeforeStartDevServer(async () => { + if (typegenProcess) { + return; + } const { execa } = await import('execa'); // Run typegen in background (non-blocking) for watch mode - const child = execa( + typegenProcess = execa( 'npx', ['--yes', 'react-router', 'typegen', '--watch'], { @@ -181,11 +190,21 @@ export const pluginReactRouter = ( } ); // Don't await - let it run in the background - child.catch(() => { + typegenProcess.catch(() => { // Silently ignore errors when the process is killed on server shutdown }); }); + api.onCloseDevServer(async () => { + const process = typegenProcess; + typegenProcess = undefined; + if (!process) { + return; + } + process.kill('SIGTERM'); + await process.catch(() => undefined); + }); + api.onBeforeBuild(async () => { const { execa } = await import('execa'); // Run typegen synchronously before build @@ -369,14 +388,16 @@ export const pluginReactRouter = ( ? entryServerPath : templateServerPath; - const rootRoutePath = findEntryFile(resolve(appDirectory, 'root')); + const getRootRoutePath = () => findEntryFile(resolve(appDirectory, 'root')); + const rootRoutePath = getRootRoutePath(); // 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 latestRootRouteFile = relative(appDirectory, getRootRoutePath()); const latestRoutes = { - root: { path: '', id: 'root', file: rootRouteFile }, + root: { path: '', id: 'root', file: latestRootRouteFile }, ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), }; return createRouteManifestSnapshot(latestRoutes); @@ -419,7 +440,9 @@ export const pluginReactRouter = ( }; const routeChunkCache: RouteChunkCache = new Map(); const routeTransformExecutor = createRouteTransformExecutor({ - parallelTransforms: pluginOptions.parallelTransforms, + parallelTransforms: + pluginOptions.parallelTransforms ?? + shouldParallelizeRouteTransforms(routeCount), routeChunkCache, splitRouteModules: Boolean(splitRouteModules), }); @@ -434,6 +457,10 @@ export const pluginReactRouter = ( const watchDirectory = resolve(appDirectory); const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); const routeWatchFiles: WatchFileConfig[] = [ + { + paths: configPath, + type: 'reload-server', + }, { paths: routesPath, type: 'reload-server', @@ -443,14 +470,16 @@ export const pluginReactRouter = ( type: 'reload-server', }, ]; - let closeRouteTopologyWatcher: (() => void) | undefined; + let closeRouteTopologyWatcher: (() => Promise) | undefined; api.onBeforeStartDevServer(async () => { await ensureDevRestartMarker(routeRestartMarkerPath); closeRouteTopologyWatcher = await createRouteTopologyWatcher({ watchDirectory, getRouteTopology: getWatchedRouteTopology, + initialRouteTopology: createRouteManifestSnapshot(routes), restartMarkerPath: routeRestartMarkerPath, + onRouteTopologyChange: pluginOptions.onRouteTopologyChange, onError: error => { api.logger.warn( `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` @@ -459,8 +488,8 @@ export const pluginReactRouter = ( }); }); - api.onCloseDevServer(() => { - closeRouteTopologyWatcher?.(); + api.onCloseDevServer(async () => { + await closeRouteTopologyWatcher?.(); closeRouteTopologyWatcher = undefined; }); api.onCloseBuild(async () => { @@ -474,6 +503,7 @@ export const pluginReactRouter = ( ReturnType >; let latestBrowserManifest: ReactRouterManifest | null = null; + let latestBrowserManifestModuleExports: RouteManifestModuleExports = {}; let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; @@ -822,6 +852,7 @@ export const pluginReactRouter = ( const validateSsrFalsePrerenderExports = async ( manifest: Awaited>, + routeExports: RouteManifestModuleExports, prerenderList: string[] ) => { if (prerenderList.length === 0) { @@ -845,8 +876,6 @@ export const pluginReactRouter = ( ); } - const routeExports = getRouteManifestModuleExports(manifest); - const errors: string[] = []; for (const [routeId, route] of Object.entries(manifest.routes)) { const exports = routeExports[routeId] ?? []; @@ -952,17 +981,24 @@ export const pluginReactRouter = ( if (isPrerenderEnabled) { if (!ssr) { - const manifest = - latestBrowserManifest ?? - (await getReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - )); - await validateSsrFalsePrerenderExports(manifest, prerenderPaths); + const generated = latestBrowserManifest + ? { + manifest: latestBrowserManifest, + moduleExportsByRouteId: latestBrowserManifestModuleExports, + } + : await generateReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + ); + await validateSsrFalsePrerenderExports( + generated.manifest, + generated.moduleExportsByRouteId, + prerenderPaths + ); } const routeTree = createPrerenderRoutes(routes); @@ -1357,13 +1393,15 @@ export const pluginReactRouter = ( { future, manifestChunkNames, - onManifest: (manifest, sri) => { + onManifest: (manifest, sri, moduleExportsByRouteId) => { performanceProfiler.recordSync( 'web', 'manifest:stage', 'virtual/react-router/browser-manifest', () => { latestBrowserManifest = manifest; + latestBrowserManifestModuleExports = + moduleExportsByRouteId; const baseServerManifest = { ...manifest, sri, @@ -1403,19 +1441,6 @@ 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 }) => { @@ -1602,6 +1627,10 @@ export const pluginReactRouter = ( resource: args.resource, resourcePath: args.resourcePath, environmentName: args.environment.name, + sourceMaps: + args.environment.config.output.sourceMap === true || + (typeof args.environment.config.output.sourceMap === 'object' && + Boolean(args.environment.config.output.sourceMap.js)), ssr, isBuild, isSpaMode, diff --git a/src/manifest.ts b/src/manifest.ts index 72463c9..488e10e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -146,14 +146,10 @@ export const createReactRouterManifestStats = ( export type RouteManifestModuleExports = Record; -const routeManifestModuleExports = new WeakMap< - ReactRouterManifestForDev, - RouteManifestModuleExports ->(); - -export const getRouteManifestModuleExports = ( - manifest: ReactRouterManifestForDev -): RouteManifestModuleExports => routeManifestModuleExports.get(manifest) ?? {}; +export type ReactRouterManifestGenerationResult = { + manifest: ReactRouterManifestForDev; + moduleExportsByRouteId: RouteManifestModuleExports; +}; const DEFAULT_MANIFEST_DIR = 'static/js'; @@ -216,7 +212,7 @@ export const getReactRouterManifestChunkNames = ( return chunkNames; }; -export async function getReactRouterManifestForDev( +export async function generateReactRouterManifestForDev( routes: Record, //@ts-ignore options: PluginOptions, @@ -224,7 +220,7 @@ export async function getReactRouterManifestForDev( context: string, assetPrefix = '/', routeChunkOptions?: RouteChunkManifestOptions -): Promise { +): Promise { const result: Record = {}; const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false; const enforceSplitRouteModules = splitRouteModules === 'enforce'; @@ -395,6 +391,14 @@ export async function getReactRouterManifestForDev( routes: result, }; - routeManifestModuleExports.set(manifest, routeModuleExportsByRouteId); - return manifest; + return { + manifest, + moduleExportsByRouteId: routeModuleExportsByRouteId, + }; +} + +export async function getReactRouterManifestForDev( + ...args: Parameters +): Promise { + return (await generateReactRouterManifestForDev(...args)).manifest; } diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index b172eac..4bcb5a7 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -4,6 +4,7 @@ import { rspack } from '@rsbuild/core'; import type { Rspack } from '@rsbuild/core'; import { createReactRouterManifestStats, + generateReactRouterManifestForDev, getReactRouterManifestChunkNames, getReactRouterManifestForDev, getReactRouterManifestPath, @@ -29,7 +30,10 @@ export function createModifyBrowserManifestPlugin( manifestChunkNames?: ReadonlySet; onManifest?: ( manifest: Awaited>, - sri: Record | undefined + sri: Record | undefined, + moduleExportsByRouteId: Awaited< + ReturnType + >['moduleExportsByRouteId'] ) => void; } ) { @@ -42,21 +46,22 @@ export function createModifyBrowserManifestPlugin( return { apply(compiler: Rspack.Compiler): void { - compiler.hooks.emit.tapAsync( + compiler.hooks.emit.tapPromise( 'ModifyBrowserManifest', - async (compilation: Rspack.Compilation, callback) => { + async (compilation: Rspack.Compilation) => { const stats = createReactRouterManifestStats( compilation, manifestChunkNames ); - const manifest = await getReactRouterManifestForDev( - routes, - pluginOptions, - stats, - appDirectory, - assetPrefix, - routeChunkOptions - ); + const { manifest, moduleExportsByRouteId } = + await generateReactRouterManifestForDev( + routes, + pluginOptions, + stats, + appDirectory, + assetPrefix, + routeChunkOptions + ); const virtualManifestPath = 'static/js/virtual/react-router/browser-manifest.js'; @@ -136,8 +141,7 @@ export function createModifyBrowserManifestPlugin( } } - options?.onManifest?.(manifest, sri); - callback(); + options?.onManifest?.(manifest, sri, moduleExportsByRouteId); } ); }, diff --git a/src/parallel-route-transform-protocol.ts b/src/parallel-route-transform-protocol.ts new file mode 100644 index 0000000..2492922 --- /dev/null +++ b/src/parallel-route-transform-protocol.ts @@ -0,0 +1,35 @@ +import type { + RouteTransformResult, + RouteTransformTask, +} from './route-transform-tasks.js'; + +type WithoutRequiredSource = Task extends RouteTransformTask + ? Omit & { code?: string } + : never; + +export type CachedRouteTransformTask = + WithoutRequiredSource; + +export type WorkerRequest = { + id: number; + task: RouteTransformTask | CachedRouteTransformTask; + sourceCacheKey?: string; +}; + +export type WorkerErrorPayload = { + name?: string; + message: string; + stack?: string; +}; + +export type WorkerResponse = + | { + id: number; + ok: true; + result: RouteTransformResult; + } + | { + id: number; + ok: false; + error: WorkerErrorPayload; + }; diff --git a/src/parallel-route-transform-worker.ts b/src/parallel-route-transform-worker.ts index e9c0ad3..36e85e2 100644 --- a/src/parallel-route-transform-worker.ts +++ b/src/parallel-route-transform-worker.ts @@ -2,37 +2,13 @@ import { parentPort } from 'node:worker_threads'; import { setBoundedCacheEntry } from './bounded-cache.js'; import { executeRouteTransformTask, - type RouteTransformResult, type RouteTransformTask, } from './route-transform-tasks.js'; - -type CachedRouteTransformTask = Omit & { - code?: string; -}; - -type WorkerRequest = { - id: number; - task: RouteTransformTask | CachedRouteTransformTask; - sourceCacheKey?: string; -}; - -type WorkerErrorPayload = { - name?: string; - message: string; - stack?: string; -}; - -type WorkerResponse = - | { - id: number; - ok: true; - result: RouteTransformResult; - } - | { - id: number; - ok: false; - error: WorkerErrorPayload; - }; +import type { + WorkerErrorPayload, + WorkerRequest, + WorkerResponse, +} from './parallel-route-transform-protocol.js'; const serializeError = (error: unknown): WorkerErrorPayload => { if (error instanceof Error) { diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 6eddf6d..c33a8f7 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -1,6 +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 { executeRouteTransformTask, @@ -9,6 +8,11 @@ import { type RouteTransformTaskOptions, } from './route-transform-tasks.js'; import type { PluginOptions } from './types.js'; +import type { + WorkerErrorPayload, + WorkerRequest, + WorkerResponse, +} from './parallel-route-transform-protocol.js'; export type ParallelTransformsConfig = NonNullable extends infer Config @@ -25,32 +29,6 @@ export type RouteTransformExecutor = { close: () => Promise; }; -type WorkerResponse = - | { - id: number; - ok: true; - result: RouteTransformResult; - } - | { - id: number; - ok: false; - error: WorkerErrorPayload; - }; - -type WorkerRequest = { - id: number; - task: - | RouteTransformTask - | (Omit & { code?: string }); - sourceCacheKey?: string; -}; - -type WorkerErrorPayload = { - name?: string; - message: string; - stack?: string; -}; - type PendingTask = { resolve: (result: RouteTransformResult) => void; reject: (error: Error) => void; @@ -63,11 +41,6 @@ type WorkerState = { startupError?: WorkerStartupError; }; -type RouteModuleResultCacheEntry = { - source: string; - result: Promise; -}; - class WorkerStartupError extends Error { constructor(message: string) { super(message); @@ -76,10 +49,15 @@ class WorkerStartupError extends Error { } const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; -const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; +const AUTO_PARALLEL_ROUTE_THRESHOLD = 256; +const DEFAULT_MAX_WORKERS = 8; +const MAX_CONFIGURED_WORKERS = 32; export const getDefaultWorkerCount = (cpuCount?: number): number => - getDefaultConcurrency(cpuCount); + Math.min(DEFAULT_MAX_WORKERS, getDefaultConcurrency(cpuCount)); + +export const shouldParallelizeRouteTransforms = (routeCount: number): boolean => + routeCount >= AUTO_PARALLEL_ROUTE_THRESHOLD; const getConfiguredWorkerCount = ( parallelTransforms: ParallelTransformsConfig @@ -92,12 +70,17 @@ const getConfiguredWorkerCount = ( if (configured === undefined) { return getDefaultWorkerCount(); } - if (!Number.isFinite(configured) || configured < 1) { + if (!Number.isInteger(configured) || configured < 1) { throw new Error( - '[react-router] parallelTransforms.maxWorkers must be at least 1.' + '[react-router] parallelTransforms.maxWorkers must be a positive integer.' ); } - return Math.floor(configured); + if (configured > MAX_CONFIGURED_WORKERS) { + throw new Error( + `[react-router] parallelTransforms.maxWorkers must not exceed ${MAX_CONFIGURED_WORKERS}.` + ); + } + return configured; }; const hashString = (value: string): number => { @@ -123,31 +106,33 @@ 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; + #closePromise: Promise | undefined; + #workersDisabled = false; #nextId = 1; #nextRouteModuleWorkerIndex = 0; #nextSplitRouteAnalysisWorkerIndex = 0; - #routeModuleResultCache = new Map(); #splitRouteAnalysisWorkers = new Map(); #workers: WorkerState[]; constructor( workerCount: number, private readonly options: RouteTransformTaskOptions, - private readonly balanceRouteModuleTransforms: boolean, - private readonly shareRouteModuleBuildResults: boolean + private readonly balanceRouteModuleTransforms: boolean ) { - this.#workers = Array.from({ length: workerCount }, () => - this.#createWorkerState() - ); + this.#workers = []; + try { + for (let index = 0; index < workerCount; index += 1) { + this.#workers.push(this.#createWorkerState()); + } + } catch (error) { + for (const state of this.#workers) { + void state.worker.terminate(); + } + this.#workers = []; + throw error; + } } async run(task: RouteTransformTask): Promise { @@ -155,13 +140,6 @@ 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) { @@ -172,14 +150,14 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { } } - async close(): Promise { - if (this.#closed) { - return; + close(): Promise { + if (this.#closePromise) { + return this.#closePromise; } this.#closed = true; const workers = this.#workers; this.#workers = []; - await Promise.all( + this.#closePromise = Promise.all( workers.map(async state => { for (const pending of state.pending.values()) { pending.reject(new Error('Route transform worker closed.')); @@ -187,7 +165,25 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { state.pending.clear(); await state.worker.terminate(); }) - ); + ).then(() => undefined); + return this.#closePromise; + } + + #disableWorkers(error: WorkerStartupError): void { + if (this.#workersDisabled || this.#closed) { + return; + } + this.#workersDisabled = true; + const workers = this.#workers; + this.#workers = []; + for (const state of workers) { + state.startupError = error; + for (const pending of state.pending.values()) { + pending.reject(error); + } + state.pending.clear(); + void state.worker.terminate(); + } } #createWorkerState(): WorkerState { @@ -214,60 +210,22 @@ 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); - } - state.pending.clear(); + this.#disableWorkers(startupError); }); worker.on('exit', code => { - if (this.#closed || code === 0) { + if (this.#closed || this.#workersDisabled) { return; } const startupError = new WorkerStartupError( `Route transform worker exited with code ${code}.` ); - state.startupError = startupError; - for (const pending of state.pending.values()) { - pending.reject(startupError); - } - state.pending.clear(); + this.#disableWorkers(startupError); }); return state; } - #runCachedRouteModuleBuildTask( - task: RouteTransformTask - ): Promise { - const cacheKey = task.resourcePath; - const cached = this.#routeModuleResultCache.get(cacheKey); - if (cached?.source === task.code) { - return cached.result; - } - - 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; - }); - setBoundedCacheEntry( - this.#routeModuleResultCache, - cacheKey, - { - source: task.code, - result, - }, - MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES - ); - return result; - } - #runInWorker(task: RouteTransformTask): Promise { const workerIndex = this.#getWorkerIndex(task); const state = this.#workers[workerIndex]; @@ -287,11 +245,19 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { ); return new Promise((resolve, reject) => { state.pending.set(id, { resolve, reject }); - state.worker.postMessage({ - id, - task: requestTask, - sourceCacheKey, - } satisfies WorkerRequest); + try { + state.worker.postMessage({ + id, + task: requestTask, + sourceCacheKey, + } satisfies WorkerRequest); + } catch (error) { + state.pending.delete(id); + // The worker may not have received the source update. Force the next + // request for this module to send its full source again. + state.sourceCache.delete(sourceCacheKey); + reject(error instanceof Error ? error : new Error(String(error))); + } }); } @@ -353,7 +319,7 @@ export const createRouteTransformExecutor = ({ splitRouteModules, }: RouteTransformExecutorOptions = {}): RouteTransformExecutor => { const options = { routeChunkCache }; - const effectiveParallelTransforms = parallelTransforms ?? true; + const effectiveParallelTransforms = parallelTransforms ?? false; if (!effectiveParallelTransforms) { return { run: task => executeRouteTransformTask(task, options), @@ -372,7 +338,6 @@ export const createRouteTransformExecutor = ({ return new ParallelRouteTransformExecutor( workerCount, options, - Boolean(splitRouteModules), Boolean(splitRouteModules) ); }; diff --git a/src/performance.ts b/src/performance.ts index fb9adf6..1dedbd2 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -180,12 +180,24 @@ export const createReactRouterPerformanceProfiler = ({ return callback().then( result => { const end = performance.now(); - recordDuration(resolvedEnvironment, operation, resource, start, end); + recordDuration( + resolvedEnvironment, + operation, + resource, + start, + end + ); return result; }, error => { const end = performance.now(); - recordDuration(resolvedEnvironment, operation, resource, start, end); + recordDuration( + resolvedEnvironment, + operation, + resource, + start, + end + ); throw error; } ); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 1ba9166..5156198 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,118 +1,6 @@ import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; -import { walk, type ParseResult } from 'yuku-parser'; -import { - NAMED_COMPONENT_EXPORTS, - NAMED_COMPONENT_EXPORTS_SET, - JS_EXTENSIONS, -} from './constants.js'; - -type AnyNode = Record; - -const getProgram = (ast: ParseResult | AnyNode): AnyNode => - (ast as ParseResult).program ?? ast; - -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) - ) { - throw invalidDestructureError(element.name); - } - - if ( - element.type === 'RestElement' && - element.argument.type === 'Identifier' && - exportsToRemove.includes(element.argument.name) - ) { - throw invalidDestructureError(element.argument.name); - } - - if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { - validateDestructuredExports(element, exportsToRemove); - } - } - } - - if (id.type === 'ObjectPattern') { - for (const property of id.properties ?? []) { - if (!property) { - continue; - } - - if (property.type === 'Property') { - if ( - property.value.type === 'Identifier' && - exportsToRemove.includes(property.value.name) - ) { - throw invalidDestructureError(property.value.name); - } - - if ( - property.value.type === 'AssignmentPattern' || - property.value.type === 'ArrayPattern' || - property.value.type === 'ObjectPattern' - ) { - validateDestructuredExports(property.value, exportsToRemove); - } - } - - if ( - property.type === 'RestElement' && - property.argument.type === 'Identifier' && - exportsToRemove.includes(property.argument.name) - ) { - throw invalidDestructureError(property.argument.name); - } - } - } -} - -export function invalidDestructureError(name: string): Error { - return new Error(`Cannot remove destructured export "${name}"`); -} - -export function toFunctionExpression(decl: AnyNode): AnyNode { - return { - ...decl, - type: 'FunctionExpression', - declare: undefined, - }; -} - -export function toClassExpression(decl: AnyNode): AnyNode { - return { - ...decl, - type: 'ClassExpression', - declare: undefined, - }; -} +import { JS_EXTENSIONS } from './constants.js'; export function combineURLs(baseURL: string, relativeURL: string): string { return relativeURL @@ -185,882 +73,10 @@ 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 === '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; -}; - -const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); - -const collectReferencedNames = (node: AnyNode): Set => { - const referenced = new Set(); - walk(node as any, { - Identifier(node: AnyNode, ctx: any) { - const parent = ctx.parent as AnyNode | null; - if (!isNonReferenceIdentifier(node, parent)) { - 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, 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); - } - }, - }); - 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; -}; - -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 === 'VariableDeclaration') { - for (const declarator of statement.declarations) { - registerDeclaration( - declarator, - declarator, - getPatternIdentifierNames(declarator.id) - ); - } - continue; - } - 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 (graph.declarationsByNode.has(statement)) { - continue; - } - 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 liveDeclarations; -}; - -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) => !isRemovableDeadDeclaration(declarator) - ); - return statement.declarations.length > 0; - } - return !isRemovableDeadDeclaration(statement); - }); -}; - -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[], - exportsToRemoveSet: ReadonlySet = new Set(exportsToRemove) -): boolean => { - const program = getProgram(ast); - if (!hasRemovableExport(program, exportsToRemoveSet)) { - return 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 === 'ExportAllDeclaration') { - const exportedName = statement.exported - ? getExportedName({ exported: statement.exported }) - : null; - if (!exportedName || exportsToRemoveSet.has(exportedName)) { - exportsChanged = true; - removeFromArray(program.body, statement); - } - continue; - } - - if (statement.type === 'ExportNamedDeclaration') { - if (statement.specifiers?.length) { - statement.specifiers = statement.specifiers.filter( - (specifier: AnyNode) => { - if (specifier.type !== 'ExportSpecifier') { - return true; - } - const exportedName = getExportedName(specifier); - if (exportedName && exportsToRemoveSet.has(exportedName)) { - exportsChanged = true; - if (specifier.local?.name) { - removedExportLocalNames.add(specifier.local.name); - removedExportReferencedNames.add(specifier.local.name); - } - return false; - } - return true; - } - ); - if (statement.specifiers.length === 0 && !statement.declaration) { - removeFromArray(program.body, statement); - } - } - - const declaration = statement.declaration; - if (declaration?.type === 'VariableDeclaration') { - declaration.declarations = declaration.declarations.filter( - (declarator: AnyNode) => { - if (declarator.id.type === 'Identifier') { - if (exportsToRemoveSet.has(declarator.id.name)) { - exportsChanged = true; - removedExportLocalNames.add(declarator.id.name); - removedExportReferencedNames.add(declarator.id.name); - trackRemovedExportReferences(declarator); - return false; - } - return true; - } - - validateDestructuredExports(declarator.id, exportsToRemove); - return true; - } - ); - if (declaration.declarations.length === 0) { - removeFromArray(program.body, statement); - } - } - - if ( - (declaration?.type === 'FunctionDeclaration' || - declaration?.type === 'ClassDeclaration') && - declaration.id?.name && - exportsToRemoveSet.has(declaration.id.name) - ) { - exportsChanged = true; - removedExportLocalNames.add(declaration.id.name); - removedExportReferencedNames.add(declaration.id.name); - trackRemovedExportReferences(statement); - removeFromArray(program.body, statement); - } - } - - if ( - statement.type === 'ExportDefaultDeclaration' && - exportsToRemoveSet.has('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); - } - } - - 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' && - removedExportLocalNames.has(left.object.name) - ) { - removeFromArray(program.body, statement); - } - } - - if (exportsChanged) { - removeNewlyDeadTopLevelDeclarations( - program, - declarationGraph, - previouslyLive, - removedExportReferencedNames - ); - } - - return exportsChanged; -}; - -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); - } - } -}; - -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 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 = new Set(); - const hocs: Array<[string, string]> = []; - const componentWrapperDeclarations: AnyNode[] = []; - - function getUid(name: string) { - let uid = `_${name}`; - let index = 2; - while (usedNames.has(uid) || hasTopLevelBindingName(program, uid)) { - 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) { - const uid = getHocUid('withComponentProps'); - statement.declaration = callExpression(uid, [expr]); - } - continue; - } - - 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?.type === 'ClassDeclaration') && - declaration.id?.name && - isNamedComponentExport(declaration.id.name) - ) { - const name = declaration.id.name; - 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( - hocs.map(([name, local]) => ({ imported: name, local })), - 'virtual/react-router/with-props' - ) - ); - } -}; - -function isNamedComponentExport( - name: string -): name is (typeof NAMED_COMPONENT_EXPORTS)[number] { - return NAMED_COMPONENT_EXPORTS_SET.has(name); -} +export { + invalidDestructureError, + removeExports, + removeUnusedImports, + validateDestructuredExports, +} from './route-export-pruning.js'; +export { transformRoute } from './route-component-transform.js'; diff --git a/src/prerender.ts b/src/prerender.ts index 9fbde67..8b41ab0 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -1,4 +1,3 @@ -import { getDefaultConcurrency } from './concurrency.js'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -137,7 +136,7 @@ export const resolvePrerenderPaths = async ( export const getPrerenderConcurrency = ( prerender: PrerenderConfig, - cpuCount?: number + _cpuCount?: number ): number => { if ( typeof prerender === 'object' && @@ -149,7 +148,9 @@ export const getPrerenderConcurrency = ( return value; } } - return getDefaultConcurrency(cpuCount); + // Match React Router's default. Parallel prerendering can fan out loaders and + // external requests, so it must remain explicitly opt-in. + return 1; }; const isValidPrerenderPathsConfig = ( diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 59f1b4c..e8c47b7 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -3,7 +3,7 @@ import { type Module, type Symbol as YukuSymbol, } from 'yuku-analyzer'; -import { strip } from 'yuku-codegen'; +import { print } from 'yuku-codegen'; import { walk } from 'yuku-parser'; import { normalize, relative, resolve } from 'pathe'; @@ -126,6 +126,7 @@ const analyzeCode = ( const module = analyzer.addFile(cacheKey, code, { lang: 'tsx', sourceType: 'module', + attachComments: true, }); const errors = module.diagnostics.filter( diagnostic => diagnostic.severity === 'error' @@ -405,7 +406,7 @@ const generateCode = (program: AnyNode): string | undefined => { if (program.body.length === 0) { return undefined; } - const result = strip(program as any, { comments: 'some' }); + const result = print(program as any, { comments: true }); if (result.errors.length > 0) { throw new Error(result.errors.map(error => error.message).join('\n')); } diff --git a/src/route-component-transform.ts b/src/route-component-transform.ts new file mode 100644 index 0000000..dfdc0ad --- /dev/null +++ b/src/route-component-transform.ts @@ -0,0 +1,429 @@ +import { + NAMED_COMPONENT_EXPORTS, + NAMED_COMPONENT_EXPORTS_SET, +} from './constants.js'; +import type { ParseResult } from 'yuku-parser'; + +type AnyNode = Record; + +const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; + +const removeFromArray = (array: T[], value: T): void => { + const index = array.indexOf(value); + if (index >= 0) { + array.splice(index, 1); + } +}; + +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; +}; + +export function toFunctionExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'FunctionExpression', + declare: undefined, + }; +} + +export function toClassExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'ClassExpression', + declare: undefined, + }; +} + +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 exportSpecifier = (local: string, exported: string): AnyNode => ({ + type: 'ExportSpecifier', + start: 0, + end: 0, + local: identifier(local), + exported: identifier(exported), + exportKind: 'value', +}); + +const exportNamedDeclaration = (specifiers: AnyNode[]): AnyNode => ({ + type: 'ExportNamedDeclaration', + start: 0, + end: 0, + declaration: null, + specifiers, + source: null, + attributes: [], + exportKind: 'value', +}); + +const getModuleExportName = ( + node: AnyNode | null | undefined +): string | null => { + if (!node) { + return null; + } + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal') { + return String(node.value); + } + return null; +}; + +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 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 = new Set(); + const hocs: Array<[string, string]> = []; + const componentWrapperDeclarations: AnyNode[] = []; + const sourceReexportImports: AnyNode[] = []; + const sourceReexportDeclarations: AnyNode[] = []; + + function getUid(name: string) { + let uid = `_${name}`; + let index = 2; + while (usedNames.has(uid) || hasTopLevelBindingName(program, uid)) { + 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; + if (!declaration) { + continue; + } + const uid = getHocUid('withComponentProps'); + if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration') && + declaration.id?.name + ) { + const statementIndex = program.body.indexOf(statement); + program.body.splice(statementIndex, 0, declaration); + statement.declaration = callExpression(uid, [ + identifier(declaration.id.name), + ]); + continue; + } + const expression = + declaration.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration.type === 'ClassDeclaration' + ? toClassExpression(declaration) + : declaration; + statement.declaration = callExpression(uid, [expression]); + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + if (statement.exportKind === 'type') { + 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?.type === 'ClassDeclaration') && + declaration.id?.name && + isNamedComponentExport(declaration.id.name) + ) { + const name = declaration.id.name; + statement.declaration = wrapNamedComponentDeclaration(name, declaration); + continue; + } + + if (statement.source) { + const importSpecifiers: Array<{ local: string; imported: string }> = []; + const wrappedExportSpecifiers: AnyNode[] = []; + statement.specifiers = (statement.specifiers ?? []).filter( + (specifier: AnyNode) => { + if ( + specifier.type !== 'ExportSpecifier' || + specifier.exportKind === 'type' + ) { + return true; + } + const exportedName = getExportedName(specifier); + const importedName = getModuleExportName(specifier.local); + if ( + !exportedName || + !importedName || + !isNamedComponentExport(exportedName) + ) { + return true; + } + const sourceLocalName = getUid(`${exportedName}Source`); + const wrappedLocalName = getUid(exportedName); + const uid = getHocUid(`with${exportedName}Props`); + importSpecifiers.push({ + imported: importedName, + local: sourceLocalName, + }); + componentWrapperDeclarations.push( + variableDeclaration( + wrappedLocalName, + callExpression(uid, [identifier(sourceLocalName)]) + ) + ); + wrappedExportSpecifiers.push( + exportSpecifier(wrappedLocalName, exportedName) + ); + return false; + } + ); + if (importSpecifiers.length > 0) { + sourceReexportImports.push( + importDeclaration(importSpecifiers, String(statement.source.value)) + ); + sourceReexportDeclarations.push( + exportNamedDeclaration(wrappedExportSpecifiers) + ); + if (statement.specifiers.length === 0) { + removeFromArray(program.body, statement); + } + } + 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.unshift(...sourceReexportImports); + program.body.push( + ...componentWrapperDeclarations, + ...sourceReexportDeclarations + ); + + if (hocs.length > 0) { + program.body.unshift( + importDeclaration( + hocs.map(([name, local]) => ({ imported: name, local })), + 'virtual/react-router/with-props' + ) + ); + } +}; + +function isNamedComponentExport( + name: string +): name is (typeof NAMED_COMPONENT_EXPORTS)[number] { + return NAMED_COMPONENT_EXPORTS_SET.has(name); +} diff --git a/src/route-export-pruning.ts b/src/route-export-pruning.ts new file mode 100644 index 0000000..72fd89a --- /dev/null +++ b/src/route-export-pruning.ts @@ -0,0 +1,704 @@ +import { walk, type ParseResult } from 'yuku-parser'; + +type AnyNode = Record; + +const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; + +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) + ) { + throw invalidDestructureError(element.name); + } + + if ( + element.type === 'RestElement' && + element.argument.type === 'Identifier' && + exportsToRemove.includes(element.argument.name) + ) { + throw invalidDestructureError(element.argument.name); + } + + if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { + validateDestructuredExports(element, exportsToRemove); + } + } + } + + if (id.type === 'ObjectPattern') { + for (const property of id.properties ?? []) { + if (!property) { + continue; + } + + if (property.type === 'Property') { + if ( + property.value.type === 'Identifier' && + exportsToRemove.includes(property.value.name) + ) { + throw invalidDestructureError(property.value.name); + } + + if ( + property.value.type === 'AssignmentPattern' || + property.value.type === 'ArrayPattern' || + property.value.type === 'ObjectPattern' + ) { + validateDestructuredExports(property.value, exportsToRemove); + } + } + + if ( + property.type === 'RestElement' && + property.argument.type === 'Identifier' && + exportsToRemove.includes(property.argument.name) + ) { + throw invalidDestructureError(property.argument.name); + } + } + } +} + +export function invalidDestructureError(name: string): Error { + return new Error(`Cannot remove destructured export "${name}"`); +} + +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 === '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; +}; + +const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); + +const collectReferencedNames = (node: AnyNode): Set => { + const referenced = new Set(); + walk(node as any, { + Identifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!isNonReferenceIdentifier(node, parent)) { + referenced.add(node.name); + } + }, + JSXIdentifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!parent) { + return; + } + if (parent.type === 'JSXMemberExpression' && parent.object === node) { + referenced.add(node.name); + return; + } + if (!isUppercaseName(node.name)) { + return; + } + if ( + (parent.type === 'JSXOpeningElement' || + parent.type === 'JSXClosingElement') && + parent.name === node + ) { + referenced.add(node.name); + return; + } + }, + 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); + } + }, + }); + 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; +}; + +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 === 'VariableDeclaration') { + for (const declarator of statement.declarations) { + registerDeclaration( + declarator, + declarator, + getPatternIdentifierNames(declarator.id) + ); + } + continue; + } + 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 (graph.declarationsByNode.has(statement)) { + continue; + } + 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 liveDeclarations; +}; + +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) => !isRemovableDeadDeclaration(declarator) + ); + return statement.declarations.length > 0; + } + return !isRemovableDeadDeclaration(statement); + }); +}; + +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[], + exportsToRemoveSet: ReadonlySet = new Set(exportsToRemove) +): boolean => { + const program = getProgram(ast); + if (!hasRemovableExport(program, exportsToRemoveSet)) { + return 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 === 'ExportAllDeclaration') { + const exportedName = statement.exported + ? getExportedName({ exported: statement.exported }) + : null; + if (exportedName && exportsToRemoveSet.has(exportedName)) { + exportsChanged = true; + removeFromArray(program.body, statement); + } + continue; + } + + if (statement.type === 'ExportNamedDeclaration') { + if (statement.specifiers?.length) { + statement.specifiers = statement.specifiers.filter( + (specifier: AnyNode) => { + if (specifier.type !== 'ExportSpecifier') { + return true; + } + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemoveSet.has(exportedName)) { + exportsChanged = true; + if (specifier.local?.name) { + removedExportLocalNames.add(specifier.local.name); + removedExportReferencedNames.add(specifier.local.name); + } + return false; + } + return true; + } + ); + if (statement.specifiers.length === 0 && !statement.declaration) { + removeFromArray(program.body, statement); + } + } + + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + declaration.declarations = declaration.declarations.filter( + (declarator: AnyNode) => { + if (declarator.id.type === 'Identifier') { + if (exportsToRemoveSet.has(declarator.id.name)) { + exportsChanged = true; + removedExportLocalNames.add(declarator.id.name); + removedExportReferencedNames.add(declarator.id.name); + trackRemovedExportReferences(declarator); + return false; + } + return true; + } + + validateDestructuredExports(declarator.id, exportsToRemove); + return true; + } + ); + if (declaration.declarations.length === 0) { + removeFromArray(program.body, statement); + } + } + + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemoveSet.has(declaration.id.name) + ) { + exportsChanged = true; + removedExportLocalNames.add(declaration.id.name); + removedExportReferencedNames.add(declaration.id.name); + trackRemovedExportReferences(statement); + removeFromArray(program.body, statement); + } + } + + if ( + statement.type === 'ExportDefaultDeclaration' && + exportsToRemoveSet.has('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); + } + } + + 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' && + removedExportLocalNames.has(left.object.name) + ) { + removeFromArray(program.body, statement); + } + } + + if (exportsChanged) { + removeNewlyDeadTopLevelDeclarations( + program, + declarationGraph, + previouslyLive, + removedExportReferencedNames + ); + } + + return exportsChanged; +}; + +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); + } + } +}; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index 7375304..1bb134d 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -2,7 +2,7 @@ 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'; -import { generate, parse } from './babel.js'; +import { generate, parse } from './yuku.js'; import { JS_EXTENSIONS, PLUGIN_NAME, @@ -67,6 +67,7 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & { kind: 'routeModule'; resource: string; environmentName: string; + sourceMaps: boolean; ssr: boolean; isBuild: boolean; isSpaMode: boolean; @@ -312,7 +313,9 @@ const transformRouteModule = async ( } return generate(ast, { - sourceMaps: !task.isBuild, + // Rsbuild merges this map with its downstream SWC transform. Only pay the + // code-generation cost when this environment actually emits JS maps. + sourceMaps: task.sourceMaps, filename: task.resource, sourceFileName: task.resourcePath, }); diff --git a/src/route-watch.ts b/src/route-watch.ts index d57cc81..851363d 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -1,10 +1,10 @@ -import { readFileSync, watch, type FSWatcher } from 'node:fs'; +import { watch, type FSWatcher } from 'node:fs'; import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; -import type { ProcessAssetsHandler, RsbuildConfig } from '@rsbuild/core'; +import type { 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 ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch'; const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch'; type RouteManifestSnapshotEntry = Pick< @@ -24,12 +24,21 @@ type RouteDirectoryState = { routeTopology: Set; }; -type ProcessAssetsContext = Parameters[0]; -type RouteRestartMarkerAssetOptions = Pick< - ProcessAssetsContext, - 'compilation' | 'sources' -> & { - restartMarkerPath: string; +type DirectoryWatcher = Pick; +type WatchDirectoryEntry = ( + directory: string, + onChange: () => void, + onError: (error: unknown) => void +) => DirectoryWatcher; + +const defaultWatchDirectoryEntry: WatchDirectoryEntry = ( + directory, + onChange, + onError +) => { + const watcher = watch(directory, onChange); + watcher.on('error', onError); + return watcher; }; export const mergeWatchFiles = ( @@ -48,30 +57,6 @@ export const mergeWatchFiles = ( export const getRouteRestartMarkerPath = (outputClientPath: string): string => resolve(outputClientPath, ROUTE_RESTART_MARKER_ASSET); -const readRestartMarkerContent = (restartMarkerPath: string): string => { - 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 => @@ -154,22 +139,32 @@ const readRouteDirectoryState = async ({ export const createRouteTopologyWatcher = async ({ watchDirectory, getRouteTopology, + initialRouteTopology, restartMarkerPath, onError, + onRouteTopologyChange, + watchDirectoryEntry: watchDirectoryOverride = defaultWatchDirectoryEntry, }: { watchDirectory: string; getRouteTopology: () => Promise>; + initialRouteTopology?: Set; restartMarkerPath: string; onError: (error: unknown) => void; -}): Promise<() => void> => { - let state = await readRouteDirectoryState({ + onRouteTopologyChange?: () => void | Promise; + watchDirectoryEntry?: WatchDirectoryEntry; +}): Promise<() => Promise> => { + const discoveredState = await readRouteDirectoryState({ watchDirectory, getRouteTopology, }); + let state = { + ...discoveredState, + routeTopology: initialRouteTopology ?? discoveredState.routeTopology, + }; let closed = false; let rescanTimer: ReturnType | undefined; let rescanQueue = Promise.resolve(); - const directoryWatchers = new Map(); + const directoryWatchers = new Map(); const touchRestartMarker = async (): Promise => { await mkdir(dirname(restartMarkerPath), { recursive: true }); @@ -193,10 +188,14 @@ export const createRouteTopologyWatcher = async ({ continue; } try { - const watcher = watch(directory, () => { - scheduleRescan(); + let watcher: DirectoryWatcher; + watcher = watchDirectoryOverride(directory, scheduleRescan, error => { + if (directoryWatchers.get(directory) === watcher) { + watcher.close(); + directoryWatchers.delete(directory); + } + onError(error); }); - watcher.on('error', onError); directoryWatchers.set(directory, watcher); } catch (error) { onError(error); @@ -218,10 +217,26 @@ export const createRouteTopologyWatcher = async ({ watchDirectory, getRouteTopology, }); + if (closed) { + return; + } syncDirectoryWatchers(nextState.directories); if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { + if (onRouteTopologyChange) { + // This is a notification boundary, not part of the rescan + // transaction. A custom-server callback may close this watcher while + // replacing its compiler, so awaiting it here would deadlock close(). + const notification = onRouteTopologyChange(); + state = nextState; + void Promise.resolve(notification).catch(onError); + return; + } else { + await touchRestartMarker(); + } + if (closed) { + return; + } state = nextState; - await touchRestartMarker(); return; } state = nextState; @@ -246,8 +261,15 @@ export const createRouteTopologyWatcher = async ({ }; syncDirectoryWatchers(state.directories); + if (initialRouteTopology) { + await runRescan(); + } - return () => { + return async () => { + if (closed) { + await rescanQueue; + return; + } closed = true; if (rescanTimer) { clearTimeout(rescanTimer); @@ -256,5 +278,10 @@ export const createRouteTopologyWatcher = async ({ watcher.close(); } directoryWatchers.clear(); + await rescanQueue; + for (const watcher of directoryWatchers.values()) { + watcher.close(); + } + directoryWatchers.clear(); }; }; diff --git a/src/types.ts b/src/types.ts index ba40843..e046abf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,13 +45,22 @@ 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, using `available CPUs - 2` workers. + * @default Automatically enabled for 256+ resolved routes. The automatic + * pool is capped at 8 workers. */ parallelTransforms?: | boolean | { maxWorkers?: number; }; + + /** + * Called when the route graph changes during development. + * Programmatic/custom servers can use this to recreate their Rsbuild server; + * the CLI uses its built-in reload-server watcher when this is omitted. This + * notification is not awaited, so it may safely close the current server. + */ + onRouteTopologyChange?: () => void | Promise; }; export type RouteManifestItem = Omit & { diff --git a/src/babel.ts b/src/yuku.ts similarity index 82% rename from src/babel.ts rename to src/yuku.ts index d14a254..18a9ccc 100644 --- a/src/babel.ts +++ b/src/yuku.ts @@ -5,15 +5,17 @@ import { type ParseResult, } from 'yuku-parser'; import type { Rspack } from '@rsbuild/core'; -import { strip } from 'yuku-codegen'; +import { print } from 'yuku-codegen'; export const parse = ( code: string, options: ParseOptions = {} ): ParseResult => { const result = yukuParse(code, { + ...options, sourceType: options.sourceType ?? 'module', lang: options.lang ?? 'tsx', + attachComments: options.attachComments ?? true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' @@ -35,8 +37,8 @@ export const generate = ( } = {} ): { code: string; map: Rspack.RawSourceMap | null } => { const result = 'program' in ast ? ast : { program: ast, lineStarts: [] }; - const generated = strip(result.program as Parameters[0], { - comments: 'some', + const generated = print(result.program as Parameters[0], { + comments: true, sourceMaps: options.sourceMaps ? { lineStarts: result.lineStarts, @@ -45,6 +47,9 @@ export const generate = ( } : undefined, }); + if (generated.errors.length > 0) { + throw new Error(generated.errors.map(error => error.message).join('\n')); + } const map = generated.map ? { ...generated.map, @@ -59,5 +64,4 @@ export const generate = ( return { code: generated.code, map }; }; -export const t = {}; export type { ParseResult }; diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts index 921d1d6..4dc18ce 100644 --- a/tests/export-utils.test.ts +++ b/tests/export-utils.test.ts @@ -1,64 +1,10 @@ import { describe, expect, it } from '@rstest/core'; -import { parse } from '../src/babel'; -import { - getBundlerRouteAnalysis, - getExportNamesAndExportAll, - transformToEsm, -} from '../src/export-utils'; +import { getExportNamesAndExportAll } from '../src/export-utils'; -const routeChunkConfig = { - splitRouteModules: true as const, - appDirectory: '/app', - rootRouteFile: 'root.tsx', -}; - -describe('getBundlerRouteAnalysis', () => { - 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; } - `; - 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.exportNames).toBe(first.exportNames); - expect(second.getRouteChunkInfo(undefined, routeChunkConfig)).toBe( - first.getRouteChunkInfo(undefined, routeChunkConfig) - ); - - expect(first.code).toBe(source); - expect(first.exportNames).toEqual(['clientAction', 'default']); +describe('getExportNamesAndExportAll', () => { + it('collects runtime exports and export-all modules', async () => { 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); - expect(updated.exportNames).toEqual(['clientLoader']); - }); - - it('collects runtime exports and export-all modules from the initial parse', async () => { - const analysis = await getBundlerRouteAnalysis( - ` + getExportNamesAndExportAll(` export type LoaderData = { value: string }; export interface RouteHandle { title: string } export type * from './types'; @@ -67,88 +13,41 @@ describe('getBundlerRouteAnalysis', () => { 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({ + `) + ).resolves.toEqual({ exportNames: ['helpers', 'loader', 'default'], exportAllModules: ['./shared'], }); - await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( - exportInfo - ); }); - it('collects exported TypeScript enum names as runtime exports', async () => { + it('collects exported TypeScript enums as runtime exports', async () => { await expect( - getExportNamesAndExportAll( - `export enum Status { Active = 'active' }` - ) + 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 }`, - '/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('ignores erased default interfaces', async () => { + await expect( + getExportNamesAndExportAll( + `export default interface RouteType { value: string }` + ) + ).resolves.toEqual({ exportNames: [], exportAllModules: [] }); }); - it('does not report erased ambient declarations as runtime exports', async () => { - const analysis = await getBundlerRouteAnalysis( - ` + it('ignores erased ambient declarations', async () => { + await expect( + getExportNamesAndExportAll(` 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({ + `) + ).resolves.toEqual({ exportNames: ['clientLoader'], exportAllModules: [], }); - await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( - exportInfo - ); - }); -}); - -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(); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 85db3fe..97182c7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -40,6 +40,12 @@ describe('pluginReactRouter', () => { paths: 'custom.config.ts', type: 'reload-server', }, + { + paths: expect.stringMatching( + /react-router\.config\.[cm]?[jt]sx?$/ + ), + type: 'reload-server', + }, { paths: expect.stringMatching(/app\/routes\.[cm]?[jt]sx?$/), type: 'reload-server', @@ -54,51 +60,6 @@ describe('pluginReactRouter', () => { ); }); - 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/manifest.test.ts b/tests/manifest.test.ts index 2d77c1e..301ff33 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -5,9 +5,9 @@ import { describe, expect, it } from '@rstest/core'; import { createReactRouterManifestStats, configRoutesToRouteManifest, + generateReactRouterManifestForDev, getReactRouterManifestForDev, getReactRouterManifestChunkNames, - getRouteManifestModuleExports, } from '../src/manifest'; const createTempApp = (routeCode: string) => { @@ -332,25 +332,26 @@ describe('manifest', () => { export default function Page() { return null; } `); try { - const manifest = await getReactRouterManifestForDev( - routes, - {}, - clientStats, - appDir, - '/', - { - isBuild: true, - rootRouteFile: 'root.tsx', - splitRouteModules: false, - } - ); + const { manifest, moduleExportsByRouteId } = + await generateReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: true, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ); const routeManifest = manifest.routes['routes/page']; expect(routeManifest).toMatchObject({ hasAction: true, hasLoader: true, }); - expect(getRouteManifestModuleExports(manifest)['routes/page']).toEqual( + expect(moduleExportsByRouteId['routes/page']).toEqual( expect.arrayContaining(['headers', 'action', 'loader', 'default']) ); expect(routeManifest).not.toHaveProperty('headers'); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 4141abf..34f278e 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -25,6 +25,46 @@ const createAsset = (source: string) => ({ }); describe('modify browser manifest plugin', () => { + it('rejects the promise hook when build route analysis fails', async () => { + const { root, appDir } = createTempApp(); + writeFileSync(join(appDir, 'routes/page.tsx'), 'export const = broken;'); + 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) => Promise) | undefined; + const compiler = { + hooks: { + emit: { + tapPromise(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + + try { + createModifyBrowserManifestPlugin(routes, {}, appDir, '/', { + isBuild: true, + }).apply(compiler as never); + + expect(emit).toBeDefined(); + await expect( + emit?.({ + namedChunks: new Map(), + assets: {}, + }) + ).rejects.toThrow(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('does not read ignored chunk files while creating manifest stats', async () => { const { root, appDir } = createTempApp(); const routes = { @@ -36,13 +76,11 @@ describe('modify browser manifest plugin', () => { path: 'page', }, }; - let emit: - | ((compilation: unknown, callback: (error?: Error) => void) => void) - | undefined; + let emit: ((compilation: unknown) => Promise) | undefined; const compiler = { hooks: { emit: { - tapAsync(_name: string, callback: typeof emit) { + tapPromise(_name: string, callback: typeof emit) { emit = callback; }, }, @@ -61,35 +99,18 @@ describe('modify browser manifest plugin', () => { }, }); - 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(); - } - ); + await 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";' + ), + }, }); } finally { rmSync(root, { recursive: true, force: true }); @@ -107,13 +128,11 @@ describe('modify browser manifest plugin', () => { path: 'page', }, }; - let emit: - | ((compilation: unknown, callback: (error?: Error) => void) => void) - | undefined; + let emit: ((compilation: unknown) => Promise) | undefined; const compiler = { hooks: { emit: { - tapAsync(_name: string, callback: typeof emit) { + tapPromise(_name: string, callback: typeof emit) { emit = callback; }, }, @@ -132,11 +151,7 @@ describe('modify browser manifest plugin', () => { isBuild: true, }, { - manifestChunkNames: new Set([ - 'entry.client', - 'root', - 'routes/page', - ]), + manifestChunkNames: new Set(['entry.client', 'root', 'routes/page']), } ).apply(compiler as never); @@ -147,35 +162,18 @@ describe('modify browser manifest plugin', () => { }, }); - 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(); - } - ); + await 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";' + ), + }, }); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 6120941..72539a5 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, rstest } from '@rstest/core'; -import * as exportUtils from '../src/export-utils'; +import { describe, expect, it } from '@rstest/core'; +import { getExportNames } from '../src/export-utils'; import { executeRouteTransformTask, type RouteModuleTransformTask, @@ -7,6 +7,7 @@ import { import { createRouteTransformExecutor, getDefaultWorkerCount, + shouldParallelizeRouteTransforms, } from '../src/parallel-route-transforms'; import type { RouteChunkConfig } from '../src/route-chunks'; @@ -34,6 +35,7 @@ const createRouteModuleTask = ( resource: `${resourcePath}?react-router-route`, resourcePath, environmentName: 'web', + sourceMaps: true, ssr: true, isBuild: false, isSpaMode: false, @@ -42,6 +44,15 @@ const createRouteModuleTask = ( }); describe('parallel route transforms', () => { + it.each([ + [48, false], + [255, false], + [256, true], + [1024, true], + ])('selects the adaptive default for %i routes', (routeCount, expected) => { + expect(shouldParallelizeRouteTransforms(routeCount)).toBe(expected); + }); + it.each([ [1, 0], [2, 0], @@ -50,12 +61,26 @@ describe('parallel route transforms', () => { [6, 4], [8, 6], [10, 8], - [12, 10], - [24, 22], - ])('defaults to cpu count minus two workers', (cpus, workers) => { + [12, 8], + [24, 8], + ])('caps the automatic worker count', (cpus, workers) => { expect(getDefaultWorkerCount(cpus)).toBe(workers); }); + it('rejects unsafe explicit worker counts', () => { + expect(() => + createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 1.5 }, + }) + ).toThrow('must be a positive integer'); + + expect(() => + createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 33 }, + }) + ).toThrow('must not exceed 32'); + }); + it('honors explicit maxWorkers', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, @@ -105,107 +130,6 @@ describe('parallel route transforms', () => { }); }); - it('does not run bundler route analysis for client entries without split route chunks', async () => { - const getBundlerRouteAnalysis = rstest.spyOn( - exportUtils, - 'getBundlerRouteAnalysis' - ); - - try { - await 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, - }); - - 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 }, @@ -221,7 +145,7 @@ describe('parallel route transforms', () => { } }); - it('shares build route module results across environments when output is identical', async () => { + it('produces identical build route modules when environments need the same output', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, splitRouteModules: true, @@ -248,12 +172,45 @@ describe('parallel route transforms', () => { } }); - it('does not share build route module results when web removes server-only exports', async () => { + it('keeps environment-specific build route module output isolated', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + splitRouteModules: true, + }); + const task = createRouteModuleTask({ + environmentName: 'node', + isBuild: true, + }); + + try { + const nodeResult = await executor.run(task); + const webResult = await executor.run({ + ...task, + environmentName: 'web', + }); + + await expect(getExportNames(nodeResult.code)).resolves.toContain( + 'loader' + ); + await expect(getExportNames(webResult.code)).resolves.not.toContain( + 'loader' + ); + } finally { + await executor.close(); + } + }); + + it('isolates escaped server exports across build environments', async () => { const executor = createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 2 }, splitRouteModules: true, }); const task = createRouteModuleTask({ + code: String.raw` + const implementation = async () => null; + export { implementation as lo\u0061der }; + export default function Route() { return null; } + `, environmentName: 'node', isBuild: true, }); @@ -272,6 +229,22 @@ describe('parallel route transforms', () => { } }); + it('preserves runtime TypeScript for the downstream Rsbuild SWC stage', async () => { + const result = await executeRouteTransformTask( + createRouteModuleTask({ + code: ` + export enum Status { Active } + export default function Route() { return Status.Active; } + `, + environmentName: 'node', + isBuild: true, + }) + ); + + expect(result.code).toContain('enum Status'); + expect(result.code).toContain('Status.Active'); + }); + it('preserves value imports when web route modules have no server-only exports', async () => { const result = await executeRouteTransformTask( createRouteModuleTask({ @@ -288,46 +261,6 @@ describe('parallel route transforms', () => { 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( @@ -343,7 +276,7 @@ describe('parallel route transforms', () => { ).rejects.toThrow('SPA Mode: 1 invalid route export'); }); - it('generates route module source maps only outside build mode', async () => { + it('generates route module source maps when the environment requests them', async () => { const task = createRouteModuleTask({ code: ` export async function loader() { return null; } @@ -351,12 +284,11 @@ describe('parallel route transforms', () => { `, }); - await expect( - executeRouteTransformTask({ - ...task, - isBuild: true, - }) - ).resolves.toMatchObject({ map: null }); + const buildResult = await executeRouteTransformTask({ + ...task, + isBuild: true, + }); + expect(buildResult.map).not.toBeNull(); const devResult = await executeRouteTransformTask({ ...task, @@ -364,5 +296,11 @@ describe('parallel route transforms', () => { }); expect(devResult.map).not.toBeNull(); + + const withoutSourceMaps = await executeRouteTransformTask({ + ...task, + sourceMaps: false, + }); + expect(withoutSourceMaps.map).toBeNull(); }); }); diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index ca85de0..6a6ea7e 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@rstest/core'; -import { generate, parse } from '../src/babel'; +import { generate, parse } from '../src/yuku'; import { combineURLs, stripFileExtension, @@ -131,13 +131,35 @@ describe('plugin-utils', () => { }); describe('transformRoute', () => { + it('preserves bundler directives while transforming routes', () => { + const result = transformRouteCode(` + export default function Route() { + return import(/* webpackChunkName: "route-data" */ './data'); + } + `); + + expect(result).toContain('webpackChunkName'); + }); + + it('preserves named default class bindings', () => { + const result = transformRouteCode(` + export default class Route {} + Route.displayName = 'Route'; + `); + + expect(result).toMatch(/class Route/); + expect(result).toMatch(/export default _withComponentProps\(Route\)/); + expect(result).toContain(`Route.displayName = 'Route'`); + }); + 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/); + expect(result).toMatch(/class Route/); + expect(result).toMatch(/export default _withComponentProps\(Route\)/); }); it('wraps named class component exports', () => { @@ -166,6 +188,30 @@ describe('plugin-utils', () => { expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); }); + it('wraps component exports re-exported from another module', () => { + const result = transformRouteCode(` + export { Boundary as ErrorBoundary } from './boundary'; + `); + + expect(result).toMatch( + /import \{ Boundary as _ErrorBoundarySource \} from ["']\.\/boundary["']/ + ); + expect(result).toMatch( + /const _ErrorBoundary = _withErrorBoundaryProps\(_ErrorBoundarySource\)/ + ); + expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); + }); + + it('does not turn type-only exports into runtime component wrappers', () => { + const result = transformRouteCode(` + type Boundary = { message: string }; + export type { Boundary as ErrorBoundary }; + `); + + expect(result).not.toContain('withErrorBoundaryProps'); + expect(result).not.toContain('const _ErrorBoundary'); + }); + it('avoids top-level generated helper name collisions', () => { const result = transformRouteCode(` const _withComponentProps = 'reserved'; @@ -185,7 +231,8 @@ describe('plugin-utils', () => { `); expect(result).toContain('withComponentProps as _withComponentProps'); - expect(result).toContain('export default _withComponentProps(function Route'); + expect(result).toContain('function Route'); + expect(result).toContain('export default _withComponentProps(Route)'); expect(result).not.toContain('_withComponentProps2'); }); @@ -200,5 +247,4 @@ describe('plugin-utils', () => { expect(result).not.toContain('_withComponentProps2'); }); }); - }); diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 2f3171a..17bc814 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from '@rstest/core'; -import { getPrerenderConcurrency, getStaticPrerenderPaths, resolvePrerenderPaths } from '../src/prerender'; +import { + getPrerenderConcurrency, + getStaticPrerenderPaths, + resolvePrerenderPaths, +} from '../src/prerender'; import type { RouteConfigEntry } from '@react-router/dev/routes'; const routes: RouteConfigEntry[] = [ @@ -87,8 +91,8 @@ describe('prerender helpers', () => { expect( getPrerenderConcurrency({ paths: ['/'], unstable_concurrency: 3 }) ).toBe(3); - expect(getPrerenderConcurrency({ paths: ['/'] }, 24)).toBe(22); + expect(getPrerenderConcurrency({ paths: ['/'] }, 24)).toBe(1); expect(getPrerenderConcurrency({ paths: ['/'] }, 3)).toBe(1); - expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(0); + expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(1); }); }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index 144a597..ecc8782 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 { generate, parse, traverse } from '../src/babel'; +import { generate, parse, traverse } from '../src/yuku'; import { removeExports, removeUnusedImports } from '../src/plugin-utils'; function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { @@ -106,7 +106,7 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); - it('removes export-all declarations when removing server-only exports', () => { + it('preserves export-all declarations that cannot be filtered safely', () => { const code = ` export * from './data.server'; export default function Route() { @@ -118,10 +118,28 @@ describe('removeExports', () => { removeExports(ast, ['loader']); const result = generate(ast).code; - expect(result).not.toContain("export * from './data.server'"); + expect(result).toContain("export * from './data.server'"); expect(result).toContain('Route'); }); + it('keeps lowercase JSX member imports after removing server exports', () => { + const code = ` + import { motion } from 'framer-motion'; + export async function loader() { return null; } + export default function Route() { + return ; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + const result = generate(ast).code; + expect(result).toContain("import { motion } from 'framer-motion'"); + expect(result).toContain(' { const code = ` import { @@ -207,8 +225,7 @@ describe('removeExports', () => { 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}()`; + const value = index === helperCount - 1 ? '1' : `helper${index + 1}()`; return `const helper${index} = () => ${value};`; }).join('\n'); const code = ` diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index 8200d6b..d9dbd0f 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it, rstest } from '@rstest/core'; -import * as exportUtils from '../src/export-utils'; +import { describe, expect, it } from '@rstest/core'; import { createRouteChunkArtifact, createRouteClientEntryArtifact, @@ -96,72 +95,56 @@ describe('route artifact helpers', () => { }); it('excludes split client exports from web build route entries', async () => { - const getBundlerRouteAnalysis = rstest.spyOn( - exportUtils, - 'getBundlerRouteAnalysis' - ); - - try { - const result = await createRouteClientEntryArtifact({ - code: ` + 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(); - } + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { default } from ${JSON.stringify(routeRequest)};`, + }); }); 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: ` + 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(); - } + resourcePath: rootResourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { HydrateFallback, clientLoader, default } from ${JSON.stringify( + `${rootResourcePath}?react-router-route` + )};`, + }); }); }); 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, - }) + createRouteChunk( + `export const clientLoader = async () => {};`, + 'clientLoader', + { + config: disabledRouteChunkConfig, + isBuild: true, + } + ) ).resolves.toEqual({ code: emptyRouteChunkSnippet('Split route modules disabled'), map: null, @@ -177,25 +160,23 @@ describe('route artifact helpers', () => { routeChunkConfig, isBuild: true, }) - ).rejects.toThrow(`Invalid route chunk name in "${resourcePath}?route-chunk=invalid"`); + ).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 () => { + it('generates the expected route chunk code from source', async () => { const source = ` export const clientAction = async () => {}; export default function Route() { return null; } `; const cache: RouteChunkCache = new Map(); - const analysis = await exportUtils.getBundlerRouteAnalysis( - source, - resourcePath - ); const expectedCode = await getRouteChunkIfEnabled( cache, routeChunkConfig, resourcePath, 'clientAction', - analysis.code + source ); const result = await createRouteChunk(source, 'clientAction', { cache }); diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts index 0784ef3..a9740e4 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -8,14 +8,108 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { createRouteManifestSnapshot, + createRouteTopologyWatcher, ensureDevRestartMarker, getRouteRestartMarkerPath, } from '../src/route-watch'; describe('route watch restart marker', () => { + it('allows a topology callback to await watcher shutdown', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + mkdirSync(watchedDirectory, { recursive: true }); + let topology = new Set(['initial']); + let triggerChange!: () => void; + let close!: () => Promise; + let callbackCompleted = false; + + try { + close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + getRouteTopology: async () => topology, + onRouteTopologyChange: async () => { + await close(); + callbackCompleted = true; + }, + onError: error => { + throw error; + }, + watchDirectoryEntry: (_directory, onChange) => { + triggerChange = onChange; + return { close: () => {} }; + }, + }); + + topology = new Set(['changed']); + triggerChange(); + + await expect.poll(() => callbackCompleted, { timeout: 2000 }).toBe(true); + } finally { + await close?.(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not recreate watchers or touch the marker after close', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + mkdirSync(watchedDirectory, { recursive: true }); + await ensureDevRestartMarker(markerPath); + const initialMarker = readFileSync(markerPath, 'utf8'); + let topologyReads = 0; + let releaseRescan!: () => void; + const rescanReleased = new Promise(resolve => { + releaseRescan = resolve; + }); + let markRescanStarted!: () => void; + const rescanStarted = new Promise(resolve => { + markRescanStarted = resolve; + }); + let triggerChange!: () => void; + const closeWatcher = rstest.fn(); + + try { + const close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + onError: error => { + throw error; + }, + getRouteTopology: async () => { + topologyReads += 1; + if (topologyReads === 1) { + return new Set(['initial']); + } + markRescanStarted(); + await rescanReleased; + return new Set(['changed']); + }, + watchDirectoryEntry: (_directory, onChange) => { + triggerChange = onChange; + return { close: closeWatcher }; + }, + }); + + triggerChange(); + await rescanStarted; + const closePromise = close(); + releaseRescan(); + await closePromise; + + expect(readFileSync(markerPath, 'utf8')).toBe(initialMarker); + expect(closeWatcher).toHaveBeenCalled(); + } finally { + releaseRescan(); + rmSync(root, { recursive: true, force: true }); + } + }); + it('places the restart marker in the client build output', () => { expect(getRouteRestartMarkerPath('/project/build/client')).toBe( '/project/build/client/.react-router/route-watch' From 82666cd7d22188713c89053b264964e0d2955849 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:35:09 +0000 Subject: [PATCH 31/64] fix: polish route transform hardening --- .../manifest-performance-methodology.md | 11 +++++- src/index.ts | 28 +++++++++++---- src/manifest.ts | 3 +- src/route-component-transform.ts | 35 +++++++----------- src/route-watch.ts | 4 +-- tests/index.test.ts | 36 +++++++++++++++++++ tests/plugin-utils.test.ts | 13 +++++++ tests/remove-exports.test.ts | 33 +++-------------- 8 files changed, 101 insertions(+), 62 deletions(-) diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md index 3ce1f6f..765eb5a 100644 --- a/benchmarks/manifest-performance-methodology.md +++ b/benchmarks/manifest-performance-methodology.md @@ -165,13 +165,22 @@ because it controls warmup, cleaning, aggregation, and output format. ## Metric checklist -### Already observable from `baseline.json` +### Canonical metrics in `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. | + +### Diagnostic metrics with `--log-performance` + +These fields are empty in canonical A/B runs because plugin instrumentation is +disabled by default. Use a separate diagnostic run when operation-level +attribution is needed. + +| Metric | Source | Why it matters | +| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | 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. | diff --git a/src/index.ts b/src/index.ts index b2c79a3..bf68f70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,11 @@ 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, + JS_EXTENSIONS, + PLUGIN_NAME, +} from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { generateWithProps, @@ -180,7 +184,7 @@ export const pluginReactRouter = ( } const { execa } = await import('execa'); // Run typegen in background (non-blocking) for watch mode - typegenProcess = execa( + const process = execa( 'npx', ['--yes', 'react-router', 'typegen', '--watch'], { @@ -189,10 +193,17 @@ export const pluginReactRouter = ( cleanup: true, } ); + typegenProcess = process; // Don't await - let it run in the background - typegenProcess.catch(() => { - // Silently ignore errors when the process is killed on server shutdown - }); + process + .catch(() => { + // Silently ignore errors when the process is killed on server shutdown + }) + .finally(() => { + if (typegenProcess === process) { + typegenProcess = undefined; + } + }); }); api.onCloseDevServer(async () => { @@ -220,6 +231,11 @@ export const pluginReactRouter = ( // Read the react-router.config file first (supports .ts, .js, .mjs, etc.) const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); + const configWatchPaths = configExists + ? configPath + : JS_EXTENSIONS.map(extension => + resolve(`react-router.config${extension}`) + ); let reactRouterUserConfig: Config = {}; if (!configExists) { console.warn( @@ -458,7 +474,7 @@ export const pluginReactRouter = ( const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); const routeWatchFiles: WatchFileConfig[] = [ { - paths: configPath, + paths: configWatchPaths, type: 'reload-server', }, { diff --git a/src/manifest.ts b/src/manifest.ts index 488e10e..808a234 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -214,8 +214,7 @@ export const getReactRouterManifestChunkNames = ( export async function generateReactRouterManifestForDev( routes: Record, - //@ts-ignore - options: PluginOptions, + _options: PluginOptions, clientStats: ReactRouterManifestStats | undefined, context: string, assetPrefix = '/', diff --git a/src/route-component-transform.ts b/src/route-component-transform.ts index dfdc0ad..b1c3d83 100644 --- a/src/route-component-transform.ts +++ b/src/route-component-transform.ts @@ -9,13 +9,6 @@ type AnyNode = Record; const getProgram = (ast: ParseResult | AnyNode): AnyNode => (ast as ParseResult).program ?? ast; -const removeFromArray = (array: T[], value: T): void => { - const index = array.indexOf(value); - if (index >= 0) { - array.splice(index, 1); - } -}; - const getExportedName = (specifier: AnyNode): string | null => { const exported = specifier.exported; if (!exported) { @@ -234,8 +227,6 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { const usedNames = new Set(); const hocs: Array<[string, string]> = []; const componentWrapperDeclarations: AnyNode[] = []; - const sourceReexportImports: AnyNode[] = []; - const sourceReexportDeclarations: AnyNode[] = []; function getUid(name: string) { let uid = `_${name}`; @@ -328,6 +319,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { if (statement.source) { const importSpecifiers: Array<{ local: string; imported: string }> = []; + const sourceWrapperDeclarations: AnyNode[] = []; const wrappedExportSpecifiers: AnyNode[] = []; statement.specifiers = (statement.specifiers ?? []).filter( (specifier: AnyNode) => { @@ -353,7 +345,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { imported: importedName, local: sourceLocalName, }); - componentWrapperDeclarations.push( + sourceWrapperDeclarations.push( variableDeclaration( wrappedLocalName, callExpression(uid, [identifier(sourceLocalName)]) @@ -366,15 +358,18 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { } ); if (importSpecifiers.length > 0) { - sourceReexportImports.push( - importDeclaration(importSpecifiers, String(statement.source.value)) - ); - sourceReexportDeclarations.push( + const statementIndex = program.body.indexOf(statement); + const replacementStatements = [ + importDeclaration(importSpecifiers, String(statement.source.value)), + ]; + if (statement.specifiers.length > 0) { + replacementStatements.push(statement); + } + replacementStatements.push(...sourceWrapperDeclarations); + replacementStatements.push( exportNamedDeclaration(wrappedExportSpecifiers) ); - if (statement.specifiers.length === 0) { - removeFromArray(program.body, statement); - } + program.body.splice(statementIndex, 1, ...replacementStatements); } continue; } @@ -406,11 +401,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { } } - program.body.unshift(...sourceReexportImports); - program.body.push( - ...componentWrapperDeclarations, - ...sourceReexportDeclarations - ); + program.body.push(...componentWrapperDeclarations); if (hocs.length > 0) { program.body.unshift( diff --git a/src/route-watch.ts b/src/route-watch.ts index 851363d..e726fec 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -79,8 +79,8 @@ export const createRouteManifestSnapshot = ( 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. + // Dev owns this watched file directly so ordinary rebuilds do not rewrite it + // and trigger reload loops. await mkdir(dirname(restartMarkerPath), { recursive: true }); try { await access(restartMarkerPath); diff --git a/tests/index.test.ts b/tests/index.test.ts index 97182c7..5dc1b3d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -60,6 +60,42 @@ describe('pluginReactRouter', () => { ); }); + it('watches all supported config filenames when the config does not exist yet', async () => { + const existsSyncMock = fs.existsSync as unknown as { + mockImplementation: (implementation: (path: unknown) => boolean) => void; + mockReturnValue: (value: boolean) => void; + }; + existsSyncMock.mockImplementation( + path => !String(path).includes('react-router.config') + ); + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + const configWatch = config.dev.watchFiles.find( + (watchFile: { paths: unknown }) => Array.isArray(watchFile.paths) + ); + + expect(configWatch).toMatchObject({ + paths: expect.arrayContaining([ + expect.stringMatching(/react-router\.config\.tsx$/), + expect.stringMatching(/react-router\.config\.ts$/), + expect.stringMatching(/react-router\.config\.jsx$/), + expect.stringMatching(/react-router\.config\.js$/), + expect.stringMatching(/react-router\.config\.mjs$/), + expect.stringMatching(/react-router\.config\.mts$/), + ]), + type: 'reload-server', + }); + } finally { + existsSyncMock.mockReturnValue(true); + } + }); + it('should respect server output format', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 6a6ea7e..673ebd7 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -202,6 +202,19 @@ describe('plugin-utils', () => { expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); }); + it('preserves side-effect import order before wrapped source re-exports', () => { + const result = transformRouteCode(` + import './setup'; + export { Boundary as ErrorBoundary } from './boundary'; + `); + + expect(result.indexOf("import './setup'")).toBeLessThan( + result.search( + /import\s*\{\s*Boundary as _ErrorBoundarySource\s*\}\s*from ['"]\.\/boundary['"]/ + ) + ); + }); + it('does not turn type-only exports into runtime component wrappers', () => { const result = transformRouteCode(` type Boundary = { message: string }; diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index ecc8782..6e690da 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -1,23 +1,7 @@ import { describe, expect, it } from '@rstest/core'; -import { generate, parse, traverse } from '../src/yuku'; +import { generate, parse } from '../src/yuku'; import { removeExports, removeUnusedImports } from '../src/plugin-utils'; -function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { - let found = false; - traverse(ast, { - ExpressionStatement(path) { - if (!path.parentPath.isProgram()) return; - const expr = path.node.expression; - if (expr.type !== 'AssignmentExpression') return; - const raw = path.toString(); - if (raw.includes(textIncludes)) { - found = true; - } - }, - }); - return found; -} - describe('removeExports', () => { it('returns false when no matching export can be removed', () => { const code = ` @@ -63,7 +47,7 @@ describe('removeExports', () => { removeExports(ast, ['loader']); // The export specifier should be gone and the assignment too. - expect(hasTopLevelAssignment(ast, 'local.hydrate')).toBe(false); + expect(generate(ast).code).not.toContain('local.hydrate'); }); it('removes top-level property assignment when default export is removed', () => { @@ -76,7 +60,7 @@ describe('removeExports', () => { const ast = parse(code, { sourceType: 'module' }); removeExports(ast, ['default']); - expect(hasTopLevelAssignment(ast, 'Root.displayName')).toBe(false); + expect(generate(ast).code).not.toContain('Root.displayName'); }); it('removes unused imports after removing server-only exports', () => { @@ -94,16 +78,7 @@ describe('removeExports', () => { removeExports(ast, ['action']); removeUnusedImports(ast); - let hasThemeImport = false; - traverse(ast, { - ImportDeclaration(path) { - if (path.node.source.value === './theme.server') { - hasThemeImport = true; - } - }, - }); - - expect(hasThemeImport).toBe(false); + expect(generate(ast).code).not.toContain('./theme.server'); }); it('preserves export-all declarations that cannot be filtered safely', () => { From 1b0bba07270ec5f4774a537c0059095a7b8cc5f2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:05:26 +0000 Subject: [PATCH 32/64] fix: honor string source map settings --- src/index.ts | 12 +++++++----- src/warnings/warn-on-client-source-maps.ts | 2 +- tests/warn-on-client-source-maps.test.ts | 10 +++++++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index bf68f70..b4f6c9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,7 +75,10 @@ import { getBuildManifest, getRoutesByServerBundleId, } from './build-manifest.js'; -import { warnOnClientSourceMaps } from './warnings/warn-on-client-source-maps.js'; +import { + isSourceMapEnabled, + warnOnClientSourceMaps, +} from './warnings/warn-on-client-source-maps.js'; import { validatePluginOrderFromConfig } from './validation/validate-plugin-order.js'; import { getSsrExternals } from './ssr-externals.js'; import { @@ -1643,10 +1646,9 @@ export const pluginReactRouter = ( resource: args.resource, resourcePath: args.resourcePath, environmentName: args.environment.name, - sourceMaps: - args.environment.config.output.sourceMap === true || - (typeof args.environment.config.output.sourceMap === 'object' && - Boolean(args.environment.config.output.sourceMap.js)), + sourceMaps: isSourceMapEnabled( + args.environment.config.output.sourceMap + ), ssr, isBuild, isSpaMode, diff --git a/src/warnings/warn-on-client-source-maps.ts b/src/warnings/warn-on-client-source-maps.ts index e595bb9..9696bf6 100644 --- a/src/warnings/warn-on-client-source-maps.ts +++ b/src/warnings/warn-on-client-source-maps.ts @@ -8,7 +8,7 @@ function isProdBuild(mode?: string): boolean { return mode === 'production' || process.env.NODE_ENV === 'production'; } -function isSourceMapEnabled(value: unknown): boolean { +export function isSourceMapEnabled(value: unknown): boolean { // Rsbuild normalizes `output.sourceMap` into either: // - boolean // - { js?: devtool; css: boolean } diff --git a/tests/warn-on-client-source-maps.test.ts b/tests/warn-on-client-source-maps.test.ts index bf385bc..d9a83a6 100644 --- a/tests/warn-on-client-source-maps.test.ts +++ b/tests/warn-on-client-source-maps.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, rstest } from '@rstest/core'; -import { warnOnClientSourceMaps } from '../src/warnings/warn-on-client-source-maps'; +import { + isSourceMapEnabled, + warnOnClientSourceMaps, +} from '../src/warnings/warn-on-client-source-maps'; describe('warnOnClientSourceMaps', () => { it('does not warn in non-production mode', () => { @@ -57,6 +60,11 @@ describe('warnOnClientSourceMaps', () => { expect(warn).toHaveBeenCalledTimes(1); }); + it('treats string output.sourceMap values as enabled', () => { + expect(isSourceMapEnabled('source-map')).toBe(true); + expect(isSourceMapEnabled('hidden-source-map')).toBe(true); + }); + it('does not warn when source maps are disabled in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( From ecb9516ba9e8826d2ef091aefd263f5625c691c6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:16:44 +0000 Subject: [PATCH 33/64] chore: add route transform hardening changeset --- .changeset/sharp-routes-heal.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/sharp-routes-heal.md diff --git a/.changeset/sharp-routes-heal.md b/.changeset/sharp-routes-heal.md new file mode 100644 index 0000000..1b44496 --- /dev/null +++ b/.changeset/sharp-routes-heal.md @@ -0,0 +1,6 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Harden route module transforms and development route watching so source maps, +server/client-only modules, and route topology restarts behave consistently. From d003508916b658d2582ed320b6dc72ab285e6408 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:54:37 +0000 Subject: [PATCH 34/64] fix: preserve route topology order --- src/route-watch.ts | 27 ++++++++++++++------------- tests/route-watch.test.ts | 34 +++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/route-watch.ts b/src/route-watch.ts index e726fec..29a49fe 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -61,19 +61,20 @@ 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, - ]) - ) + // React Router uses sibling declaration order as a match tiebreaker, so the + // snapshot must preserve route-manifest insertion order. + Object.entries(routes).map(([routeId, route], order) => + JSON.stringify([ + order, + routeId, + route.id, + route.parentId ?? null, + route.path ?? null, + route.index ?? null, + route.caseSensitive ?? null, + route.file, + ]) + ) ); export const ensureDevRestartMarker = async ( diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts index a9740e4..2315c76 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -170,27 +170,39 @@ describe('route watch topology snapshot', () => { ); }); - it('is stable for equivalent route manifests with different object insertion order', () => { + it('changes when sibling declaration order changes', () => { const first = createRouteManifestSnapshot({ root: { id: 'root', path: '', file: 'root.tsx' }, - 'routes/demo': { - id: 'routes/demo', + 'routes/a': { + id: 'routes/a', parentId: 'root', - path: 'demo', - file: 'routes/demo.tsx', + path: ':value', + file: 'routes/a.tsx', + }, + 'routes/b': { + id: 'routes/b', + parentId: 'root', + path: ':value', + file: 'routes/b.tsx', }, }); const second = createRouteManifestSnapshot({ - 'routes/demo': { - id: 'routes/demo', + root: { id: 'root', path: '', file: 'root.tsx' }, + 'routes/b': { + id: 'routes/b', parentId: 'root', - path: 'demo', - file: 'routes/demo.tsx', + path: ':value', + file: 'routes/b.tsx', + }, + 'routes/a': { + id: 'routes/a', + parentId: 'root', + path: ':value', + file: 'routes/a.tsx', }, - root: { id: 'root', path: '', file: 'root.tsx' }, }); - expect(second).toEqual(first); + expect(second).not.toEqual(first); }); }); From 66caede7655a752f112bd0743bdc27f9ad280cee Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:17:00 +0000 Subject: [PATCH 35/64] chore: add route topology order changeset --- .changeset/quiet-topology-order.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/quiet-topology-order.md diff --git a/.changeset/quiet-topology-order.md b/.changeset/quiet-topology-order.md new file mode 100644 index 0000000..e0ec1c9 --- /dev/null +++ b/.changeset/quiet-topology-order.md @@ -0,0 +1,6 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Preserve route topology declaration order during development so reordering route +entries is detected as a topology change. From 05bafb8dbd3d177ab945250f418bccea7bba8121 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:04:21 +0000 Subject: [PATCH 36/64] fix: tighten route topology watcher lifecycle --- src/index.ts | 45 ++++++++---- src/manifest.ts | 20 ++++-- src/route-watch.ts | 138 +++++++++++++++++++++--------------- tests/index.test.ts | 38 ++++++++++ tests/manifest.test.ts | 20 ++++++ tests/route-watch.test.ts | 143 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 329 insertions(+), 75 deletions(-) diff --git a/src/index.ts b/src/index.ts index b4f6c9e..57e0ec8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ import { getReactRouterManifestForDev, generateReactRouterManifestForDev, configRoutesToRouteManifest, + configRoutesToRouteManifestEntries, createReactRouterManifestStats, type ReactRouterManifestStats, type RouteManifestModuleExports, @@ -412,14 +413,21 @@ 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 createRouteTopologySnapshot = ( + latestRootRouteFile: string, + latestRouteConfig: RouteConfigEntry[] + ) => + createRouteManifestSnapshot([ + ['root', { path: '', id: 'root', file: latestRootRouteFile }], + ...configRoutesToRouteManifestEntries(appDirectory, latestRouteConfig), + ]); const getWatchedRouteTopology = async (): Promise> => { const latestRouteConfig = await loadRouteConfig(); const latestRootRouteFile = relative(appDirectory, getRootRoutePath()); - const latestRoutes = { - root: { path: '', id: 'root', file: latestRootRouteFile }, - ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), - }; - return createRouteManifestSnapshot(latestRoutes); + return createRouteTopologySnapshot( + latestRootRouteFile, + latestRouteConfig + ); }; const routes = { @@ -475,19 +483,25 @@ export const pluginReactRouter = ( const assetsBuildDirectory = relative(process.cwd(), outputClientPath); const watchDirectory = resolve(appDirectory); const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); + const routeTopologyWatchFiles: WatchFileConfig[] = + pluginOptions.onRouteTopologyChange + ? [] + : [ + { + paths: routesPath, + type: 'reload-server', + }, + { + paths: routeRestartMarkerPath, + type: 'reload-server', + }, + ]; const routeWatchFiles: WatchFileConfig[] = [ { paths: configWatchPaths, type: 'reload-server', }, - { - paths: routesPath, - type: 'reload-server', - }, - { - paths: routeRestartMarkerPath, - type: 'reload-server', - }, + ...routeTopologyWatchFiles, ]; let closeRouteTopologyWatcher: (() => Promise) | undefined; @@ -496,7 +510,10 @@ export const pluginReactRouter = ( closeRouteTopologyWatcher = await createRouteTopologyWatcher({ watchDirectory, getRouteTopology: getWatchedRouteTopology, - initialRouteTopology: createRouteManifestSnapshot(routes), + initialRouteTopology: createRouteTopologySnapshot( + rootRouteFile, + routeConfig + ), restartMarkerPath: routeRestartMarkerPath, onRouteTopologyChange: pluginOptions.onRouteTopologyChange, onError: error => { diff --git a/src/manifest.ts b/src/manifest.ts index 808a234..d8721b6 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -21,7 +21,18 @@ export function configRoutesToRouteManifest( routes: RouteConfigEntry[], rootId = 'root' ): Record { - const routeManifest: Record = {}; + return Object.fromEntries( + configRoutesToRouteManifestEntries(appDirectory, routes, rootId) + ); +} + +export function configRoutesToRouteManifestEntries( + appDirectory: string, + routes: RouteConfigEntry[], + rootId = 'root' +): Array<[string, Route]> { + const routeManifestEntries: Array<[string, Route]> = []; + const routeIds = new Set(); function walk(route: RouteConfigEntry, parentId: string) { const id = route.id || createRouteId(route.file); @@ -36,12 +47,13 @@ export function configRoutesToRouteManifest( caseSensitive: route.caseSensitive, }; - if (Object.prototype.hasOwnProperty.call(routeManifest, id)) { + if (routeIds.has(id)) { throw new Error( `Unable to define routes with duplicate route id: "${id}"` ); } - routeManifest[id] = manifestItem; + routeIds.add(id); + routeManifestEntries.push([id, manifestItem]); if (route.children) { for (const child of route.children) { @@ -54,7 +66,7 @@ export function configRoutesToRouteManifest( walk(route, rootId); } - return routeManifest; + return routeManifestEntries; } type RouteChunkManifestOptions = { diff --git a/src/route-watch.ts b/src/route-watch.ts index 29a49fe..7c6068c 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -11,6 +11,9 @@ type RouteManifestSnapshotEntry = Pick< Route, 'caseSensitive' | 'file' | 'id' | 'index' | 'parentId' | 'path' >; +type RouteManifestSnapshotEntries = + | Record + | Iterable; type WatchFilesConfig = NonNullable< NonNullable['watchFiles'] @@ -58,23 +61,25 @@ export const getRouteRestartMarkerPath = (outputClientPath: string): string => resolve(outputClientPath, ROUTE_RESTART_MARKER_ASSET); export const createRouteManifestSnapshot = ( - routes: Record + routes: RouteManifestSnapshotEntries ): Set => new Set( - // React Router uses sibling declaration order as a match tiebreaker, so the - // snapshot must preserve route-manifest insertion order. - Object.entries(routes).map(([routeId, route], order) => - JSON.stringify([ - order, - routeId, - route.id, - route.parentId ?? null, - route.path ?? null, - route.index ?? null, - route.caseSensitive ?? null, - route.file, - ]) - ) + (Symbol.iterator in routes ? Array.from(routes) : Object.entries(routes)) + // React Router uses sibling declaration order as a match tiebreaker, so + // callers that have ordered route config should pass ordered entries + // instead of a record with numeric-like keys. + .map(([routeId, route], order) => + JSON.stringify([ + order, + routeId, + route.id, + route.parentId ?? null, + route.path ?? null, + route.index ?? null, + route.caseSensitive ?? null, + route.file, + ]) + ) ); export const ensureDevRestartMarker = async ( @@ -102,13 +107,9 @@ const areSetsEqual = (left: Set, right: Set): boolean => { return true; }; -const readRouteDirectoryState = async ({ - watchDirectory, - getRouteTopology, -}: { - watchDirectory: string; - getRouteTopology: () => Promise>; -}): Promise => { +const readRouteDirectories = async ( + watchDirectory: string +): Promise> => { const directories = new Set(); const walkDirectory = async (directory: string): Promise => { @@ -131,10 +132,7 @@ const readRouteDirectoryState = async ({ }; await walkDirectory(watchDirectory); - return { - directories, - routeTopology: await getRouteTopology(), - }; + return directories; }; export const createRouteTopologyWatcher = async ({ @@ -154,10 +152,23 @@ export const createRouteTopologyWatcher = async ({ onRouteTopologyChange?: () => void | Promise; watchDirectoryEntry?: WatchDirectoryEntry; }): Promise<() => Promise> => { - const discoveredState = await readRouteDirectoryState({ - watchDirectory, - getRouteTopology, - }); + const discoveredDirectories = await readRouteDirectories(watchDirectory); + let discoveredState: RouteDirectoryState; + try { + discoveredState = { + directories: discoveredDirectories, + routeTopology: await getRouteTopology(), + }; + } catch (error) { + if (!initialRouteTopology) { + throw error; + } + onError(error); + discoveredState = { + directories: discoveredDirectories, + routeTopology: initialRouteTopology, + }; + } let state = { ...discoveredState, routeTopology: initialRouteTopology ?? discoveredState.routeTopology, @@ -209,39 +220,51 @@ export const createRouteTopologyWatcher = async ({ watchNewDirectories(nextDirectories); }; - const runRescan = async (): Promise => { + const applyNextState = async (nextState: RouteDirectoryState) => { if (closed) { return; } - try { - const nextState = await readRouteDirectoryState({ - watchDirectory, - getRouteTopology, - }); - if (closed) { + syncDirectoryWatchers(nextState.directories); + if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { + if (onRouteTopologyChange) { + // This is a notification boundary, not part of the rescan + // transaction. A custom-server callback may close this watcher while + // replacing its compiler, so awaiting it here would deadlock close(). + const notification = onRouteTopologyChange(); + state = nextState; + void Promise.resolve(notification).catch(onError); return; + } else { + await touchRestartMarker(); } - syncDirectoryWatchers(nextState.directories); - if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { - if (onRouteTopologyChange) { - // This is a notification boundary, not part of the rescan - // transaction. A custom-server callback may close this watcher while - // replacing its compiler, so awaiting it here would deadlock close(). - const notification = onRouteTopologyChange(); - state = nextState; - void Promise.resolve(notification).catch(onError); - return; - } else { - await touchRestartMarker(); - } - if (closed) { - return; - } - state = nextState; + if (closed) { return; } state = nextState; + return; + } + state = nextState; + }; + + const runRescan = async (): Promise => { + if (closed) { + return; + } + let nextDirectories: Set | undefined; + try { + nextDirectories = await readRouteDirectories(watchDirectory); + const nextState = { + directories: nextDirectories, + routeTopology: await getRouteTopology(), + }; + if (closed) { + return; + } + await applyNextState(nextState); } catch (error) { + if (nextDirectories && !closed) { + syncDirectoryWatchers(nextDirectories); + } onError(error); } }; @@ -261,9 +284,10 @@ export const createRouteTopologyWatcher = async ({ }, 100); }; - syncDirectoryWatchers(state.directories); - if (initialRouteTopology) { - await runRescan(); + try { + await applyNextState(discoveredState); + } catch (error) { + onError(error); } return async () => { diff --git a/tests/index.test.ts b/tests/index.test.ts index 5dc1b3d..d792583 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -96,6 +96,44 @@ describe('pluginReactRouter', () => { } }); + it('lets custom route topology callbacks own route restart handling', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([ + pluginReactRouter({ + onRouteTopologyChange: () => {}, + }), + ]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.watchFiles).toEqual( + expect.arrayContaining([ + { + paths: expect.stringMatching( + /react-router\.config\.[cm]?[jt]sx?$/ + ), + type: 'reload-server', + }, + ]) + ); + expect(config.dev.watchFiles).not.toEqual( + expect.arrayContaining([ + { + paths: expect.stringMatching(/app\/routes\.[cm]?[jt]sx?$/), + type: 'reload-server', + }, + { + paths: expect.stringMatching( + /build\/client\/\.react-router\/route-watch$/ + ), + type: 'reload-server', + }, + ]) + ); + }); + it('should respect server output format', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 301ff33..17b8ead 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from '@rstest/core'; import { createReactRouterManifestStats, configRoutesToRouteManifest, + configRoutesToRouteManifestEntries, generateReactRouterManifestForDev, getReactRouterManifestForDev, getReactRouterManifestChunkNames, @@ -172,6 +173,25 @@ describe('manifest', () => { expect(result['routes/home'].index).toBe(true); }); + it('preserves declaration order in route manifest entries', () => { + const routeConfig = [ + { + id: '2', + file: 'routes/two.tsx', + path: ':value', + }, + { + id: '1', + file: 'routes/one.tsx', + path: ':value', + }, + ]; + + const result = configRoutesToRouteManifestEntries('app', routeConfig); + + expect(result.map(([id]) => id)).toEqual(['2', '1']); + }); + it('should handle nested routes with parentId', () => { const routeConfig = [ { diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts index 2315c76..083c461 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -143,6 +143,101 @@ describe('route watch restart marker', () => { rmSync(root, { recursive: true, force: true }); } }); + + it('reuses discovered topology when initial topology is already current', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + mkdirSync(watchedDirectory, { recursive: true }); + let topologyReads = 0; + + try { + const close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + initialRouteTopology: new Set(['current']), + getRouteTopology: async () => { + topologyReads += 1; + return new Set(['current']); + }, + onError: error => { + throw error; + }, + watchDirectoryEntry: () => ({ close: () => {} }), + }); + await close(); + + expect(topologyReads).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('uses discovered topology to notify when initial topology is stale', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + mkdirSync(watchedDirectory, { recursive: true }); + let topologyReads = 0; + const onRouteTopologyChange = rstest.fn(); + + try { + const close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + initialRouteTopology: new Set(['stale']), + getRouteTopology: async () => { + topologyReads += 1; + return new Set(['current']); + }, + onRouteTopologyChange, + onError: error => { + throw error; + }, + watchDirectoryEntry: () => ({ close: () => {} }), + }); + await close(); + + expect(topologyReads).toBe(1); + expect(onRouteTopologyChange).toHaveBeenCalledTimes(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('retains discovered recovery directories when startup topology evaluation fails', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + const helperDirectory = join(watchedDirectory, 'helpers'); + mkdirSync(helperDirectory, { recursive: true }); + const watchedDirectories: string[] = []; + const onError = rstest.fn(); + + try { + const close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + initialRouteTopology: new Set(['last-good']), + getRouteTopology: async () => { + throw new Error('route config failed'); + }, + onError, + watchDirectoryEntry: directory => { + watchedDirectories.push(directory); + return { close: () => {} }; + }, + }); + await close(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(watchedDirectories).toEqual( + expect.arrayContaining([watchedDirectory, helperDirectory]) + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('route watch topology snapshot', () => { @@ -205,4 +300,52 @@ describe('route watch topology snapshot', () => { expect(second).not.toEqual(first); }); + + it('preserves ordered entries for numeric-like route IDs', () => { + const first = createRouteManifestSnapshot([ + ['root', { id: 'root', path: '', file: 'root.tsx' }], + [ + '2', + { + id: '2', + parentId: 'root', + path: ':value', + file: 'routes/two.tsx', + }, + ], + [ + '1', + { + id: '1', + parentId: 'root', + path: ':value', + file: 'routes/one.tsx', + }, + ], + ]); + + const second = createRouteManifestSnapshot([ + ['root', { id: 'root', path: '', file: 'root.tsx' }], + [ + '1', + { + id: '1', + parentId: 'root', + path: ':value', + file: 'routes/one.tsx', + }, + ], + [ + '2', + { + id: '2', + parentId: 'root', + path: ':value', + file: 'routes/two.tsx', + }, + ], + ]); + + expect(second).not.toEqual(first); + }); }); From 86542f5fb8989c58ebd9a0ddb1bdacd31a7c2a2d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:23:15 +0000 Subject: [PATCH 37/64] chore: polish route topology watcher diff --- src/index.ts | 8 ++++---- src/route-watch.ts | 8 ++++++-- tests/index.test.ts | 4 ++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 57e0ec8..54eb7d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -414,12 +414,12 @@ export const pluginReactRouter = ( // so it can resolve them correctly during compilation. const rootRouteFile = relative(appDirectory, rootRoutePath); const createRouteTopologySnapshot = ( - latestRootRouteFile: string, - latestRouteConfig: RouteConfigEntry[] + routeFile: string, + routeConfig: RouteConfigEntry[] ) => createRouteManifestSnapshot([ - ['root', { path: '', id: 'root', file: latestRootRouteFile }], - ...configRoutesToRouteManifestEntries(appDirectory, latestRouteConfig), + ['root', { path: '', id: 'root', file: routeFile }], + ...configRoutesToRouteManifestEntries(appDirectory, routeConfig), ]); const getWatchedRouteTopology = async (): Promise> => { const latestRouteConfig = await loadRouteConfig(); diff --git a/src/route-watch.ts b/src/route-watch.ts index 7c6068c..7fbaaf0 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -11,9 +11,13 @@ type RouteManifestSnapshotEntry = Pick< Route, 'caseSensitive' | 'file' | 'id' | 'index' | 'parentId' | 'path' >; +type RouteManifestSnapshotEntryPair = readonly [ + string, + RouteManifestSnapshotEntry, +]; type RouteManifestSnapshotEntries = | Record - | Iterable; + | ReadonlyArray; type WatchFilesConfig = NonNullable< NonNullable['watchFiles'] @@ -64,7 +68,7 @@ export const createRouteManifestSnapshot = ( routes: RouteManifestSnapshotEntries ): Set => new Set( - (Symbol.iterator in routes ? Array.from(routes) : Object.entries(routes)) + (Array.isArray(routes) ? routes : Object.entries(routes)) // React Router uses sibling declaration order as a match tiebreaker, so // callers that have ordered route config should pass ordered entries // instead of a record with numeric-like keys. diff --git a/tests/index.test.ts b/tests/index.test.ts index d792583..51d5a24 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -124,6 +124,10 @@ describe('pluginReactRouter', () => { paths: expect.stringMatching(/app\/routes\.[cm]?[jt]sx?$/), type: 'reload-server', }, + ]) + ); + expect(config.dev.watchFiles).not.toEqual( + expect.arrayContaining([ { paths: expect.stringMatching( /build\/client\/\.react-router\/route-watch$/ From 96aeb230432b17a9fb6396df99c4b2f628ec505a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:17:41 +0000 Subject: [PATCH 38/64] chore: add route watcher lifecycle changeset --- .changeset/warm-watchers-rest.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/warm-watchers-rest.md diff --git a/.changeset/warm-watchers-rest.md b/.changeset/warm-watchers-rest.md new file mode 100644 index 0000000..5517ea1 --- /dev/null +++ b/.changeset/warm-watchers-rest.md @@ -0,0 +1,6 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Avoid duplicate startup route topology scans and tighten development watcher +lifecycle handling for route additions and removals. From 2d41a0cc0b1750411bcb710116cc71d77ea39630 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:16:06 +0000 Subject: [PATCH 39/64] chore: deslop bundling performance pr --- .changeset/bright-routes-run.md | 2 +- .changeset/quiet-topology-order.md | 2 +- .changeset/sharp-routes-heal.md | 2 +- .changeset/warm-watchers-rest.md | 2 +- README.md | 6 +- .../manifest-performance-methodology.md | 284 ------------------ src/parallel-route-transforms.ts | 9 +- src/types.ts | 2 +- tests/parallel-route-transforms.test.ts | 14 +- 9 files changed, 13 insertions(+), 310 deletions(-) delete mode 100644 benchmarks/manifest-performance-methodology.md diff --git a/.changeset/bright-routes-run.md b/.changeset/bright-routes-run.md index 3c52877..9055b0c 100644 --- a/.changeset/bright-routes-run.md +++ b/.changeset/bright-routes-run.md @@ -1,5 +1,5 @@ --- -"rsbuild-plugin-react-router": patch +'rsbuild-plugin-react-router': patch --- Improve route analysis and route chunking performance for larger applications, with benchmark tooling to track build overhead. diff --git a/.changeset/quiet-topology-order.md b/.changeset/quiet-topology-order.md index e0ec1c9..2e14bc3 100644 --- a/.changeset/quiet-topology-order.md +++ b/.changeset/quiet-topology-order.md @@ -1,5 +1,5 @@ --- -"rsbuild-plugin-react-router": patch +'rsbuild-plugin-react-router': patch --- Preserve route topology declaration order during development so reordering route diff --git a/.changeset/sharp-routes-heal.md b/.changeset/sharp-routes-heal.md index 1b44496..f4645f8 100644 --- a/.changeset/sharp-routes-heal.md +++ b/.changeset/sharp-routes-heal.md @@ -1,5 +1,5 @@ --- -"rsbuild-plugin-react-router": patch +'rsbuild-plugin-react-router': patch --- Harden route module transforms and development route watching so source maps, diff --git a/.changeset/warm-watchers-rest.md b/.changeset/warm-watchers-rest.md index 5517ea1..5ec061f 100644 --- a/.changeset/warm-watchers-rest.md +++ b/.changeset/warm-watchers-rest.md @@ -1,5 +1,5 @@ --- -"rsbuild-plugin-react-router": patch +'rsbuild-plugin-react-router': patch --- Avoid duplicate startup route topology scans and tighten development watcher diff --git a/README.md b/README.md index 482b790..c257bd2 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ pluginReactRouter({ * Run route transforms in a worker-thread pool. * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. * @default Automatically enabled for 256+ resolved routes. The automatic - * pool is capped at 8 workers. + * pool uses available CPU cores minus 2. */ parallelTransforms?: boolean | { maxWorkers?: number }, @@ -322,8 +322,8 @@ If no configuration is provided, the following defaults will be used: ``` Route transforms run inline for fewer than 256 resolved routes and use worker -threads for larger route graphs. The automatic worker count is capped at 8. -Pass `true` to force workers, `{ maxWorkers }` (up to 32) to override that +threads for larger route graphs. The automatic worker count uses available CPU +cores minus 2. Pass `true` to force workers, `{ maxWorkers }` to override that count, or `false` to force inline transforms. For builds with 256+ routes, detailed file-size reporting is compacted to totals diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md deleted file mode 100644 index 765eb5a..0000000 --- a/benchmarks/manifest-performance-methodology.md +++ /dev/null @@ -1,284 +0,0 @@ -# Manifest-generation performance benchmark recipe - -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. - -Record environment details for each run: - -- 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`: - -```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. keeps plugin instrumentation disabled for canonical end-to-end A/B runs; - pass `--log-performance` for a separate diagnostic run that emits structured - `[react-router:performance]` 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 end-to-end wall time, process CPU, and RSS as the primary comparison -signals. Plugin `logPerformance` reports are diagnostic because their timers -include queueing and add observer overhead. 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 -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 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 - -### Canonical metrics in `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. | - -### Diagnostic metrics with `--log-performance` - -These fields are empty in canonical A/B runs because plugin instrumentation is -disabled by default. Use a separate diagnostic run when operation-level -attribution is needed. - -| Metric | Source | Why it matters | -| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| 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. - -## Baseline expectations - -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 - 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. - -Use `synthetic-256-ssr-esm` as the non-split control. It should not materially -change when the split-route cache path changes. - -## 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/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index c33a8f7..232c311 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -50,11 +50,9 @@ class WorkerStartupError extends Error { const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; const AUTO_PARALLEL_ROUTE_THRESHOLD = 256; -const DEFAULT_MAX_WORKERS = 8; -const MAX_CONFIGURED_WORKERS = 32; export const getDefaultWorkerCount = (cpuCount?: number): number => - Math.min(DEFAULT_MAX_WORKERS, getDefaultConcurrency(cpuCount)); + getDefaultConcurrency(cpuCount); export const shouldParallelizeRouteTransforms = (routeCount: number): boolean => routeCount >= AUTO_PARALLEL_ROUTE_THRESHOLD; @@ -75,11 +73,6 @@ const getConfiguredWorkerCount = ( '[react-router] parallelTransforms.maxWorkers must be a positive integer.' ); } - if (configured > MAX_CONFIGURED_WORKERS) { - throw new Error( - `[react-router] parallelTransforms.maxWorkers must not exceed ${MAX_CONFIGURED_WORKERS}.` - ); - } return configured; }; diff --git a/src/types.ts b/src/types.ts index e046abf..6919980 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,7 +46,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 Automatically enabled for 256+ resolved routes. The automatic - * pool is capped at 8 workers. + * pool uses available CPU cores minus 2. */ parallelTransforms?: | boolean diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 72539a5..86bd683 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -61,24 +61,18 @@ describe('parallel route transforms', () => { [6, 4], [8, 6], [10, 8], - [12, 8], - [24, 8], - ])('caps the automatic worker count', (cpus, workers) => { + [12, 10], + [24, 22], + ])('defaults worker count to CPU cores minus two', (cpus, workers) => { expect(getDefaultWorkerCount(cpus)).toBe(workers); }); - it('rejects unsafe explicit worker counts', () => { + it('rejects invalid explicit worker counts', () => { expect(() => createRouteTransformExecutor({ parallelTransforms: { maxWorkers: 1.5 }, }) ).toThrow('must be a positive integer'); - - expect(() => - createRouteTransformExecutor({ - parallelTransforms: { maxWorkers: 33 }, - }) - ).toThrow('must not exceed 32'); }); it('honors explicit maxWorkers', async () => { From 32253193a578ef6436e8a445181fe72ea676a149 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:18:15 +0000 Subject: [PATCH 40/64] chore: simplify route transform worker state --- src/parallel-route-transforms.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 232c311..82af0ea 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -38,7 +38,6 @@ type WorkerState = { worker: Worker; pending: Map; sourceCache: Map; - startupError?: WorkerStartupError; }; class WorkerStartupError extends Error { @@ -170,7 +169,6 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { const workers = this.#workers; this.#workers = []; for (const state of workers) { - state.startupError = error; for (const pending of state.pending.values()) { pending.reject(error); } @@ -225,9 +223,6 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { if (!state) { return executeRouteTransformTask(task, this.options); } - if (state.startupError) { - return Promise.reject(state.startupError); - } const id = this.#nextId++; const sourceCacheKey = task.resourcePath; From bd171ce60ac365567b5d00244929e3ff35e8da5a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:07:54 +0000 Subject: [PATCH 41/64] fix: coordinate dev server generations --- src/dev-generation-coordinator.ts | 289 +++++++++++++++++++++++ src/dev-server.ts | 18 +- src/index.ts | 181 +++++++++++++- src/modify-browser-manifest.ts | 11 +- tests/dev-generation-coordinator.test.ts | 146 ++++++++++++ 5 files changed, 619 insertions(+), 26 deletions(-) create mode 100644 src/dev-generation-coordinator.ts create mode 100644 tests/dev-generation-coordinator.test.ts diff --git a/src/dev-generation-coordinator.ts b/src/dev-generation-coordinator.ts new file mode 100644 index 0000000..b9a1e06 --- /dev/null +++ b/src/dev-generation-coordinator.ts @@ -0,0 +1,289 @@ +import type { Rspack } from '@rsbuild/core'; +import type { ServerBuild } from 'react-router'; +import type { RouteManifestModuleExports } from './manifest.js'; + +export type ReactRouterDevManifest = { + version?: string; + routes?: Record; + [key: string]: unknown; +}; + +export type ReactRouterServerBuild = ServerBuild & { + assets?: ReactRouterDevManifest; + routes?: Record }>; +}; + +export type ReactRouterWebStage = { + id: number; + stats?: Rspack.Stats; + compilation?: Rspack.Compilation; + browserManifest: ReactRouterDevManifest; + serverManifest: ReactRouterDevManifest; + serverManifestsByBundleId: Readonly>; + moduleExportsByRouteId: RouteManifestModuleExports; +}; + +export type ReactRouterNodeStage = { + id: number; + stats?: Rspack.Stats; + buildsByEntryName: Readonly>; +}; + +export type ReactRouterDevGeneration = { + id: number; + web: ReactRouterWebStage; + node: ReactRouterNodeStage; +}; + +export class ReactRouterDevGenerationCoordinator { + private nextStageId = 1; + private nextGenerationId = 1; + private latestWebStage: ReactRouterWebStage | null = null; + private latestNodeStage: ReactRouterNodeStage | null = null; + private committed: ReactRouterDevGeneration | null = null; + private lastError: unknown; + private initialWaiters = new Set<{ + resolve: (generation: ReactRouterDevGeneration) => void; + reject: (error: unknown) => void; + }>(); + + stageWeb(stage: Omit): ReactRouterWebStage { + const next = { + ...stage, + id: this.nextStageId++, + }; + this.latestWebStage = next; + return next; + } + + stageNode(stage: Omit): ReactRouterNodeStage { + const next = { + ...stage, + id: this.nextStageId++, + }; + this.latestNodeStage = next; + return next; + } + + getLatestWebStage(): ReactRouterWebStage | null { + return this.latestWebStage; + } + + getLatestNodeStage(): ReactRouterNodeStage | null { + return this.latestNodeStage; + } + + getCommitted(): ReactRouterDevGeneration | null { + return this.committed; + } + + getLastError(): unknown { + return this.lastError; + } + + reject(error: unknown): void { + this.lastError = error; + } + + commit( + web: ReactRouterWebStage | null = this.latestWebStage, + node: ReactRouterNodeStage | null = this.latestNodeStage + ): ReactRouterDevGeneration { + if (!web) { + throw new Error( + '[rsbuild-plugin-react-router] Cannot commit dev generation before the web manifest is staged.' + ); + } + if (!node) { + throw new Error( + '[rsbuild-plugin-react-router] Cannot commit dev generation before the node server build is staged.' + ); + } + + this.validateNodeStage(web, node); + + const generation = { + id: this.nextGenerationId++, + web, + node, + }; + this.committed = generation; + this.lastError = undefined; + this.resolveInitialWaiters(generation); + return generation; + } + + waitForInitialCommitted(): Promise { + if (this.committed) { + return Promise.resolve(this.committed); + } + + return new Promise((resolve, reject) => { + this.initialWaiters.add({ resolve, reject }); + }); + } + + close(): void { + const error = new Error( + '[rsbuild-plugin-react-router] Dev server closed before a React Router server build was committed.' + ); + for (const waiter of this.initialWaiters) { + waiter.reject(error); + } + this.initialWaiters.clear(); + } + + private resolveInitialWaiters(generation: ReactRouterDevGeneration): void { + for (const waiter of this.initialWaiters) { + waiter.resolve(generation); + } + this.initialWaiters.clear(); + } + + private validateNodeStage( + web: ReactRouterWebStage, + node: ReactRouterNodeStage + ): void { + for (const [entryName, build] of Object.entries(node.buildsByEntryName)) { + const expectedManifest = getExpectedManifestForEntry(web, entryName); + const actualManifest = build.assets; + if (!actualManifest) { + throw new Error( + `[rsbuild-plugin-react-router] Server build "${entryName}" does not expose a React Router assets manifest.` + ); + } + if (!manifestsMatch(expectedManifest, actualManifest)) { + throw new Error( + `[rsbuild-plugin-react-router] Server build "${entryName}" does not match the staged web manifest.` + ); + } + assertBuildMatchesManifest(entryName, build, actualManifest); + } + } +} + +const getExpectedManifestForEntry = ( + web: ReactRouterWebStage, + entryName: string +): ReactRouterDevManifest => { + const bundleId = entryName.includes('/') + ? entryName.slice(0, entryName.lastIndexOf('/')) + : undefined; + return ( + (bundleId ? web.serverManifestsByBundleId[bundleId] : undefined) ?? + web.serverManifest + ); +}; + +const stableStringify = (value: unknown): string => + JSON.stringify(sortObject(value)); + +const sortObject = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortObject); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nextValue]) => [key, sortObject(nextValue)]) + ); +}; + +const manifestsMatch = ( + expected: ReactRouterDevManifest, + actual: ReactRouterDevManifest +): boolean => stableStringify(expected) === stableStringify(actual); + +const assertBuildMatchesManifest = ( + entryName: string, + build: ReactRouterServerBuild, + manifest: ReactRouterDevManifest +): void => { + const manifestRoutes = manifest.routes; + if (!manifestRoutes || !build.routes) { + return; + } + + for (const [routeId, manifestRoute] of Object.entries(manifestRoutes)) { + if (!manifestRoute || typeof manifestRoute !== 'object') { + continue; + } + const routeModule = build.routes[routeId]?.module; + if (!routeModule) { + continue; + } + const hasLoader = Boolean( + (manifestRoute as { hasLoader?: unknown }).hasLoader + ); + const hasAction = Boolean( + (manifestRoute as { hasAction?: unknown }).hasAction + ); + if (hasLoader !== (typeof routeModule.loader === 'function')) { + throw new Error( + `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" loader export does not match the staged web manifest.` + ); + } + if (hasAction !== (typeof routeModule.action === 'function')) { + throw new Error( + `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" action export does not match the staged web manifest.` + ); + } + } +}; + +const devServerCoordinators = new WeakMap< + object, + ReactRouterDevGenerationCoordinator +>(); + +export const registerReactRouterDevServer = ( + devServer: object, + coordinator: ReactRouterDevGenerationCoordinator +): void => { + devServerCoordinators.set(devServer, coordinator); +}; + +export const unregisterReactRouterDevServer = (devServer: object): void => { + devServerCoordinators.delete(devServer); +}; + +export const loadReactRouterServerBuild = async ( + devServer: object, + entryName?: string +): Promise => { + const coordinator = devServerCoordinators.get(devServer); + if (!coordinator) { + throw new Error( + '[rsbuild-plugin-react-router] No React Router dev generation coordinator is registered for this Rsbuild dev server.' + ); + } + + const generation = + coordinator.getCommitted() ?? (await coordinator.waitForInitialCommitted()); + const build = selectServerBuild(generation, entryName); + if (!build) { + throw new Error( + entryName + ? `[rsbuild-plugin-react-router] Committed React Router server build "${entryName}" was not found.` + : '[rsbuild-plugin-react-router] No committed React Router server build was found.' + ); + } + return build; +}; + +const selectServerBuild = ( + generation: ReactRouterDevGeneration, + entryName?: string +): ReactRouterServerBuild | undefined => { + if (entryName) { + return generation.node.buildsByEntryName[entryName]; + } + return ( + generation.node.buildsByEntryName['static/js/app'] ?? + generation.node.buildsByEntryName.app ?? + Object.values(generation.node.buildsByEntryName)[0] + ); +}; diff --git a/src/dev-server.ts b/src/dev-server.ts index b175fed..38d35e0 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { loadReactRouterServerBuild } from './dev-generation-coordinator.js'; import { normalizeBuildModule, resolveBuildExports } from './server-utils.js'; export type DevServerMiddleware = ( @@ -14,22 +15,7 @@ export const createDevServerMiddleware = (server: any): DevServerMiddleware => { next: (err?: any) => void ): Promise => { try { - const tryLoadBundle = async (entryName: string) => { - try { - return await server.environments.node.loadBundle(entryName); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("Can't find entry") - ) { - return null; - } - throw error; - } - }; - - const bundle = - (await tryLoadBundle('static/js/app')) ?? (await tryLoadBundle('app')); + const bundle = await loadReactRouterServerBuild(server); if (!bundle || !bundle.routes) { throw new Error('Server bundle not found or invalid'); diff --git a/src/index.ts b/src/index.ts index 54eb7d4..5308695 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,14 @@ import { normalizeAssetPrefix, } from './plugin-utils.js'; import type { PluginOptions } from './types.js'; +import { + ReactRouterDevGenerationCoordinator, + registerReactRouterDevServer, + unregisterReactRouterDevServer, + loadReactRouterServerBuild, + type ReactRouterDevManifest, + type ReactRouterServerBuild, +} from './dev-generation-coordinator.js'; import { generateServerBuild, normalizeBuildModule, @@ -90,6 +98,8 @@ import { mapVirtualModules } from './virtual-modules.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +export { loadReactRouterServerBuild }; + type ModuleFederationPluginLike = { name?: string; _options?: { experiments?: { asyncStartup?: boolean } }; @@ -538,6 +548,19 @@ export const pluginReactRouter = ( type ReactRouterManifest = Awaited< ReturnType >; + const devGenerationCoordinator = new ReactRouterDevGenerationCoordinator(); + let currentDevServer: object | undefined; + api.onBeforeStartDevServer(({ server }) => { + currentDevServer = server as object; + registerReactRouterDevServer(currentDevServer, devGenerationCoordinator); + }); + api.onCloseDevServer(() => { + if (currentDevServer) { + unregisterReactRouterDevServer(currentDevServer); + currentDevServer = undefined; + } + devGenerationCoordinator.close(); + }); let latestBrowserManifest: ReactRouterManifest | null = null; let latestBrowserManifestModuleExports: RouteManifestModuleExports = {}; let latestServerManifest: ReactRouterManifest | null = null; @@ -587,14 +610,125 @@ export const pluginReactRouter = ( rootDirectory: process.cwd(), }); const routesByServerBundleId = getRoutesByServerBundleId(buildManifest); + const serverBuildFileBase = (serverBuildFile || 'index.js').replace( + /\.js$/, + '' + ); + const serverBuildEntryNames = [ + 'static/js/app', + ...Object.entries(routesByServerBundleId) + .filter(([, bundleRoutes]) => { + return bundleRoutes && Object.keys(bundleRoutes).length > 0; + }) + .map(([bundleId]) => `${bundleId}/${serverBuildFileBase}`), + ]; let clientStats: ReactRouterManifestStats | undefined; - api.onAfterEnvironmentCompile(({ stats, environment }) => { + const statsHasErrors = (stats: Rspack.Stats | undefined): boolean => { + return Boolean( + stats && typeof stats.hasErrors === 'function' + ? stats.hasErrors() + : false + ); + }; + const loadDevServerBuild = async ( + entryName: string + ): Promise => { + if (!currentDevServer) { + throw new Error( + `[${PLUGIN_NAME}] Cannot evaluate React Router server build before the Rsbuild dev server is registered.` + ); + } + const devServer = currentDevServer as { + environments?: { + node?: { + loadBundle?: (entryName: string) => Promise; + }; + }; + }; + const loadBundle = devServer.environments?.node?.loadBundle; + if (typeof loadBundle !== 'function') { + throw new Error( + `[${PLUGIN_NAME}] Rsbuild dev server does not expose node.loadBundle().` + ); + } + + try { + const bundle = await loadBundle(entryName); + const normalizedBuild = normalizeBuildModule( + bundle as Record + ); + const build = await resolveBuildExports(normalizedBuild); + if (!build || !build.routes) { + throw new Error( + `[${PLUGIN_NAME}] Server build "${entryName}" is missing React Router routes.` + ); + } + return build as ReactRouterServerBuild; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Can't find entry") + ) { + return null; + } + throw error; + } + }; + const stageNodeServerBuilds = async ( + stats: Rspack.Stats | undefined + ): Promise => { + const buildsByEntryName: Record = {}; + for (const entryName of serverBuildEntryNames) { + const build = await loadDevServerBuild(entryName); + if (build) { + buildsByEntryName[entryName] = build; + } + } + + if (Object.keys(buildsByEntryName).length === 0) { + throw new Error( + `[${PLUGIN_NAME}] React Router server build not found.` + ); + } + + const nodeStage = devGenerationCoordinator.stageNode({ + stats, + buildsByEntryName, + }); + devGenerationCoordinator.commit( + devGenerationCoordinator.getLatestWebStage(), + nodeStage + ); + }; + + api.onAfterEnvironmentCompile(async ({ stats, environment }) => { if (environment.name === 'web') { clientStats = createReactRouterManifestStats( stats?.compilation, manifestChunkNames ); + if (!isBuild && statsHasErrors(stats)) { + devGenerationCoordinator.reject( + new Error(`[${PLUGIN_NAME}] Web compilation has errors.`) + ); + } + } + if (!isBuild && environment.name === 'node') { + if (statsHasErrors(stats)) { + devGenerationCoordinator.reject( + new Error(`[${PLUGIN_NAME}] Node compilation has errors.`) + ); + } else { + try { + await stageNodeServerBuilds(stats); + } catch (error) { + devGenerationCoordinator.reject(error); + api.logger.error( + error instanceof Error ? error.message : String(error) + ); + } + } } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -1232,11 +1366,6 @@ export const pluginReactRouter = ( : useAsyncNodeChunkLoading ? 'async-node' : 'require'; - const serverBuildFileBase = (serverBuildFile || 'index.js').replace( - /\.js$/, - '' - ); - const nodeEntries: Record = { ...(hasServerApp ? { @@ -1429,7 +1558,12 @@ export const pluginReactRouter = ( { future, manifestChunkNames, - onManifest: (manifest, sri, moduleExportsByRouteId) => { + onManifest: ( + manifest, + sri, + moduleExportsByRouteId, + manifestContext + ) => { performanceProfiler.recordSync( 'web', 'manifest:stage', @@ -1443,6 +1577,10 @@ export const pluginReactRouter = ( sri, }; latestServerManifest = baseServerManifest; + const nextServerManifestsByBundleId: Record< + string, + ReactRouterManifest + > = {}; for (const [ bundleId, bundleRoutes, @@ -1458,11 +1596,30 @@ export const pluginReactRouter = ( ([routeId]) => routeIds.has(routeId) ) ); - latestServerManifestsByBundleId[bundleId] = { + nextServerManifestsByBundleId[bundleId] = { ...baseServerManifest, routes: filteredRoutes, }; } + Object.assign( + latestServerManifestsByBundleId, + nextServerManifestsByBundleId + ); + if (!isBuild) { + devGenerationCoordinator.stageWeb({ + compilation: manifestContext.compilation, + browserManifest: + manifest as ReactRouterDevManifest, + serverManifest: + baseServerManifest as ReactRouterDevManifest, + serverManifestsByBundleId: + nextServerManifestsByBundleId as Record< + string, + ReactRouterDevManifest + >, + moduleExportsByRouteId, + }); + } } ); }, @@ -1513,8 +1670,16 @@ export const pluginReactRouter = ( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); const bundleId = bundleMatch?.[1]?.replace(/\.js$/, ''); + const stagedWeb = !isBuild + ? devGenerationCoordinator.getLatestWebStage() + : null; const manifest = + (!isBuild && stagedWeb + ? bundleId && stagedWeb.serverManifestsByBundleId[bundleId] + ? stagedWeb.serverManifestsByBundleId[bundleId] + : stagedWeb.serverManifest + : null) ?? (isBuild && latestServerManifest ? bundleId && latestServerManifestsByBundleId[bundleId] ? latestServerManifestsByBundleId[bundleId] diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 4bcb5a7..35fa237 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -33,7 +33,11 @@ export function createModifyBrowserManifestPlugin( sri: Record | undefined, moduleExportsByRouteId: Awaited< ReturnType - >['moduleExportsByRouteId'] + >['moduleExportsByRouteId'], + context: { + compilation: Rspack.Compilation; + manifestStats: ReturnType; + } ) => void; } ) { @@ -141,7 +145,10 @@ export function createModifyBrowserManifestPlugin( } } - options?.onManifest?.(manifest, sri, moduleExportsByRouteId); + options?.onManifest?.(manifest, sri, moduleExportsByRouteId, { + compilation, + manifestStats: stats, + }); } ); }, diff --git a/tests/dev-generation-coordinator.test.ts b/tests/dev-generation-coordinator.test.ts new file mode 100644 index 0000000..7d50003 --- /dev/null +++ b/tests/dev-generation-coordinator.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from '@rstest/core'; +import { + ReactRouterDevGenerationCoordinator, + loadReactRouterServerBuild, + registerReactRouterDevServer, +} from '../src/dev-generation-coordinator'; + +const createManifest = (hasLoader: boolean) => + ({ + version: hasLoader ? 'with-loader' : 'without-loader', + url: '/static/js/manifest.js', + hmr: undefined, + routes: { + 'routes/page': { + id: 'routes/page', + module: '/app/routes/page.tsx', + hasLoader, + }, + }, + }) as any; + +const createBuild = (assets: any, loader?: () => unknown) => + ({ + assets, + routes: { + 'routes/page': { + id: 'routes/page', + module: loader ? { loader } : {}, + }, + }, + }) as any; + +describe('ReactRouterDevGenerationCoordinator', () => { + it('rejects a server build whose embedded manifest does not match the staged web candidate', () => { + const coordinator = new ReactRouterDevGenerationCoordinator(); + const firstManifest = createManifest(false); + const firstWeb = coordinator.stageWeb({ + browserManifest: firstManifest, + serverManifest: firstManifest, + serverManifestsByBundleId: {}, + moduleExportsByRouteId: {}, + }); + const firstNode = coordinator.stageNode({ + buildsByEntryName: { + 'static/js/app': createBuild(firstManifest), + }, + }); + const firstGeneration = coordinator.commit(firstWeb, firstNode); + + const nextManifest = createManifest(true); + const nextWeb = coordinator.stageWeb({ + browserManifest: nextManifest, + serverManifest: nextManifest, + serverManifestsByBundleId: {}, + moduleExportsByRouteId: {}, + }); + const mismatchedNode = coordinator.stageNode({ + buildsByEntryName: { + 'static/js/app': createBuild(firstManifest, () => 'data'), + }, + }); + + expect(() => coordinator.commit(nextWeb, mismatchedNode)).toThrow( + 'does not match the staged web manifest' + ); + expect(coordinator.getCommitted()).toBe(firstGeneration); + }); + + it('keeps serving the last committed build after a rejected candidate', async () => { + const coordinator = new ReactRouterDevGenerationCoordinator(); + const server = {}; + const manifest = createManifest(false); + const build = createBuild(manifest); + + registerReactRouterDevServer(server, coordinator); + coordinator.commit( + coordinator.stageWeb({ + browserManifest: manifest, + serverManifest: manifest, + serverManifestsByBundleId: {}, + moduleExportsByRouteId: {}, + }), + coordinator.stageNode({ + buildsByEntryName: { + 'static/js/app': build, + }, + }) + ); + coordinator.reject(new Error('candidate failed')); + + await expect(loadReactRouterServerBuild(server)).resolves.toBe(build); + }); + + it('rejects node-new web-old route export mismatches even when the embedded manifest is old-coherent', () => { + const coordinator = new ReactRouterDevGenerationCoordinator(); + const manifest = createManifest(false); + const web = coordinator.stageWeb({ + browserManifest: manifest, + serverManifest: manifest, + serverManifestsByBundleId: {}, + moduleExportsByRouteId: {}, + }); + const node = coordinator.stageNode({ + buildsByEntryName: { + 'static/js/app': createBuild(manifest, () => 'data'), + }, + }); + + expect(() => coordinator.commit(web, node)).toThrow( + 'loader export does not match the staged web manifest' + ); + expect(coordinator.getCommitted()).toBeNull(); + }); + + it('custom server helper waits for the initial committed generation', async () => { + const coordinator = new ReactRouterDevGenerationCoordinator(); + const server = {}; + const manifest = createManifest(false); + const build = createBuild(manifest); + registerReactRouterDevServer(server, coordinator); + + const pendingBuild = loadReactRouterServerBuild(server); + let resolved = false; + pendingBuild.then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + + coordinator.commit( + coordinator.stageWeb({ + browserManifest: manifest, + serverManifest: manifest, + serverManifestsByBundleId: {}, + moduleExportsByRouteId: {}, + }), + coordinator.stageNode({ + buildsByEntryName: { + 'static/js/app': build, + }, + }) + ); + + await expect(pendingBuild).resolves.toBe(build); + }); +}); From 29dc663186c36dba5c2b7143ed1e219cc79da41f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:19:41 +0000 Subject: [PATCH 42/64] fix: keep dev generation staging fresh --- src/dev-generation-coordinator.ts | 53 ++++++++---------------- src/index.ts | 5 +++ tests/dev-generation-coordinator.test.ts | 13 +++--- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/dev-generation-coordinator.ts b/src/dev-generation-coordinator.ts index b9a1e06..17af954 100644 --- a/src/dev-generation-coordinator.ts +++ b/src/dev-generation-coordinator.ts @@ -1,16 +1,18 @@ import type { Rspack } from '@rsbuild/core'; -import type { ServerBuild } from 'react-router'; +import type { + ServerBuild, + UNSAFE_AssetsManifest as AssetsManifest, +} from 'react-router'; import type { RouteManifestModuleExports } from './manifest.js'; -export type ReactRouterDevManifest = { - version?: string; - routes?: Record; +export type ReactRouterDevManifest = AssetsManifest & { + routes: Record; [key: string]: unknown; }; export type ReactRouterServerBuild = ServerBuild & { - assets?: ReactRouterDevManifest; - routes?: Record }>; + assets: ReactRouterDevManifest; + routes: Record }>; }; export type ReactRouterWebStage = { @@ -77,6 +79,11 @@ export class ReactRouterDevGenerationCoordinator { return this.committed; } + resetStaging(): void { + this.latestWebStage = null; + this.latestNodeStage = null; + } + getLastError(): unknown { return this.lastError; } @@ -152,12 +159,8 @@ export class ReactRouterDevGenerationCoordinator { `[rsbuild-plugin-react-router] Server build "${entryName}" does not expose a React Router assets manifest.` ); } - if (!manifestsMatch(expectedManifest, actualManifest)) { - throw new Error( - `[rsbuild-plugin-react-router] Server build "${entryName}" does not match the staged web manifest.` - ); - } - assertBuildMatchesManifest(entryName, build, actualManifest); + assertBuildMatchesManifest(entryName, build, expectedManifest); + build.assets = expectedManifest; } } } @@ -175,28 +178,6 @@ const getExpectedManifestForEntry = ( ); }; -const stableStringify = (value: unknown): string => - JSON.stringify(sortObject(value)); - -const sortObject = (value: unknown): unknown => { - if (Array.isArray(value)) { - return value.map(sortObject); - } - if (!value || typeof value !== 'object') { - return value; - } - return Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, nextValue]) => [key, sortObject(nextValue)]) - ); -}; - -const manifestsMatch = ( - expected: ReactRouterDevManifest, - actual: ReactRouterDevManifest -): boolean => stableStringify(expected) === stableStringify(actual); - const assertBuildMatchesManifest = ( entryName: string, build: ReactRouterServerBuild, @@ -213,7 +194,9 @@ const assertBuildMatchesManifest = ( } const routeModule = build.routes[routeId]?.module; if (!routeModule) { - continue; + throw new Error( + `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" is missing from the evaluated server build.` + ); } const hasLoader = Boolean( (manifestRoute as { hasLoader?: unknown }).hasLoader diff --git a/src/index.ts b/src/index.ts index 5308695..d14b0c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -551,6 +551,7 @@ export const pluginReactRouter = ( const devGenerationCoordinator = new ReactRouterDevGenerationCoordinator(); let currentDevServer: object | undefined; api.onBeforeStartDevServer(({ server }) => { + devGenerationCoordinator.resetStaging(); currentDevServer = server as object; registerReactRouterDevServer(currentDevServer, devGenerationCoordinator); }); @@ -1423,6 +1424,10 @@ export const pluginReactRouter = ( ? [] : [ (middlewares, server) => { + registerReactRouterDevServer( + server as object, + devGenerationCoordinator + ); middlewares.push(createDevServerMiddleware(server)); }, ], diff --git a/tests/dev-generation-coordinator.test.ts b/tests/dev-generation-coordinator.test.ts index 7d50003..43adf55 100644 --- a/tests/dev-generation-coordinator.test.ts +++ b/tests/dev-generation-coordinator.test.ts @@ -31,7 +31,7 @@ const createBuild = (assets: any, loader?: () => unknown) => }) as any; describe('ReactRouterDevGenerationCoordinator', () => { - it('rejects a server build whose embedded manifest does not match the staged web candidate', () => { + it('normalizes stale embedded manifests to the staged web candidate', () => { const coordinator = new ReactRouterDevGenerationCoordinator(); const firstManifest = createManifest(false); const firstWeb = coordinator.stageWeb({ @@ -54,16 +54,17 @@ describe('ReactRouterDevGenerationCoordinator', () => { serverManifestsByBundleId: {}, moduleExportsByRouteId: {}, }); + const staleBuild = createBuild(firstManifest, () => 'data'); const mismatchedNode = coordinator.stageNode({ buildsByEntryName: { - 'static/js/app': createBuild(firstManifest, () => 'data'), + 'static/js/app': staleBuild, }, }); - expect(() => coordinator.commit(nextWeb, mismatchedNode)).toThrow( - 'does not match the staged web manifest' - ); - expect(coordinator.getCommitted()).toBe(firstGeneration); + const nextGeneration = coordinator.commit(nextWeb, mismatchedNode); + expect(nextGeneration).not.toBe(firstGeneration); + expect(staleBuild.assets).toBe(nextManifest); + expect(coordinator.getCommitted()).toBe(nextGeneration); }); it('keeps serving the last committed build after a rejected candidate', async () => { From 7e3519030ee8a2345d2edbeada32abdd802d956a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:00:28 +0000 Subject: [PATCH 43/64] fix: make dev generations coherent --- .changeset/coherent-dev-generations.md | 13 + README.md | 130 +-- docs/coherent-dev-generations.md | 70 ++ .../custom-node-server/app/load-context.d.ts | 7 + .../app/routes/projects/edit.tsx | 6 +- .../app/routes/projects/index.tsx | 4 +- .../app/routes/projects/layout.tsx | 4 +- .../app/routes/projects/project.tsx | 8 +- .../app/routes/projects/settings.tsx | 6 +- examples/custom-node-server/package.json | 9 +- .../custom-node-server/playwright.config.ts | 4 +- examples/custom-node-server/rsbuild.config.ts | 5 +- .../scripts/smoke-production.mjs | 62 ++ examples/custom-node-server/server.js | 99 +- examples/custom-node-server/server/index.ts | 18 - examples/custom-node-server/tsconfig.json | 4 +- package.json | 3 +- scripts/test-package-interop.mjs | 81 ++ src/build-manifest.ts | 5 +- src/dev-generation-coordinator.ts | 272 ------ src/dev-generation.ts | 444 +++++++++ src/dev-runtime-artifacts.ts | 207 ++++ src/dev-runtime-controller.ts | 575 ++++++++++++ src/dev-server.ts | 80 +- src/index.ts | 339 +++---- src/react-router-config.ts | 4 +- src/server-utils.ts | 104 ++- tests/build-manifest.test.ts | 8 +- tests/dev-generation-coordinator.test.ts | 147 --- tests/dev-generation-multi-entry.test.ts | 284 ++++++ tests/dev-generation.test.ts | 832 +++++++++++++++++ tests/dev-runtime-controller.test.ts | 881 ++++++++++++++++++ tests/dev-runtime.integration.test.ts | 549 +++++++++++ tests/dev-server.test.ts | 68 ++ .../fixtures/dev-runtime/app/entry.client.tsx | 7 + .../fixtures/dev-runtime/app/entry.server.tsx | 20 + tests/fixtures/dev-runtime/app/root.tsx | 14 + tests/fixtures/dev-runtime/app/routes.ts | 6 + .../fixtures/dev-runtime/app/routes/index.tsx | 5 + .../fixtures/dev-runtime/app/routes/other.tsx | 3 + .../dev-runtime/react-router.config.ts | 8 + tests/fixtures/dev-runtime/server/index.ts | 1 + tests/index.test.ts | 28 + tests/modify-browser-manifest.test.ts | 50 + tests/react-router-config.test.ts | 18 + tests/server-utils.test.ts | 68 ++ tests/setup.ts | 3 + 47 files changed, 4701 insertions(+), 862 deletions(-) create mode 100644 .changeset/coherent-dev-generations.md create mode 100644 docs/coherent-dev-generations.md create mode 100644 examples/custom-node-server/app/load-context.d.ts create mode 100644 examples/custom-node-server/scripts/smoke-production.mjs delete mode 100644 examples/custom-node-server/server/index.ts create mode 100644 scripts/test-package-interop.mjs delete mode 100644 src/dev-generation-coordinator.ts create mode 100644 src/dev-generation.ts create mode 100644 src/dev-runtime-artifacts.ts create mode 100644 src/dev-runtime-controller.ts delete mode 100644 tests/dev-generation-coordinator.test.ts create mode 100644 tests/dev-generation-multi-entry.test.ts create mode 100644 tests/dev-generation.test.ts create mode 100644 tests/dev-runtime-controller.test.ts create mode 100644 tests/dev-runtime.integration.test.ts create mode 100644 tests/dev-server.test.ts create mode 100644 tests/fixtures/dev-runtime/app/entry.client.tsx create mode 100644 tests/fixtures/dev-runtime/app/entry.server.tsx create mode 100644 tests/fixtures/dev-runtime/app/root.tsx create mode 100644 tests/fixtures/dev-runtime/app/routes.ts create mode 100644 tests/fixtures/dev-runtime/app/routes/index.tsx create mode 100644 tests/fixtures/dev-runtime/app/routes/other.tsx create mode 100644 tests/fixtures/dev-runtime/react-router.config.ts create mode 100644 tests/fixtures/dev-runtime/server/index.ts create mode 100644 tests/server-utils.test.ts diff --git a/.changeset/coherent-dev-generations.md b/.changeset/coherent-dev-generations.md new file mode 100644 index 0000000..e6d97c8 --- /dev/null +++ b/.changeset/coherent-dev-generations.md @@ -0,0 +1,13 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Keep development SSR requests on the last successfully evaluated atomic set of +React Router server entries and their paired web manifests, and expose +`loadReactRouterServerBuild` so custom servers use the same last-good pair. +Expose `resolveReactRouterServerBuild` to normalize ESM and CommonJS production +server modules through the same validated build boundary. +Preserve `serverBundles` through config normalization and publish every +configured bundle atomically with its exact filtered manifest. +This does not snapshot deferred server chunks, make emitted client assets +atomic, or delay Rsbuild's WebSocket success notification. diff --git a/README.md b/README.md index c257bd2..b6678b5 100644 --- a/README.md +++ b/README.md @@ -449,30 +449,25 @@ If the server is created programmatically with `createDevServer()`, pass `onRouteTopologyChange` and use it to recreate that server. Rsbuild's `reload-server` watcher is owned by the CLI and is not installed by the programmatic API. The callback is a notification and is not awaited, so it can -safely close the current server as part of the replacement. - -When using a custom server, you'll need to: - -1. Create a server handler (`server/index.ts`): - -```ts -import { createRequestHandler } from '@react-router/express'; - -export const app = createRequestHandler({ - build: () => import('virtual/react-router/server-build'), - getLoadContext() { - // Add custom context available to your loaders/actions - return { - // ... your custom context - }; - }, -}); -``` - -2. Set up your server entry point (`server.js`): +safely start a serialized replacement task. Always `await` the active server's +`close()` before calling `createDevServer()` again; the plugin rejects overlapping +or out-of-order replacement instead of closing one server from inside another +server's startup hooks. If startup fails before returning a server, or if +`close()` rejects, restart the process before retrying unless you can externally +prove and force complete teardown; a fresh Rsbuild instance alone is not +sufficient. Do not launch concurrent `createDevServer()` calls. + +Create one server entry point (`server.js`) and let it own the React Router +request handler in both development and production. Only the build provider +changes between modes: ```js import { createRsbuild, loadConfig } from '@rsbuild/core'; +import { createRequestHandler } from '@react-router/express'; +import { + loadReactRouterServerBuild, + resolveReactRouterServerBuild, +} from 'rsbuild-plugin-react-router'; import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -484,64 +479,83 @@ const app = express(); const isDev = process.env.NODE_ENV !== 'production'; async function startServer() { + let devServer; + let build; + if (isDev) { const config = await loadConfig(); const rsbuild = await createRsbuild({ rsbuildConfig: config.content, }); - const devServer = await rsbuild.createDevServer(); - app.use(devServer.middlewares); - - app.use(async (req, res, next) => { - try { - const bundle = await devServer.environments.node.loadBundle('app'); - await bundle.app(req, res, next); - } catch (e) { - next(e); - } - }); - - const port = Number.parseInt(process.env.PORT || '3000', 10); - const server = app.listen(port, () => { - console.log(`Development server is running on http://localhost:${port}`); - devServer.afterListen(); - }); - devServer.connectWebSocket({ server }); + const currentDevServer = await rsbuild.createDevServer(); + devServer = currentDevServer; + app.use(currentDevServer.middlewares); + build = () => loadReactRouterServerBuild(currentDevServer); } else { - // Production mode 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'); - // Mount the server app after static file handling - app.use(async (req, res, next) => { - try { - await serverBundle.default.app(req, res, next); - } catch (e) { - next(e); - } - }); - - const port = Number.parseInt(process.env.PORT || '3000', 10); - app.listen(port, () => { - console.log(`Production server is running on http://localhost:${port}`); - }); + build = await resolveReactRouterServerBuild( + import('./build/server/static/js/app.js') + ); } + + app.use( + createRequestHandler({ + build, + mode: isDev ? 'development' : 'production', + getLoadContext() { + return { + // Add custom loader/action context here. + }; + }, + }) + ); + + const port = Number.parseInt(process.env.PORT || '3000', 10); + const server = app.listen(port, () => { + const mode = isDev ? 'Development' : 'Production'; + console.log(`${mode} server is running on http://localhost:${port}`); + devServer?.afterListen(); + }); + devServer?.connectWebSocket({ server }); } startServer().catch(console.error); ``` -3. Update your `package.json` scripts: +`loadReactRouterServerBuild` waits for a complete React Router development +generation. During rebuilds it returns the last successfully evaluated server +build, whose embedded manifest is paired with the selected web compilation. +A failed or incomplete candidate does not replace that last-good pair. The +built-in development middleware uses the same path. Calling +`devServer.environments.node.loadBundle()` directly bypasses this guarantee. + +When `serverBundles` is configured, pass its exact Rsbuild entry name as the +optional second argument (for example, `bundle-a/index`). The default build +and every configured bundle are +evaluated and published as one generation; one failing bundle keeps the whole +previous generation active. + +`resolveReactRouterServerBuild` accepts an imported production server module, +normalizes ESM and CommonJS namespace shapes, resolves supported asynchronous +build exports, and validates the result before it reaches React Router. + +This guarantee covers the eagerly evaluated server entry object and its +embedded manifest. It does not snapshot deferred server chunks, make emitted +client assets immutable, or delay Rsbuild's WebSocket success notification. +Same-path server or client chunks can change before the matching framework +generation commits. Closing that publication gap requires a supported Rsbuild +graph-settled hook plus immutable or staged outputs. + +Then update your `package.json` scripts: ```json { "scripts": { - "dev": "node server.js", + "dev": "NODE_ENV=development NODE_OPTIONS=\"--experimental-vm-modules\" node server.js", "build": "rsbuild build", "start": "NODE_ENV=production node server.js" } diff --git a/docs/coherent-dev-generations.md b/docs/coherent-dev-generations.md new file mode 100644 index 0000000..5acc592 --- /dev/null +++ b/docs/coherent-dev-generations.md @@ -0,0 +1,70 @@ +# Coherent React Router development generations + +The plugin builds React Router applications with separate `web` and `node` +compilers. During development, those compilers can finish at different times. +Publishing each result independently can pair a new browser manifest with an +older server entry object. + +## Contract + +The plugin exposes only a committed React Router development generation: + +- every configured React Router server entry was evaluated successfully; +- each entry's embedded manifest came from the selected web compilation; +- failed, incomplete, and superseded candidates cannot replace the last-good + generation; and +- built-in middleware and `loadReactRouterServerBuild(devServer)` read the same + committed generation. + +Requests capture one committed server entry object for their lifetime. Calling +`loadReactRouterServerBuild(devServer, entryName)` selects a configured server +bundle by its exact Rsbuild entry name; omitting `entryName` selects the full +default build. All entries switch generations together. The public helper is +the supported build provider for custom development servers; calling +`devServer.environments.node.loadBundle()` directly bypasses this contract. + +## Lifecycle model + +A candidate records the exact web compilation used to produce its manifests and +the corresponding evaluated node builds. It becomes visible only after +Rsbuild's aggregate development callback supplies a complete, error-free pair. +One-sided callbacks are accepted only when their known changed files do not +intersect the unchanged compiler's dependencies. + +Each node compilation also records the latest completed web compilation when it +starts. If rapid edits produce a callback containing a node result paired with +a different web compilation, that mixed candidate is discarded. Fatal compiler +failures reject initial waiters promptly; later failures preserve last-good +output. + +The committed generation remains available while a later candidate builds. +Initial compilation failures are reported to requests; failures after a commit +leave the last-good generation available. Starting a replacement dev server +creates a new lifecycle session so callbacks from the old session cannot +publish into the new one. + +Programmatic replacement requires callers to await the active server's +`close()` before calling `createDevServer()` again. The plugin rejects an +overlapping or out-of-order replacement rather than closing one server from +inside another server's global startup-hook transaction. Callers must serialize +`createDevServer()` calls; concurrent server creation is outside this contract. +If startup fails before returning a server, or if closing the active server +rejects, restart the process before retrying unless the caller can externally +prove and force complete teardown. A fresh Rsbuild instance alone is not +sufficient because the prior compiler or watchers may still be active. + +## Deliberate limit + +This is an eagerly evaluated server-entry-set and manifest-pairing guarantee, not +byte-level output atomicity. Development outputs use stable paths and mutable +storage, so an old server-build object does not preserve older client assets or +server chunks that are imported lazily after entry evaluation. + +Rsbuild publishes compiler-derived WebSocket success before its supported +`onAfterDevCompile` plugin callback. The plugin therefore cannot promise that +browser success notification waits for framework publication. A supported +graph-settled, pre-success hook is required to close that gap. + +Strict old-or-new asset serving would additionally require immutable +generation filenames, staged output promotion, or request-pinned asset +snapshots with garbage collection. That is outside this contract. diff --git a/examples/custom-node-server/app/load-context.d.ts b/examples/custom-node-server/app/load-context.d.ts new file mode 100644 index 0000000..5d62985 --- /dev/null +++ b/examples/custom-node-server/app/load-context.d.ts @@ -0,0 +1,7 @@ +import 'react-router'; + +declare module 'react-router' { + interface AppLoadContext { + VALUE_FROM_EXPRESS: string; + } +} diff --git a/examples/custom-node-server/app/routes/projects/edit.tsx b/examples/custom-node-server/app/routes/projects/edit.tsx index 2b2927b..dcbe0f2 100644 --- a/examples/custom-node-server/app/routes/projects/edit.tsx +++ b/examples/custom-node-server/app/routes/projects/edit.tsx @@ -1,9 +1,11 @@ import { Form, Link, useLoaderData, useNavigation } from 'react-router'; import type { Route } from './+types/edit'; +type LoaderData = Route.ComponentProps['loaderData']; + export function handle() { return { - breadcrumb: (data: Route.LoaderData) => `Edit ${data.project.name}`, + breadcrumb: (data: LoaderData) => `Edit ${data.project.name}`, }; } @@ -31,7 +33,7 @@ export async function action({ request, params }: Route.ActionArgs) { } export default function EditProject() { - const { project } = useLoaderData(); + const { project } = useLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === 'submitting'; diff --git a/examples/custom-node-server/app/routes/projects/index.tsx b/examples/custom-node-server/app/routes/projects/index.tsx index a5da7a3..1278ecc 100644 --- a/examples/custom-node-server/app/routes/projects/index.tsx +++ b/examples/custom-node-server/app/routes/projects/index.tsx @@ -1,6 +1,8 @@ import { Link, useLoaderData } from 'react-router'; import type { Route } from './+types/index'; +type LoaderData = Route.ComponentProps['loaderData']; + export function handle() { return { breadcrumb: () => 'All Projects', @@ -73,7 +75,7 @@ function StatCard({ } export default function ProjectsIndex() { - const { stats, recentActivity } = useLoaderData(); + const { stats, recentActivity } = useLoaderData(); return (
diff --git a/examples/custom-node-server/app/routes/projects/layout.tsx b/examples/custom-node-server/app/routes/projects/layout.tsx index 9fd9d5c..cd638ce 100644 --- a/examples/custom-node-server/app/routes/projects/layout.tsx +++ b/examples/custom-node-server/app/routes/projects/layout.tsx @@ -1,6 +1,8 @@ import { Link, NavLink, Outlet, useLoaderData } from 'react-router'; import type { Route } from './+types/layout'; +type LoaderData = Route.ComponentProps['loaderData']; + export function handle() { return { breadcrumb: () => 'Projects', @@ -20,7 +22,7 @@ export function loader() { } export default function ProjectsLayout() { - const { projects } = useLoaderData(); + const { projects } = useLoaderData(); return (
diff --git a/examples/custom-node-server/app/routes/projects/project.tsx b/examples/custom-node-server/app/routes/projects/project.tsx index d99dd02..5a30f57 100644 --- a/examples/custom-node-server/app/routes/projects/project.tsx +++ b/examples/custom-node-server/app/routes/projects/project.tsx @@ -1,9 +1,11 @@ -import { Link, useLoaderData, useParams } from 'react-router'; +import { Link, useLoaderData } from 'react-router'; import type { Route } from './+types/project'; +type LoaderData = Route.ComponentProps['loaderData']; + export function handle() { return { - breadcrumb: (data: Route.LoaderData) => data.project.name, + breadcrumb: (data: LoaderData) => data.project.name, }; } @@ -74,7 +76,7 @@ function Avatar({ name, initials }: { name: string; initials: string }) { } export default function Project() { - const { project } = useLoaderData(); + const { project } = useLoaderData(); return (
diff --git a/examples/custom-node-server/app/routes/projects/settings.tsx b/examples/custom-node-server/app/routes/projects/settings.tsx index 76e7318..6f9d124 100644 --- a/examples/custom-node-server/app/routes/projects/settings.tsx +++ b/examples/custom-node-server/app/routes/projects/settings.tsx @@ -1,9 +1,11 @@ import { Form, Link, useLoaderData, useNavigation } from 'react-router'; import type { Route } from './+types/settings'; +type LoaderData = Route.ComponentProps['loaderData']; + export function handle() { return { - breadcrumb: (data: Route.LoaderData) => `${data.project.name} Settings`, + breadcrumb: (data: LoaderData) => `${data.project.name} Settings`, }; } @@ -64,7 +66,7 @@ function SettingsSection({ } export default function ProjectSettings() { - const { project } = useLoaderData(); + const { project } = useLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === 'submitting'; diff --git a/examples/custom-node-server/package.json b/examples/custom-node-server/package.json index 7b5fcf4..c395e14 100644 --- a/examples/custom-node-server/package.json +++ b/examples/custom-node-server/package.json @@ -6,11 +6,14 @@ "type": "module", "main": "index.js", "scripts": { - "dev": "RSDOCTOR=false PORT=3003 NODE_OPTIONS=\"--experimental-vm-modules --experimental-global-webcrypto\" node server.js", + "dev": "NODE_ENV=development RSDOCTOR=false PORT=3003 NODE_OPTIONS=\"--experimental-vm-modules --experimental-global-webcrypto\" node server.js", "start": "NODE_ENV=production PORT=3003 node server.js", "build": "rsbuild build", - "typecheck": "react-router typegen && tsc", - "test:e2e": "playwright test" + "typecheck": "react-router typegen && tsc --noEmit", + "test:e2e": "playwright test && corepack pnpm run test:production", + "test:production": "corepack pnpm run test:production:module && corepack pnpm run test:production:commonjs", + "test:production:module": "RR_SERVER_OUTPUT=module corepack pnpm run build && node scripts/smoke-production.mjs", + "test:production:commonjs": "RR_SERVER_OUTPUT=commonjs corepack pnpm run build && node scripts/smoke-production.mjs" }, "keywords": [], "author": "", diff --git a/examples/custom-node-server/playwright.config.ts b/examples/custom-node-server/playwright.config.ts index fbeb1bd..caf49c3 100644 --- a/examples/custom-node-server/playwright.config.ts +++ b/examples/custom-node-server/playwright.config.ts @@ -42,9 +42,9 @@ export default defineConfig({ // Web server configuration - starts dev server for custom node server webServer: { - command: 'pnpm run dev', + command: 'corepack pnpm run dev', url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120000, }, -}); \ No newline at end of file +}); diff --git a/examples/custom-node-server/rsbuild.config.ts b/examples/custom-node-server/rsbuild.config.ts index b0fd786..e277460 100644 --- a/examples/custom-node-server/rsbuild.config.ts +++ b/examples/custom-node-server/rsbuild.config.ts @@ -3,12 +3,15 @@ import { pluginReact } from '@rsbuild/plugin-react'; import { pluginReactRouter } from 'rsbuild-plugin-react-router'; export default defineConfig(() => { + const serverOutput = + process.env.RR_SERVER_OUTPUT === 'commonjs' ? 'commonjs' : 'module'; return { plugins: [ pluginReactRouter({ customServer: true, + serverOutput, }), - pluginReact() + pluginReact(), ], }; }); diff --git a/examples/custom-node-server/scripts/smoke-production.mjs b/examples/custom-node-server/scripts/smoke-production.mjs new file mode 100644 index 0000000..fe15161 --- /dev/null +++ b/examples/custom-node-server/scripts/smoke-production.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const probe = createServer(); +probe.listen(0, '127.0.0.1'); +await once(probe, 'listening'); +const address = probe.address(); +assert(address && typeof address !== 'string'); +const { port } = address; +await new Promise(resolve => probe.close(resolve)); + +const child = spawn(process.execPath, ['server.js'], { + cwd: root, + env: { + ...process.env, + NODE_ENV: 'production', + PORT: String(port), + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let output = ''; +child.stdout.on('data', chunk => { + output += chunk; +}); +child.stderr.on('data', chunk => { + output += chunk; +}); + +try { + const deadline = Date.now() + 30_000; + let response; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Production server exited early.\n${output}`); + } + try { + response = await fetch(`http://127.0.0.1:${port}/`); + if (response.ok) { + break; + } + } catch { + // The server is still starting. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert(response?.ok, `Production server did not become ready.\n${output}`); + assert.match(await response.text(), /React Router Demo/); + console.log('Production smoke request returned HTTP 200.'); +} finally { + child.kill('SIGTERM'); + await Promise.race([ + once(child, 'exit'), + new Promise(resolve => setTimeout(resolve, 5_000)), + ]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + } +} diff --git a/examples/custom-node-server/server.js b/examples/custom-node-server/server.js index d500dff..a1b7c4b 100644 --- a/examples/custom-node-server/server.js +++ b/examples/custom-node-server/server.js @@ -1,4 +1,9 @@ import { createRsbuild, loadConfig } from '@rsbuild/core'; +import { createRequestHandler } from '@react-router/express'; +import { + loadReactRouterServerBuild, + resolveReactRouterServerBuild, +} from 'rsbuild-plugin-react-router'; import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -10,71 +15,49 @@ const app = express(); const isDev = process.env.NODE_ENV !== 'production'; async function startServer() { + /** @type {import('@rsbuild/core').RsbuildDevServer | undefined} */ + let devServer; + /** @type {import('react-router').ServerBuild | (() => Promise)} */ + let build; + if (isDev) { const config = await loadConfig(); const rsbuild = await createRsbuild({ rsbuildConfig: config.content, }); - const devServer = await rsbuild.createDevServer(); - app.use(devServer.middlewares); - - app.use(async (req, res, next) => { - try { - const tryLoadBundle = async (entryName) => { - try { - return await devServer.environments.node.loadBundle(entryName); - } catch (error) { - if (error instanceof Error && error.message.includes("Can't find entry")) { - return null; - } - throw error; - } - }; - const bundle = /** @type {import("./server/index.js")} */ ( - (await tryLoadBundle('static/js/app')) ?? (await tryLoadBundle('app')) - ); - await bundle.app(req, res, next); - } catch (e) { - next(e); - } - }); - - const port = Number.parseInt(process.env.PORT || '3000', 10); - const server = app.listen(port, () => { - console.log(`Development server is running on http://localhost:${port}`); - devServer.afterListen(); - }); - devServer.connectWebSocket({ server }); + const currentDevServer = await rsbuild.createDevServer(); + devServer = currentDevServer; + app.use(currentDevServer.middlewares); + build = () => loadReactRouterServerBuild(currentDevServer); } else { - // Production mode - - 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'); - const serverApp = serverBundle.app ?? serverBundle.default?.app; - if (typeof serverApp !== 'function') { - throw new Error( - 'Invalid server bundle: expected an exported `app(req, res, next)` handler.' - ); - } - - // Mount the server app after static file handling - app.use(async (req, res, next) => { - try { - await serverApp(req, res, next); - } catch (e) { - next(e); - } - }); - - const port = Number.parseInt(process.env.PORT || '3000', 10); - app.listen(port, () => { - console.log(`Production server is running on http://localhost:${port}`); - }); + app.use( + express.static(path.join(__dirname, 'build/client'), { + index: false, + }) + ); + const productionBuildPath = './build/server/static/js/app.js'; + build = await resolveReactRouterServerBuild(import(productionBuildPath)); } + + app.use( + createRequestHandler({ + build, + mode: isDev ? 'development' : 'production', + getLoadContext() { + return { + VALUE_FROM_EXPRESS: 'Hello from Express', + }; + }, + }) + ); + + const port = Number.parseInt(process.env.PORT || '3000', 10); + const server = app.listen(port, () => { + const mode = isDev ? 'Development' : 'Production'; + console.log(`${mode} server is running on http://localhost:${port}`); + devServer?.afterListen(); + }); + devServer?.connectWebSocket({ server }); } startServer().catch(console.error); diff --git a/examples/custom-node-server/server/index.ts b/examples/custom-node-server/server/index.ts deleted file mode 100644 index 477feb4..0000000 --- a/examples/custom-node-server/server/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import 'react-router'; -import { createRequestHandler } from '@react-router/express'; - -declare module 'react-router' { - interface AppLoadContext { - VALUE_FROM_EXPRESS: string; - } -} - -export const app = createRequestHandler({ - // @ts-expect-error - virtual module provided by React Router at build time - build: () => import('virtual/react-router/server-build'), - getLoadContext() { - return { - VALUE_FROM_EXPRESS: 'Hello from Express', - }; - }, -}); diff --git a/examples/custom-node-server/tsconfig.json b/examples/custom-node-server/tsconfig.json index 0f91f7b..c50dc99 100644 --- a/examples/custom-node-server/tsconfig.json +++ b/examples/custom-node-server/tsconfig.json @@ -2,7 +2,7 @@ "include": [ "./.react-router/types/**/*", "./app/**/*", - "./server/**/*" + "./server.js" ], "exclude": ["./build"], "compilerOptions": { @@ -10,7 +10,7 @@ "strict": true, "checkJs": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["@rsbuild/core/types"], + "types": ["@rsbuild/core/types", "node"], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", diff --git a/package.json b/package.json index 1e20e80..cecf3de 100644 --- a/package.json +++ b/package.json @@ -56,13 +56,14 @@ "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", "bench:large": "node scripts/bench-builds.mjs --profile large --iterations 1 --warmup 0 --clean cold --format both --out .benchmark/results/large", - "e2e": "pnpm build && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only}' test:e2e", + "e2e": "pnpm build && pnpm test:package-interop && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only}' test:e2e", "dev": "rslib build --watch", "test": "rstest run", "test:watch": "rstest watch", "test:coverage": "rstest run --coverage", "test:core": "rstest run -c ./rstest.config.ts", "test:core:watch": "rstest watch -c ./rstest.config.ts", + "test:package-interop": "node scripts/test-package-interop.mjs", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "changeset": "changeset", diff --git a/scripts/test-package-interop.mjs b/scripts/test-package-interop.mjs new file mode 100644 index 0000000..4783046 --- /dev/null +++ b/scripts/test-package-interop.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const esm = await import('../dist/index.js'); +const commonjs = require('../dist/index.cjs'); +const build = { + entry: { module: { default: () => new Response() } }, + routes: {}, + assets: { routes: {}, version: 'interop' }, + assetsBuildDirectory: '/app/build/client', + basename: '/', + future: {}, + isSpaMode: false, + prerender: [], + publicPath: '/', + routeDiscovery: { mode: 'initial' }, + ssr: true, +}; + +const collect = hooks => hook => hooks.push(hook); +const noop = () => undefined; + +async function verifyRegistration(writer, reader) { + const starts = []; + const closes = []; + const api = { + context: { action: 'dev', rootPath: process.cwd() }, + logger: { info: noop, warn: noop, error: noop }, + getNormalizedConfig: () => ({}), + modifyRsbuildConfig: noop, + modifyEnvironmentConfig: noop, + onBeforeBuild: noop, + onBeforeStartDevServer: collect(starts), + onCloseDevServer: collect(closes), + onCloseBuild: noop, + onAfterEnvironmentCompile: noop, + onAfterBuild: noop, + processAssets: noop, + transform: noop, + onBeforeDevCompile: noop, + onAfterCreateCompiler: noop, + onAfterDevCompile: noop, + }; + await writer.pluginReactRouter({ customServer: true }).setup(api); + + const start = starts.find(hook => hook.order === 'pre').handler; + const close = closes.find(hook => hook.order === 'pre').handler; + const server = { + close: async () => undefined, + environments: { node: { loadBundle: async () => build } }, + sockWrite: noop, + }; + await start({ environments: {}, server }); + + const pending = reader.loadReactRouterServerBuild(server); + await close(); + await assert.rejects(pending, /closed before a React Router build was ready/); + await assert.rejects( + reader.loadReactRouterServerBuild(server), + /not registered/ + ); +} + +process.chdir( + fileURLToPath(new URL('../tests/fixtures/dev-runtime/', import.meta.url)) +); +await verifyRegistration(esm, commonjs); +await verifyRegistration(commonjs, esm); + +assert.deepEqual( + await esm.resolveReactRouterServerBuild({ default: build }), + build +); +assert.deepEqual( + await commonjs.resolveReactRouterServerBuild({ default: build }), + build +); + +console.log('ESM and CommonJS package entrypoints share runtime state.'); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 1cbdbae..5c49bab 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -147,7 +147,8 @@ export const getBuildManifest = async ({ }; export const getRoutesByServerBundleId = ( - buildManifest: BuildManifest | undefined + buildManifest: BuildManifest | undefined, + sourceRoutes: Record ): Record> => { if (!buildManifest || !('routeIdToServerBundleId' in buildManifest)) { return {}; @@ -158,7 +159,7 @@ export const getRoutesByServerBundleId = ( buildManifest.routeIdToServerBundleId )) { routesByServerBundleId[serverBundleId] ??= {}; - const branch = getRouteBranch(buildManifest.routes, routeId); + const branch = getRouteBranch(sourceRoutes, routeId); for (const route of branch) { routesByServerBundleId[serverBundleId][route.id] = route; } diff --git a/src/dev-generation-coordinator.ts b/src/dev-generation-coordinator.ts deleted file mode 100644 index 17af954..0000000 --- a/src/dev-generation-coordinator.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { Rspack } from '@rsbuild/core'; -import type { - ServerBuild, - UNSAFE_AssetsManifest as AssetsManifest, -} from 'react-router'; -import type { RouteManifestModuleExports } from './manifest.js'; - -export type ReactRouterDevManifest = AssetsManifest & { - routes: Record; - [key: string]: unknown; -}; - -export type ReactRouterServerBuild = ServerBuild & { - assets: ReactRouterDevManifest; - routes: Record }>; -}; - -export type ReactRouterWebStage = { - id: number; - stats?: Rspack.Stats; - compilation?: Rspack.Compilation; - browserManifest: ReactRouterDevManifest; - serverManifest: ReactRouterDevManifest; - serverManifestsByBundleId: Readonly>; - moduleExportsByRouteId: RouteManifestModuleExports; -}; - -export type ReactRouterNodeStage = { - id: number; - stats?: Rspack.Stats; - buildsByEntryName: Readonly>; -}; - -export type ReactRouterDevGeneration = { - id: number; - web: ReactRouterWebStage; - node: ReactRouterNodeStage; -}; - -export class ReactRouterDevGenerationCoordinator { - private nextStageId = 1; - private nextGenerationId = 1; - private latestWebStage: ReactRouterWebStage | null = null; - private latestNodeStage: ReactRouterNodeStage | null = null; - private committed: ReactRouterDevGeneration | null = null; - private lastError: unknown; - private initialWaiters = new Set<{ - resolve: (generation: ReactRouterDevGeneration) => void; - reject: (error: unknown) => void; - }>(); - - stageWeb(stage: Omit): ReactRouterWebStage { - const next = { - ...stage, - id: this.nextStageId++, - }; - this.latestWebStage = next; - return next; - } - - stageNode(stage: Omit): ReactRouterNodeStage { - const next = { - ...stage, - id: this.nextStageId++, - }; - this.latestNodeStage = next; - return next; - } - - getLatestWebStage(): ReactRouterWebStage | null { - return this.latestWebStage; - } - - getLatestNodeStage(): ReactRouterNodeStage | null { - return this.latestNodeStage; - } - - getCommitted(): ReactRouterDevGeneration | null { - return this.committed; - } - - resetStaging(): void { - this.latestWebStage = null; - this.latestNodeStage = null; - } - - getLastError(): unknown { - return this.lastError; - } - - reject(error: unknown): void { - this.lastError = error; - } - - commit( - web: ReactRouterWebStage | null = this.latestWebStage, - node: ReactRouterNodeStage | null = this.latestNodeStage - ): ReactRouterDevGeneration { - if (!web) { - throw new Error( - '[rsbuild-plugin-react-router] Cannot commit dev generation before the web manifest is staged.' - ); - } - if (!node) { - throw new Error( - '[rsbuild-plugin-react-router] Cannot commit dev generation before the node server build is staged.' - ); - } - - this.validateNodeStage(web, node); - - const generation = { - id: this.nextGenerationId++, - web, - node, - }; - this.committed = generation; - this.lastError = undefined; - this.resolveInitialWaiters(generation); - return generation; - } - - waitForInitialCommitted(): Promise { - if (this.committed) { - return Promise.resolve(this.committed); - } - - return new Promise((resolve, reject) => { - this.initialWaiters.add({ resolve, reject }); - }); - } - - close(): void { - const error = new Error( - '[rsbuild-plugin-react-router] Dev server closed before a React Router server build was committed.' - ); - for (const waiter of this.initialWaiters) { - waiter.reject(error); - } - this.initialWaiters.clear(); - } - - private resolveInitialWaiters(generation: ReactRouterDevGeneration): void { - for (const waiter of this.initialWaiters) { - waiter.resolve(generation); - } - this.initialWaiters.clear(); - } - - private validateNodeStage( - web: ReactRouterWebStage, - node: ReactRouterNodeStage - ): void { - for (const [entryName, build] of Object.entries(node.buildsByEntryName)) { - const expectedManifest = getExpectedManifestForEntry(web, entryName); - const actualManifest = build.assets; - if (!actualManifest) { - throw new Error( - `[rsbuild-plugin-react-router] Server build "${entryName}" does not expose a React Router assets manifest.` - ); - } - assertBuildMatchesManifest(entryName, build, expectedManifest); - build.assets = expectedManifest; - } - } -} - -const getExpectedManifestForEntry = ( - web: ReactRouterWebStage, - entryName: string -): ReactRouterDevManifest => { - const bundleId = entryName.includes('/') - ? entryName.slice(0, entryName.lastIndexOf('/')) - : undefined; - return ( - (bundleId ? web.serverManifestsByBundleId[bundleId] : undefined) ?? - web.serverManifest - ); -}; - -const assertBuildMatchesManifest = ( - entryName: string, - build: ReactRouterServerBuild, - manifest: ReactRouterDevManifest -): void => { - const manifestRoutes = manifest.routes; - if (!manifestRoutes || !build.routes) { - return; - } - - for (const [routeId, manifestRoute] of Object.entries(manifestRoutes)) { - if (!manifestRoute || typeof manifestRoute !== 'object') { - continue; - } - const routeModule = build.routes[routeId]?.module; - if (!routeModule) { - throw new Error( - `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" is missing from the evaluated server build.` - ); - } - const hasLoader = Boolean( - (manifestRoute as { hasLoader?: unknown }).hasLoader - ); - const hasAction = Boolean( - (manifestRoute as { hasAction?: unknown }).hasAction - ); - if (hasLoader !== (typeof routeModule.loader === 'function')) { - throw new Error( - `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" loader export does not match the staged web manifest.` - ); - } - if (hasAction !== (typeof routeModule.action === 'function')) { - throw new Error( - `[rsbuild-plugin-react-router] Server build "${entryName}" route "${routeId}" action export does not match the staged web manifest.` - ); - } - } -}; - -const devServerCoordinators = new WeakMap< - object, - ReactRouterDevGenerationCoordinator ->(); - -export const registerReactRouterDevServer = ( - devServer: object, - coordinator: ReactRouterDevGenerationCoordinator -): void => { - devServerCoordinators.set(devServer, coordinator); -}; - -export const unregisterReactRouterDevServer = (devServer: object): void => { - devServerCoordinators.delete(devServer); -}; - -export const loadReactRouterServerBuild = async ( - devServer: object, - entryName?: string -): Promise => { - const coordinator = devServerCoordinators.get(devServer); - if (!coordinator) { - throw new Error( - '[rsbuild-plugin-react-router] No React Router dev generation coordinator is registered for this Rsbuild dev server.' - ); - } - - const generation = - coordinator.getCommitted() ?? (await coordinator.waitForInitialCommitted()); - const build = selectServerBuild(generation, entryName); - if (!build) { - throw new Error( - entryName - ? `[rsbuild-plugin-react-router] Committed React Router server build "${entryName}" was not found.` - : '[rsbuild-plugin-react-router] No committed React Router server build was found.' - ); - } - return build; -}; - -const selectServerBuild = ( - generation: ReactRouterDevGeneration, - entryName?: string -): ReactRouterServerBuild | undefined => { - if (entryName) { - return generation.node.buildsByEntryName[entryName]; - } - return ( - generation.node.buildsByEntryName['static/js/app'] ?? - generation.node.buildsByEntryName.app ?? - Object.values(generation.node.buildsByEntryName)[0] - ); -}; diff --git a/src/dev-generation.ts b/src/dev-generation.ts new file mode 100644 index 0000000..0a82200 --- /dev/null +++ b/src/dev-generation.ts @@ -0,0 +1,444 @@ +import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import type { ServerBuild } from 'react-router'; +import { + evaluateServerBuilds, + getEnvironmentStats, + isSafeOneSidedChange, + pinServerBuildsToManifests, + snapshotDependencies, + type DependencySnapshot, + type DevCompilationIdentity, + type DevGraphChanges, + type DevGraphIdentity, + type ReactRouterDevBuildPlan, + type ReactRouterDevManifestSet, + type ReactRouterServerBuilds, + type WebArtifact, +} from './dev-runtime-artifacts.js'; + +export { snapshotDevChangedFiles } from './dev-runtime-artifacts.js'; +export type { + DevChangedFiles, + DevGraphChanges, + DevGraphIdentity, + ReactRouterDevBuildPlan, + ReactRouterDevManifest, + ReactRouterDevManifestSet, +} from './dev-runtime-artifacts.js'; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +}; + +type CommittedGeneration = { + buildsByEntryName: ReactRouterServerBuilds; + webIdentity: DevCompilationIdentity; + nodeIdentity: DevCompilationIdentity; + web: WebArtifact; + nodeDependencies: DependencySnapshot; +}; + +type RuntimeState = + | { + kind: 'starting'; + attemptId: number; + readiness: Deferred; + } + | { kind: 'failed'; attemptId: number; error: Error } + | { + kind: 'ready'; + committed: CommittedGeneration; + pendingAttemptId: number | null; + } + | { kind: 'closed'; error: Error }; + +export type ReactRouterDevRuntime = { + beginAttempt: () => void; + captureWeb: ( + compilation: Rspack.Compilation, + manifestsByEntryName: ReactRouterDevManifestSet + ) => void; + finishAttempt: ( + stats: Rspack.Stats | Rspack.MultiStats, + changes: DevGraphChanges, + identity: DevGraphIdentity + ) => Promise; + failAttempt: (error: Error) => void; + load: (entryName?: string) => Promise; + close: (error?: Error) => void; +}; + +type CreateReactRouterDevRuntimeOptions = { + server: RsbuildDevServer; + buildPlan: ReactRouterDevBuildPlan; + onEvaluationError: (error: Error) => void; + onWarning?: (message: string) => void; +}; + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + // Compilation can fail before a request asks for the build. Observe the + // rejection now while returning the same promise to future callers. + void promise.catch(() => undefined); + return { promise, resolve, reject }; +}; + +export const createReactRouterDevRuntime = ({ + server, + buildPlan, + onEvaluationError, + onWarning = () => undefined, +}: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => { + let nextAttemptId = 1; + let state: RuntimeState = { + kind: 'starting', + attemptId: 0, + readiness: createDeferred(), + }; + const manifestsByCompilation = new WeakMap< + Rspack.Compilation, + ReactRouterDevManifestSet + >(); + + const uniqueEntryNames = new Set(buildPlan.entryNames); + if ( + uniqueEntryNames.size !== buildPlan.entryNames.length || + !uniqueEntryNames.has(buildPlan.defaultEntryName) + ) { + throw new Error( + '[rsbuild-plugin-react-router] The development server build plan must contain unique entries and include its default entry.' + ); + } + + const selectBuild = ( + generation: CommittedGeneration, + requestedEntryName?: string + ): ServerBuild => { + const entryName = requestedEntryName ?? buildPlan.defaultEntryName; + const build = generation.buildsByEntryName[entryName]; + if (!build) { + throw new Error( + `[rsbuild-plugin-react-router] Committed React Router server build ${JSON.stringify(entryName)} was not found.` + ); + } + return build; + }; + + const getCurrentAttemptId = (): number | null => { + if (state.kind === 'starting') { + return state.attemptId; + } + return state.kind === 'ready' ? state.pendingAttemptId : null; + }; + + const isCurrentAttempt = (attemptId: number): boolean => + getCurrentAttemptId() === attemptId; + + const rejectAttempt = ( + attemptId: number, + error: Error, + report: boolean + ): void => { + if (!isCurrentAttempt(attemptId)) { + return; + } + if (state.kind === 'starting') { + const { readiness } = state; + state = { kind: 'failed', attemptId, error }; + readiness.reject(error); + } else if (state.kind === 'ready') { + state = { ...state, pendingAttemptId: null }; + } + if (report) { + onEvaluationError(error); + } + }; + + const commit = (attemptId: number, committed: CommittedGeneration): void => { + if (!isCurrentAttempt(attemptId)) { + return; + } + if (state.kind === 'starting') { + const { readiness } = state; + state = { kind: 'ready', committed, pendingAttemptId: null }; + readiness.resolve(committed); + } else if (state.kind === 'ready') { + state = { kind: 'ready', committed, pendingAttemptId: null }; + } + }; + + const discardUnsafeOneSidedResult = ( + attemptId: number, + previous: CommittedGeneration, + webChanged: boolean, + changes: DevGraphChanges + ): boolean => { + const side = webChanged ? 'web-only' : 'node-only'; + const changedFiles = webChanged ? changes.web : changes.node; + const unchangedDependencies = webChanged + ? previous.nodeDependencies + : previous.web.dependencies; + if (isSafeOneSidedChange(changedFiles, unchangedDependencies)) { + return false; + } + onWarning( + `[rsbuild-plugin-react-router] Discarded an incomplete ${side} development result and kept the last-good build.` + ); + rejectAttempt( + attemptId, + new Error(`Incomplete ${side} development result.`), + false + ); + return true; + }; + + return { + beginAttempt(): void { + if (state.kind === 'closed') { + return; + } + const attemptId = nextAttemptId++; + if (state.kind === 'failed') { + state = { + kind: 'starting', + attemptId, + readiness: createDeferred(), + }; + } else if (state.kind === 'starting') { + state = { ...state, attemptId }; + } else { + state = { ...state, pendingAttemptId: attemptId }; + } + }, + + captureWeb(compilation, manifestsByEntryName): void { + if (state.kind !== 'closed') { + manifestsByCompilation.set( + compilation, + structuredClone(manifestsByEntryName) + ); + } + }, + + async finishAttempt(stats, changes, identity): Promise { + const attemptId = getCurrentAttemptId(); + if (attemptId === null) { + return; + } + const webStats = getEnvironmentStats(stats, 'web'); + const nodeStats = getEnvironmentStats(stats, 'node'); + if (!webStats || !nodeStats) { + rejectAttempt( + attemptId, + new Error( + '[rsbuild-plugin-react-router] Development compilation did not provide both web and node results.' + ), + true + ); + return; + } + if ( + webStats.compilation.needAdditionalPass || + nodeStats.compilation.needAdditionalPass + ) { + return; + } + if (webStats.hasErrors() || nodeStats.hasErrors()) { + rejectAttempt( + attemptId, + new Error( + '[rsbuild-plugin-react-router] The React Router development compilation failed.' + ), + false + ); + return; + } + + const webCompilation = webStats.compilation; + const nodeCompilation = nodeStats.compilation; + const webIdentity = identity.web; + const nodeIdentity = identity.node; + if (!webIdentity || !nodeIdentity) { + rejectAttempt( + attemptId, + new Error( + '[rsbuild-plugin-react-router] Development compilation identity was unavailable.' + ), + true + ); + return; + } + const previous = state.kind === 'ready' ? state.committed : undefined; + const webChanged = !previous || previous.webIdentity !== webIdentity; + const nodeChanged = !previous || previous.nodeIdentity !== nodeIdentity; + + if (!webChanged && !nodeChanged) { + return; + } + + if (nodeChanged && identity.nodeWeb !== webIdentity) { + const message = + '[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.'; + if (!previous) { + return; + } + onWarning(message); + rejectAttempt(attemptId, new Error(message), false); + return; + } + + const manifestsByEntryName = webChanged + ? manifestsByCompilation.get(webCompilation) + : previous?.web.manifestsByEntryName; + if (!manifestsByEntryName) { + rejectAttempt( + attemptId, + new Error( + '[rsbuild-plugin-react-router] The web compilation completed without a matching React Router manifest. Keeping the last-good development build.' + ), + true + ); + return; + } + if ( + previous && + webChanged !== nodeChanged && + discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes) + ) { + return; + } + + try { + const buildsByEntryName = nodeChanged + ? await evaluateServerBuilds(server, buildPlan.entryNames) + : previous!.buildsByEntryName; + if (!isCurrentAttempt(attemptId)) { + return; + } + const web = webChanged + ? { + manifestsByEntryName, + dependencies: snapshotDependencies(webCompilation), + } + : previous!.web; + commit(attemptId, { + buildsByEntryName: pinServerBuildsToManifests( + buildsByEntryName, + buildPlan.entryNames, + web.manifestsByEntryName + ), + webIdentity, + nodeIdentity, + web, + nodeDependencies: nodeChanged + ? snapshotDependencies(nodeCompilation) + : previous!.nodeDependencies, + }); + } catch (cause) { + rejectAttempt( + attemptId, + cause instanceof Error ? cause : new Error(String(cause)), + true + ); + } + }, + + failAttempt(error): void { + const attemptId = getCurrentAttemptId(); + if (attemptId !== null) { + rejectAttempt(attemptId, error, false); + } + }, + + load(entryName?: string): Promise { + if (entryName && !uniqueEntryNames.has(entryName)) { + return Promise.reject( + new Error( + `[rsbuild-plugin-react-router] React Router server build ${JSON.stringify(entryName)} is not part of this development server build plan.` + ) + ); + } + if (state.kind === 'ready') { + return Promise.resolve(selectBuild(state.committed, entryName)); + } + if (state.kind === 'starting') { + const selected = state.readiness.promise.then(generation => + selectBuild(generation, entryName) + ); + // Compilation may fail before the request awaiting this selection has + // a chance to attach its own rejection handler. + void selected.catch(() => undefined); + return selected; + } + return Promise.reject(state.error); + }, + + close(error?: Error): void { + if (state.kind === 'closed') { + return; + } + const closeError = + error ?? + new Error( + '[rsbuild-plugin-react-router] The development server closed before a React Router build was ready.' + ); + if (state.kind === 'starting') { + state.readiness.reject(closeError); + } + state = { kind: 'closed', error: closeError }; + }, + }; +}; + +const DEV_RUNTIME_KEY = Symbol.for( + 'rsbuild-plugin-react-router.dev-runtime.v1' +); + +const getRegisteredRuntime = ( + server: RsbuildDevServer +): ReactRouterDevRuntime | undefined => + Reflect.get(server, DEV_RUNTIME_KEY) as ReactRouterDevRuntime | undefined; + +export const registerReactRouterDevRuntime = ( + server: RsbuildDevServer, + runtime: ReactRouterDevRuntime +): void => { + // Symbol.for keeps registration shared when the plugin and public helper are + // loaded through different ESM and CommonJS package entrypoints. + Object.defineProperty(server, DEV_RUNTIME_KEY, { + configurable: true, + enumerable: false, + value: runtime, + }); +}; + +export const unregisterReactRouterDevRuntime = ( + server: RsbuildDevServer, + runtime: ReactRouterDevRuntime +): void => { + if (getRegisteredRuntime(server) === runtime) { + Reflect.deleteProperty(server, DEV_RUNTIME_KEY); + } +}; + +export const loadReactRouterServerBuild = ( + server: RsbuildDevServer, + entryName?: string +): Promise => { + const runtime = getRegisteredRuntime(server); + if (!runtime) { + return Promise.reject( + new Error( + '[rsbuild-plugin-react-router] This Rsbuild development server is not registered with the React Router plugin. Add pluginReactRouter() before calling loadReactRouterServerBuild().' + ) + ); + } + return runtime.load(entryName); +}; diff --git a/src/dev-runtime-artifacts.ts b/src/dev-runtime-artifacts.ts new file mode 100644 index 0000000..2fd4dca --- /dev/null +++ b/src/dev-runtime-artifacts.ts @@ -0,0 +1,207 @@ +import { isAbsolute, relative } from 'node:path'; +import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import type { ServerBuild } from 'react-router'; +import type { ReactRouterManifestForDev } from './manifest.js'; +import { resolveServerBuildModule } from './server-utils.js'; + +export type ReactRouterDevManifest = ReactRouterManifestForDev; + +export type ReactRouterDevBuildPlan = { + defaultEntryName: string; + entryNames: readonly string[]; +}; + +export type ReactRouterDevManifestSet = Readonly< + Record +>; + +export type ReactRouterServerBuilds = Readonly>; + +export type DependencySnapshot = { + files: ReadonlySet; + contexts: ReadonlySet; + missing: ReadonlySet; +}; + +export type DevChangedFiles = { + known: boolean; + files: ReadonlySet; +}; + +export type DevGraphChanges = { + web: DevChangedFiles; + node: DevChangedFiles; +}; + +export type DevCompilationIdentity = symbol; + +export type DevGraphIdentity = { + web: DevCompilationIdentity | undefined; + node: DevCompilationIdentity | undefined; + nodeWeb: DevCompilationIdentity | undefined; +}; + +export type WebArtifact = { + manifestsByEntryName: ReactRouterDevManifestSet; + dependencies: DependencySnapshot; +}; + +export const snapshotDevChangedFiles = ( + compiler: Pick | undefined +): DevChangedFiles => { + if (!compiler) { + return { known: false, files: new Set() }; + } + return { + known: + compiler.modifiedFiles !== undefined || + compiler.removedFiles !== undefined, + files: new Set([ + ...(compiler.modifiedFiles ?? []), + ...(compiler.removedFiles ?? []), + ]), + }; +}; + +export const snapshotDependencies = ( + compilation: Rspack.Compilation +): DependencySnapshot => ({ + files: new Set([ + ...compilation.fileDependencies, + ...(compilation.buildDependencies ?? []), + ]), + contexts: new Set(compilation.contextDependencies), + missing: new Set(compilation.missingDependencies), +}); + +const isWithinDirectory = (directory: string, file: string): boolean => { + const relativePath = relative(directory, file); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + ); +}; + +export const isSafeOneSidedChange = ( + changes: DevChangedFiles, + dependencies: DependencySnapshot +): boolean => { + if (!changes.known || changes.files.size === 0) { + return false; + } + for (const file of changes.files) { + if (dependencies.files.has(file) || dependencies.missing.has(file)) { + return false; + } + for (const directory of dependencies.contexts) { + if (isWithinDirectory(directory, file)) { + return false; + } + } + } + return true; +}; + +export const getEnvironmentStats = ( + stats: Rspack.Stats | Rspack.MultiStats, + name: 'web' | 'node' +): Rspack.Stats | undefined => { + const children = Array.isArray((stats as Rspack.MultiStats).stats) + ? (stats as Rspack.MultiStats).stats + : [stats as Rspack.Stats]; + return children.find(child => { + const compilation = child.compilation; + return compilation.name === name || compilation.compiler?.name === name; + }); +}; + +const evaluateServerBuild = async ( + server: RsbuildDevServer, + entryName: string +): Promise => { + const loaded = await server.environments.node.loadBundle(entryName); + return resolveServerBuildModule( + loaded, + `Server entry ${JSON.stringify(entryName)}` + ); +}; + +export const evaluateServerBuilds = async ( + server: RsbuildDevServer, + entryNames: readonly string[] +): Promise => { + const evaluated = await Promise.all( + entryNames.map(async entryName => [ + entryName, + await evaluateServerBuild(server, entryName), + ]) + ); + return Object.fromEntries(evaluated) as Record; +}; + +const assertBuildMatchesManifest = ( + entryName: string, + build: ServerBuild, + manifest: ReactRouterDevManifest +): void => { + for (const [routeId, manifestRoute] of Object.entries(manifest.routes)) { + const routeModule = build.routes[routeId]?.module; + if (!routeModule) { + throw new Error( + `[rsbuild-plugin-react-router] Server build ${JSON.stringify(entryName)} route ${JSON.stringify(routeId)} is missing from the evaluated server build.` + ); + } + if ( + Boolean(manifestRoute.hasLoader) !== + (typeof routeModule.loader === 'function') + ) { + throw new Error( + `[rsbuild-plugin-react-router] Server build ${JSON.stringify(entryName)} route ${JSON.stringify(routeId)} loader export does not match its web manifest.` + ); + } + if ( + Boolean(manifestRoute.hasAction) !== + (typeof routeModule.action === 'function') + ) { + throw new Error( + `[rsbuild-plugin-react-router] Server build ${JSON.stringify(entryName)} route ${JSON.stringify(routeId)} action export does not match its web manifest.` + ); + } + } +}; + +const pinBuildToManifest = ( + entryName: string, + build: ServerBuild, + manifest: ReactRouterDevManifest +): ServerBuild => { + assertBuildMatchesManifest(entryName, build, manifest); + return { + ...build, + assets: structuredClone(manifest) as ServerBuild['assets'], + }; +}; + +export const pinServerBuildsToManifests = ( + builds: ReactRouterServerBuilds, + entryNames: readonly string[], + manifestsByEntryName: ReactRouterDevManifestSet +): ReactRouterServerBuilds => { + const pinned: Record = {}; + for (const entryName of entryNames) { + const build = builds[entryName]; + if (!build) { + throw new Error( + `[rsbuild-plugin-react-router] Expected server build ${JSON.stringify(entryName)} was not evaluated.` + ); + } + const manifest = manifestsByEntryName[entryName]; + if (!manifest) { + throw new Error( + `[rsbuild-plugin-react-router] Server build ${JSON.stringify(entryName)} has no matching web manifest.` + ); + } + pinned[entryName] = pinBuildToManifest(entryName, build, manifest); + } + return pinned; +}; diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts new file mode 100644 index 0000000..34feef4 --- /dev/null +++ b/src/dev-runtime-controller.ts @@ -0,0 +1,575 @@ +import type { + RsbuildConfig, + RsbuildPluginAPI, + RsbuildDevServer, + Rspack, +} from '@rsbuild/core'; +import type { ServerBuild } from 'react-router'; +import { PLUGIN_NAME } from './constants.js'; +import { + createReactRouterDevRuntime, + loadReactRouterServerBuild, + registerReactRouterDevRuntime, + unregisterReactRouterDevRuntime, + type ReactRouterDevRuntime, +} from './dev-generation.js'; +import { + getEnvironmentStats, + snapshotDevChangedFiles, + type DevCompilationIdentity, + type DevGraphChanges, + type DevGraphIdentity, + type ReactRouterDevBuildPlan, + type ReactRouterDevManifestSet, +} from './dev-runtime-artifacts.js'; + +type DevCompilerPair = { + web: Rspack.Compiler; + node: Rspack.Compiler; + settledCompilations: WeakSet; + pendingAttempt?: PendingDevCompilation; + latestCompletedWebIdentity?: DevCompilationIdentity; + latestWebStart?: CompilationStart; + latestNodeStart?: CompilationStart; +}; + +type PendingDevCompilation = { + stats: Rspack.Stats | Rspack.MultiStats; + changes: DevGraphChanges; + identity: DevGraphIdentity; + webCompilation: Rspack.Compilation; + nodeCompilation: Rspack.Compilation; +}; + +type CompilationStart = + | { status: 'pending' } + | { status: 'started'; identity: DevCompilationIdentity }; + +type RuntimeBinding = { + id: number; + server: RsbuildDevServer; + runtime: ReactRouterDevRuntime; + compilers?: DevCompilerPair; +}; + +type CloseOutcome = { ok: true } | { ok: false; cause: unknown }; + +type CloseObservation = { + binding?: RuntimeBinding; + promise?: Promise; + outcome?: CloseOutcome; +}; + +type ServerSetup = Exclude< + NonNullable['setup']>, + unknown[] +>; + +type ControllerState = + | { status: 'idle' } + | { status: 'active'; binding: RuntimeBinding } + | { status: 'closing'; binding: RuntimeBinding } + | { status: 'terminal'; error: Error }; + +export type ReactRouterDevRuntimeController = { + captureWeb: ( + compilation: Rspack.Compilation, + manifestsByEntryName: ReactRouterDevManifestSet + ) => void; + createBuildLoader: (entryName?: string) => () => Promise; +}; + +type CreateControllerOptions = { + api: RsbuildPluginAPI; + isBuild: boolean; + buildPlan: ReactRouterDevBuildPlan; +}; + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + +const isLatestStartedCompilation = ( + identity: DevCompilationIdentity | undefined, + start: CompilationStart | undefined +): boolean => + !identity || (start?.status === 'started' && start.identity === identity); + +const hasPendingCompilation = (pair: DevCompilerPair): boolean => + pair.latestWebStart?.status === 'pending' || + pair.latestNodeStart?.status === 'pending'; + +export const createReactRouterDevRuntimeController = ({ + api, + isBuild, + buildPlan, +}: CreateControllerOptions): ReactRouterDevRuntimeController => { + if (isBuild) { + return { + captureWeb() {}, + createBuildLoader() { + return () => + Promise.reject( + new Error( + `[${PLUGIN_NAME}] The development server runtime is unavailable during a production build.` + ) + ); + }, + }; + } + + let state: ControllerState = { status: 'idle' }; + let nextSessionId = 1; + const identityByCompilation = new WeakMap< + Rspack.Compilation, + DevCompilationIdentity + >(); + const webIdentityByNodeCompilation = new WeakMap< + Rspack.Compilation, + DevCompilationIdentity + >(); + const closeObservationByServer = new WeakMap< + RsbuildDevServer, + CloseObservation + >(); + + const getCompilationIdentity = ( + compilation: Rspack.Compilation + ): DevCompilationIdentity => { + const existing = identityByCompilation.get(compilation); + if (existing) { + return existing; + } + const identity = Symbol(); + // Keep compact lineage tokens in committed state without retaining entire + // Rspack compilation graphs across failed rebuilds. + identityByCompilation.set(compilation, identity); + return identity; + }; + + const getActiveBinding = (): RuntimeBinding | undefined => + state.status === 'active' ? state.binding : undefined; + + const isCurrentBinding = (binding: RuntimeBinding): boolean => + (state.status === 'active' || state.status === 'closing') && + state.binding === binding; + + const closeBinding = (binding: RuntimeBinding, error?: Error): void => { + const pair = binding.compilers; + if (pair) { + pair.pendingAttempt = undefined; + pair.latestCompletedWebIdentity = undefined; + pair.latestWebStart = undefined; + pair.latestNodeStart = undefined; + } + binding.compilers = undefined; + binding.runtime.close(error); + unregisterReactRouterDevRuntime(binding.server, binding.runtime); + }; + + const completeClose = (binding: RuntimeBinding): void => { + if (!isCurrentBinding(binding)) { + return; + } + if (state.status === 'active') { + closeBinding(binding); + } + state = { status: 'idle' }; + }; + + const failClose = (binding: RuntimeBinding, cause: unknown): void => { + if (!isCurrentBinding(binding)) { + return; + } + const error = new Error( + `[${PLUGIN_NAME}] The previous development server failed to close. Restart the process before retrying because Rsbuild may not have finished tearing down its compiler and watchers.`, + { cause } + ); + closeBinding(binding, error); + state = { status: 'terminal', error }; + }; + + const applyCloseOutcome = ( + observation: CloseObservation, + outcome: CloseOutcome + ): void => { + observation.outcome = outcome; + const { binding } = observation; + if (!binding) { + return; + } + if (outcome.ok) { + completeClose(binding); + } else { + failClose(binding, outcome.cause); + } + observation.binding = undefined; + }; + + const observeClose = (server: RsbuildDevServer): CloseObservation => { + const existing = closeObservationByServer.get(server); + if (existing) { + return existing; + } + const observation: CloseObservation = {}; + const close = server.close.bind(server); + server.close = () => { + if (observation.promise) { + return observation.promise; + } + let closePromise: Promise; + try { + closePromise = close(); + } catch (cause) { + closePromise = Promise.reject(cause); + } + observation.promise = closePromise; + void closePromise.then( + () => applyCloseOutcome(observation, { ok: true }), + cause => applyCloseOutcome(observation, { ok: false, cause }) + ); + return closePromise; + }; + closeObservationByServer.set(server, observation); + return observation; + }; + + const bindCloseObservation = (binding: RuntimeBinding): void => { + const observation = observeClose(binding.server); + observation.binding = binding; + if (observation.outcome) { + applyCloseOutcome(observation, observation.outcome); + } + }; + + const flushSettledAttempt = ( + binding: RuntimeBinding, + pair: DevCompilerPair + ): void => { + const pending = pair.pendingAttempt; + if ( + !pending || + getActiveBinding()?.id !== binding.id || + !pair.settledCompilations.has(pending.webCompilation) || + !pair.settledCompilations.has(pending.nodeCompilation) + ) { + return; + } + pair.pendingAttempt = undefined; + if ( + !isLatestStartedCompilation(pending.identity.web, pair.latestWebStart) || + !isLatestStartedCompilation(pending.identity.node, pair.latestNodeStart) + ) { + return; + } + void binding.runtime + .finishAttempt(pending.stats, pending.changes, pending.identity) + .catch(cause => { + if (getActiveBinding()?.id === binding.id) { + binding.runtime.failAttempt( + cause instanceof Error ? cause : new Error(String(cause)) + ); + } + }); + }; + + const rejectUnsupportedCompiler = (reason: string): void => { + const message = `[${PLUGIN_NAME}] Could not coordinate React Router development output because ${reason}.`; + api.logger.warn(message); + const binding = getActiveBinding(); + if (!binding) { + return; + } + const error = new Error(message); + closeBinding(binding, error); + state = { status: 'terminal', error }; + }; + + // Rsbuild runs server.setup before onBeforeStartDevServer. Prepending the + // observer here ensures setup callbacks cannot capture an unobserved close. + api.modifyRsbuildConfig({ + order: 'post', + handler(config) { + const existingSetup = config.server?.setup; + const setup = existingSetup + ? Array.isArray(existingSetup) + ? existingSetup + : [existingSetup] + : []; + const observeServer: ServerSetup = context => { + if (context.action === 'dev') { + observeClose(context.server); + } + }; + return { + ...config, + server: { + ...config.server, + setup: [observeServer, ...setup], + }, + }; + }, + }); + + api.onBeforeStartDevServer({ + order: 'pre', + async handler({ server }) { + if (state.status === 'terminal') { + throw state.error; + } + if (state.status === 'active') { + throw new Error( + `[${PLUGIN_NAME}] A development server is already active. Await its close() before calling createDevServer() again. If startup failed before returning the server, restart the process before retrying.` + ); + } + if (state.status === 'closing') { + throw new Error( + `[${PLUGIN_NAME}] The previous development server is still closing. Await its close() before calling createDevServer() again.` + ); + } + const runtime = createReactRouterDevRuntime({ + server, + buildPlan, + onEvaluationError(error) { + if (getActiveBinding()?.runtime !== runtime) { + return; + } + api.logger.error(error.message); + server.sockWrite('errors', { + text: [error.message], + html: escapeHtml(error.message), + }); + }, + onWarning: message => api.logger.warn(message), + }); + const binding = { id: nextSessionId++, server, runtime }; + state = { status: 'active', binding }; + registerReactRouterDevRuntime(server, runtime); + bindCloseObservation(binding); + }, + }); + + api.onCloseDevServer({ + order: 'pre', + handler() { + if (state.status !== 'active') { + return; + } + const binding = state.binding; + closeBinding(binding); + state = { status: 'closing', binding }; + }, + }); + + api.onBeforeDevCompile({ + order: 'pre', + handler() { + const binding = getActiveBinding(); + const pair = binding?.compilers; + if (!binding || !pair || hasPendingCompilation(pair)) { + return; + } + pair.pendingAttempt = undefined; + binding.runtime.beginAttempt(); + }, + }); + + api.onAfterCreateCompiler(({ compiler }) => { + if (!('compilers' in compiler)) { + rejectUnsupportedCompiler('Rsbuild did not create a multi-compiler'); + return; + } + const web = compiler.compilers.find(item => item.name === 'web'); + const node = compiler.compilers.find(item => item.name === 'node'); + if (!web || !node) { + rejectUnsupportedCompiler('the web or node compiler was missing'); + return; + } + const binding = getActiveBinding(); + if (!binding) { + return; + } + const pair: DevCompilerPair = { + web, + node, + settledCompilations: new WeakSet(), + }; + binding.compilers = pair; + const sessionId = binding.id; + const runtime = binding.runtime; + const failCurrentAttempt = (side: 'web' | 'node', error: Error): void => { + if (getActiveBinding()?.id === sessionId) { + if (side === 'web') { + pair.latestWebStart = undefined; + } else { + pair.latestNodeStart = undefined; + } + pair.pendingAttempt = undefined; + runtime.failAttempt(error); + } + }; + const beginCompilerAttempt = ( + side: 'latestWebStart' | 'latestNodeStart' + ): void => { + if ( + getActiveBinding()?.id === sessionId && + pair[side]?.status !== 'pending' + ) { + const attemptAlreadyPending = hasPendingCompilation(pair); + // Invalidation can arrive before the aggregate before-compile hook. + // Supersede any evaluation that could resolve in that gap immediately. + pair[side] = { status: 'pending' }; + pair.pendingAttempt = undefined; + if (!attemptAlreadyPending) { + runtime.beginAttempt(); + } + } + }; + web.hooks.invalid.tap(`${PLUGIN_NAME}:dev-web-invalid`, () => + beginCompilerAttempt('latestWebStart') + ); + node.hooks.invalid.tap(`${PLUGIN_NAME}:dev-node-invalid`, () => + beginCompilerAttempt('latestNodeStart') + ); + web.hooks.done.tap( + { name: `${PLUGIN_NAME}:dev-web-complete`, stage: -1000 }, + stats => { + if (getActiveBinding()?.id !== sessionId) { + return; + } + pair.latestCompletedWebIdentity = getCompilationIdentity( + stats.compilation + ); + } + ); + web.hooks.thisCompilation.tap( + `${PLUGIN_NAME}:dev-web-compilation`, + compilation => { + if (getActiveBinding()?.id === sessionId) { + pair.latestWebStart = { + status: 'started', + identity: getCompilationIdentity(compilation), + }; + } + } + ); + node.hooks.thisCompilation.tap( + `${PLUGIN_NAME}:dev-node-web-compilation`, + compilation => { + if (getActiveBinding()?.id !== sessionId) { + return; + } + pair.latestNodeStart = { + status: 'started', + identity: getCompilationIdentity(compilation), + }; + if (pair.latestCompletedWebIdentity) { + webIdentityByNodeCompilation.set( + compilation, + pair.latestCompletedWebIdentity + ); + } + } + ); + const settleCompilation = (stats: Rspack.Stats): void => { + if (getActiveBinding()?.id !== sessionId) { + return; + } + pair.settledCompilations.add(stats.compilation); + flushSettledAttempt(binding, pair); + }; + web.hooks.afterDone.tap( + `${PLUGIN_NAME}:dev-web-settled`, + settleCompilation + ); + node.hooks.afterDone.tap( + `${PLUGIN_NAME}:dev-node-settled`, + settleCompilation + ); + web.hooks.failed.tap(`${PLUGIN_NAME}:dev-web-failed`, error => + failCurrentAttempt('web', error) + ); + node.hooks.failed.tap(`${PLUGIN_NAME}:dev-node-failed`, error => + failCurrentAttempt('node', error) + ); + }); + + api.onAfterDevCompile(async ({ stats }) => { + const binding = getActiveBinding(); + const pair = binding?.compilers; + if (!binding || !pair) { + return; + } + const webStats = getEnvironmentStats(stats, 'web'); + const nodeStats = getEnvironmentStats(stats, 'node'); + if ( + (webStats && webStats.compilation.compiler !== pair.web) || + (nodeStats && nodeStats.compilation.compiler !== pair.node) + ) { + return; + } + const webIdentity = webStats + ? getCompilationIdentity(webStats.compilation) + : undefined; + const nodeIdentity = nodeStats + ? getCompilationIdentity(nodeStats.compilation) + : undefined; + if ( + !isLatestStartedCompilation(webIdentity, pair.latestWebStart) || + !isLatestStartedCompilation(nodeIdentity, pair.latestNodeStart) + ) { + return; + } + const changes = { + web: snapshotDevChangedFiles(pair.web), + node: snapshotDevChangedFiles(pair.node), + }; + const identity = { + web: webIdentity, + node: nodeIdentity, + nodeWeb: nodeStats + ? webIdentityByNodeCompilation.get(nodeStats.compilation) + : undefined, + }; + if (!webStats || !nodeStats) { + await binding.runtime.finishAttempt(stats, changes, identity); + return; + } + pair.pendingAttempt = { + stats, + changes, + identity, + webCompilation: webStats.compilation, + nodeCompilation: nodeStats.compilation, + }; + flushSettledAttempt(binding, pair); + }); + + return { + captureWeb(compilation, manifestsByEntryName): void { + const binding = getActiveBinding(); + if (binding?.compilers?.web === compilation.compiler) { + binding.runtime.captureWeb(compilation, manifestsByEntryName); + } + }, + + createBuildLoader(entryName?: string): () => Promise { + const server = getActiveBinding()?.server; + if (server) { + return () => loadReactRouterServerBuild(server, entryName); + } + if (state.status === 'terminal') { + const { error } = state; + return () => Promise.reject(error); + } + return () => + Promise.reject( + new Error( + `[${PLUGIN_NAME}] The development server runtime is not ready.` + ) + ); + }, + }; +}; diff --git a/src/dev-server.ts b/src/dev-server.ts index 38d35e0..daff753 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -1,53 +1,57 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { loadReactRouterServerBuild } from './dev-generation-coordinator.js'; -import { normalizeBuildModule, resolveBuildExports } from './server-utils.js'; +import type { ServerBuild } from 'react-router'; export type DevServerMiddleware = ( req: IncomingMessage, res: ServerResponse, - next: (err?: any) => void + next: (err?: unknown) => void ) => Promise; -export const createDevServerMiddleware = (server: any): DevServerMiddleware => { - return async ( - req: IncomingMessage, - res: ServerResponse, - next: (err?: any) => void - ): Promise => { - try { - const bundle = await loadReactRouterServerBuild(server); +type RequestHandler = (request: Request) => Response | Promise; +type BuildProvider = () => Promise; + +export type DevServerMiddlewareDependencies = { + loadBuild: BuildProvider; + createRequestHandler?: ( + build: BuildProvider, + mode: 'development' + ) => RequestHandler; + createRequestListener?: ( + handler: RequestHandler + ) => (req: IncomingMessage, res: ServerResponse) => void | Promise; +}; - if (!bundle || !bundle.routes) { - throw new Error('Server bundle not found or invalid'); - } +export const createDevServerMiddleware = ( + dependencies: DevServerMiddlewareDependencies +): DevServerMiddleware => { + let listenerPromise: + | Promise< + (req: IncomingMessage, res: ServerResponse) => void | Promise + > + | undefined; - // Use the modern request listener implementation directly to reduce - // our reliance on the deprecated `@mjackson/node-fetch-server` package. - const rr = await import('react-router'); - const nfs = await import('@remix-run/node-fetch-server'); - if (typeof rr.createRequestHandler !== 'function') { - throw new Error( - '[rsbuild-plugin-react-router] Missing `createRequestHandler` export from `react-router`' - ); - } - if (typeof nfs.createRequestListener !== 'function') { - throw new Error( - '[rsbuild-plugin-react-router] Missing `createRequestListener` export from `@remix-run/node-fetch-server`' - ); - } - const normalizedBuild = normalizeBuildModule(bundle); - const build = await resolveBuildExports(normalizedBuild); - const requestHandler = rr.createRequestHandler(build, 'development'); - // `createRequestListener` provides `client` info but React Router's - // request handler expects an app-defined `loadContext` object. - // For the built-in dev middleware we don't currently provide a load - // context, so pass `undefined`. - const listener = nfs.createRequestListener(request => - requestHandler(request) + const getListener = () => { + listenerPromise ??= (async () => { + const createRequestHandler = + dependencies.createRequestHandler ?? + (await import('react-router')).createRequestHandler; + const createRequestListener = + dependencies.createRequestListener ?? + (await import('@remix-run/node-fetch-server')).createRequestListener; + const requestHandler = createRequestHandler( + dependencies.loadBuild, + 'development' ); + return createRequestListener(request => requestHandler(request)); + })(); + return listenerPromise; + }; + + return async (req, res, next): Promise => { + try { + const listener = await getListener(); await listener(req, res); } catch (error) { - console.error('SSR Error:', error); next(error); } }; diff --git a/src/index.ts b/src/index.ts index d14b0c2..00d66db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,18 +27,10 @@ import { normalizeAssetPrefix, } from './plugin-utils.js'; import type { PluginOptions } from './types.js'; -import { - ReactRouterDevGenerationCoordinator, - registerReactRouterDevServer, - unregisterReactRouterDevServer, - loadReactRouterServerBuild, - type ReactRouterDevManifest, - type ReactRouterServerBuild, -} from './dev-generation-coordinator.js'; import { generateServerBuild, - normalizeBuildModule, - resolveBuildExports, + resolveReactRouterServerBuild, + resolveServerBuildModule, } from './server-utils.js'; import { getPrerenderConcurrency, @@ -95,10 +87,12 @@ import { roundMs, } from './performance.js'; import { mapVirtualModules } from './virtual-modules.js'; +import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; -const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +export { loadReactRouterServerBuild } from './dev-generation.js'; +export { resolveReactRouterServerBuild }; -export { loadReactRouterServerBuild }; +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); type ModuleFederationPluginLike = { name?: string; @@ -157,7 +151,6 @@ export const pluginReactRouter = ( enabled: logPerformance, log: message => api.logger.info(message), }); - const nodeExternals = Array.from( new Set(['express', ...getSsrExternals(process.cwd())]) ); @@ -273,8 +266,11 @@ export const pluginReactRouter = ( } } - const { resolved: resolvedConfig, presets: configPresets } = - await resolveReactRouterConfig(reactRouterUserConfig); + const { + resolved: resolvedConfig, + presets: configPresets, + hasConfiguredServerModuleFormat, + } = await resolveReactRouterConfig(reactRouterUserConfig); const { appDirectory, @@ -287,7 +283,6 @@ export const pluginReactRouter = ( prerender: prerenderConfig, serverBuildFile, serverModuleFormat, - serverBundles, buildEnd, } = resolvedConfig; @@ -295,21 +290,22 @@ export const pluginReactRouter = ( options, 'serverOutput' ); - const resolvedServerOutput = hasExplicitServerOutput - ? options.serverOutput - : serverModuleFormat === 'cjs' - ? 'commonjs' - : 'module'; + let resolvedServerOutput = pluginOptions.serverOutput; + if (!hasExplicitServerOutput) { + resolvedServerOutput = + serverModuleFormat === 'cjs' ? 'commonjs' : 'module'; + } if ( hasExplicitServerOutput && + hasConfiguredServerModuleFormat && serverModuleFormat && - (options.serverOutput === 'commonjs' ? 'cjs' : 'esm') !== + (resolvedServerOutput === 'commonjs' ? 'cjs' : 'esm') !== serverModuleFormat ) { api.logger.warn( `[${PLUGIN_NAME}] Both \`serverOutput\` and \`serverModuleFormat\` are set. ` + - `Using \`serverOutput=${options.serverOutput}\` and ignoring ` + + `Using \`serverOutput=${resolvedServerOutput}\` and ignoring ` + `\`serverModuleFormat=${serverModuleFormat}\`.` ); } @@ -324,14 +320,6 @@ export const pluginReactRouter = ( ); } - if (serverBundles) { - api.logger.warn( - `[${PLUGIN_NAME}] \`serverBundles\` is configured. Rsbuild currently ` + - 'emits a single server bundle, but the build manifest will include the ' + - 'server bundle mapping for compatibility.' - ); - } - const prerenderConfigError = validatePrerenderConfig(prerenderConfig); if (prerenderConfigError) { throw new Error(prerenderConfigError); @@ -404,6 +392,9 @@ export const pluginReactRouter = ( resolve(appDirectory, '../server/index') ); const hasServerApp = existsSync(serverAppPath); + const devServerBuildEntryName = hasServerApp + ? 'static/js/react-router-server-build' + : 'static/js/app'; // Add fallback logic for entry files const templateDir = resolve(__dirname, 'templates'); @@ -548,20 +539,6 @@ export const pluginReactRouter = ( type ReactRouterManifest = Awaited< ReturnType >; - const devGenerationCoordinator = new ReactRouterDevGenerationCoordinator(); - let currentDevServer: object | undefined; - api.onBeforeStartDevServer(({ server }) => { - devGenerationCoordinator.resetStaging(); - currentDevServer = server as object; - registerReactRouterDevServer(currentDevServer, devGenerationCoordinator); - }); - api.onCloseDevServer(() => { - if (currentDevServer) { - unregisterReactRouterDevServer(currentDevServer); - currentDevServer = undefined; - } - devGenerationCoordinator.close(); - }); let latestBrowserManifest: ReactRouterManifest | null = null; let latestBrowserManifestModuleExports: RouteManifestModuleExports = {}; let latestServerManifest: ReactRouterManifest | null = null; @@ -610,126 +587,55 @@ export const pluginReactRouter = ( routes, rootDirectory: process.cwd(), }); - const routesByServerBundleId = getRoutesByServerBundleId(buildManifest); + const routesByServerBundleId = getRoutesByServerBundleId( + buildManifest, + routes + ); const serverBuildFileBase = (serverBuildFile || 'index.js').replace( /\.js$/, '' ); - const serverBuildEntryNames = [ + const serverBundleEntries = Object.entries(routesByServerBundleId) + .filter(([, bundleRoutes]) => + Boolean(bundleRoutes && Object.keys(bundleRoutes).length > 0) + ) + .map(([bundleId]) => ({ + bundleId, + entryName: `${bundleId}/${serverBuildFileBase}`, + })); + const reservedNodeEntryNames = new Set([ 'static/js/app', - ...Object.entries(routesByServerBundleId) - .filter(([, bundleRoutes]) => { - return bundleRoutes && Object.keys(bundleRoutes).length > 0; - }) - .map(([bundleId]) => `${bundleId}/${serverBuildFileBase}`), - ]; - - let clientStats: ReactRouterManifestStats | undefined; - const statsHasErrors = (stats: Rspack.Stats | undefined): boolean => { - return Boolean( - stats && typeof stats.hasErrors === 'function' - ? stats.hasErrors() - : false - ); - }; - const loadDevServerBuild = async ( - entryName: string - ): Promise => { - if (!currentDevServer) { - throw new Error( - `[${PLUGIN_NAME}] Cannot evaluate React Router server build before the Rsbuild dev server is registered.` - ); - } - const devServer = currentDevServer as { - environments?: { - node?: { - loadBundle?: (entryName: string) => Promise; - }; - }; - }; - const loadBundle = devServer.environments?.node?.loadBundle; - if (typeof loadBundle !== 'function') { - throw new Error( - `[${PLUGIN_NAME}] Rsbuild dev server does not expose node.loadBundle().` - ); - } - - try { - const bundle = await loadBundle(entryName); - const normalizedBuild = normalizeBuildModule( - bundle as Record - ); - const build = await resolveBuildExports(normalizedBuild); - if (!build || !build.routes) { - throw new Error( - `[${PLUGIN_NAME}] Server build "${entryName}" is missing React Router routes.` - ); - } - return build as ReactRouterServerBuild; - } catch (error) { - if ( - error instanceof Error && - error.message.includes("Can't find entry") - ) { - return null; - } - throw error; - } - }; - const stageNodeServerBuilds = async ( - stats: Rspack.Stats | undefined - ): Promise => { - const buildsByEntryName: Record = {}; - for (const entryName of serverBuildEntryNames) { - const build = await loadDevServerBuild(entryName); - if (build) { - buildsByEntryName[entryName] = build; - } - } - - if (Object.keys(buildsByEntryName).length === 0) { + 'static/js/entry.server', + devServerBuildEntryName, + ]); + for (const { entryName } of serverBundleEntries) { + if (reservedNodeEntryNames.has(entryName)) { throw new Error( - `[${PLUGIN_NAME}] React Router server build not found.` + `[${PLUGIN_NAME}] Server bundle entry ${JSON.stringify(entryName)} conflicts with a reserved node entry.` ); } - - const nodeStage = devGenerationCoordinator.stageNode({ - stats, - buildsByEntryName, - }); - devGenerationCoordinator.commit( - devGenerationCoordinator.getLatestWebStage(), - nodeStage - ); + reservedNodeEntryNames.add(entryName); + } + const devBuildPlan = { + defaultEntryName: devServerBuildEntryName, + entryNames: [ + devServerBuildEntryName, + ...serverBundleEntries.map(({ entryName }) => entryName), + ], }; + const devRuntime = createReactRouterDevRuntimeController({ + api, + isBuild, + buildPlan: devBuildPlan, + }); - api.onAfterEnvironmentCompile(async ({ stats, environment }) => { + let clientStats: ReactRouterManifestStats | undefined; + api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { clientStats = createReactRouterManifestStats( stats?.compilation, manifestChunkNames ); - if (!isBuild && statsHasErrors(stats)) { - devGenerationCoordinator.reject( - new Error(`[${PLUGIN_NAME}] Web compilation has errors.`) - ); - } - } - if (!isBuild && environment.name === 'node') { - if (statsHasErrors(stats)) { - devGenerationCoordinator.reject( - new Error(`[${PLUGIN_NAME}] Node compilation has errors.`) - ); - } else { - try { - await stageNodeServerBuilds(stats); - } catch (error) { - devGenerationCoordinator.reject(error); - api.logger.error( - error instanceof Error ? error.message : String(error) - ); - } - } } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -1146,8 +1052,10 @@ export const pluginReactRouter = ( const buildModule = await import( pathToFileURL(serverBuildPath).toString() ); - const normalizedBuild = normalizeBuildModule(buildModule as any); - const build = await resolveBuildExports(normalizedBuild); + const build = await resolveServerBuildModule( + buildModule, + `Server build ${JSON.stringify(serverBuildPath)}` + ); const requestHandler = createRequestHandler(build, 'production'); if (isPrerenderEnabled) { @@ -1361,30 +1269,25 @@ export const pluginReactRouter = ( const vmodPlugin = createVirtualModulePlugin(assetPrefix); const useAsyncNodeChunkLoading = options.federation && resolvedServerOutput === 'commonjs'; - const nodeChunkLoading = - resolvedServerOutput === 'module' - ? 'import' - : useAsyncNodeChunkLoading - ? 'async-node' - : 'require'; + let nodeChunkLoading: 'import' | 'async-node' | 'require' = 'require'; + if (resolvedServerOutput === 'module') { + nodeChunkLoading = 'import'; + } else if (useAsyncNodeChunkLoading) { + nodeChunkLoading = 'async-node'; + } const nodeEntries: Record = { - ...(hasServerApp + 'static/js/app': hasServerApp + ? serverAppPath + : 'virtual/react-router/server-build', + ...(hasServerApp && !isBuild ? { - 'static/js/app': serverAppPath, + [devServerBuildEntryName]: 'virtual/react-router/server-build', } - : { - 'static/js/app': 'virtual/react-router/server-build', - }), + : {}), 'static/js/entry.server': finalEntryServerPath, }; - - for (const [bundleId, bundleRoutes] of Object.entries( - routesByServerBundleId - )) { - if (!bundleRoutes || Object.keys(bundleRoutes).length === 0) { - continue; - } - nodeEntries[`${bundleId}/${serverBuildFileBase}`] = + for (const { bundleId, entryName } of serverBundleEntries) { + nodeEntries[entryName] = `virtual/react-router/server-build-${bundleId}`; } @@ -1423,12 +1326,12 @@ export const pluginReactRouter = ( pluginOptions.customServer || !ssr ? [] : [ - (middlewares, server) => { - registerReactRouterDevServer( - server as object, - devGenerationCoordinator + middlewares => { + middlewares.push( + createDevServerMiddleware({ + loadBuild: devRuntime.createBuildLoader(), + }) ); - middlewares.push(createDevServerMiddleware(server)); }, ], }, @@ -1534,20 +1437,25 @@ export const pluginReactRouter = ( ensureFederationAsyncStartup(rspackConfig); } - if (name === 'node' && resolvedServerOutput === 'module') { + if (name === 'node') { const output = rspackConfig.output; - const library = output?.library; - const libraryType = - library && - typeof library === 'object' && - !Array.isArray(library) && - 'type' in library - ? library.type - : undefined; - if (output && libraryType === 'commonjs2') { + if (output) { + const library = output.library; + const libraryOptions = + library && + typeof library === 'object' && + !Array.isArray(library) + ? library + : {}; rspackConfig.output = { ...output, - library: { type: 'module' }, + library: { + ...libraryOptions, + type: + resolvedServerOutput === 'module' + ? 'module' + : 'commonjs2', + }, }; } } @@ -1582,14 +1490,15 @@ export const pluginReactRouter = ( sri, }; latestServerManifest = baseServerManifest; - const nextServerManifestsByBundleId: Record< - string, - ReactRouterManifest - > = {}; - for (const [ + const manifestsByEntryName = { + [devServerBuildEntryName]: baseServerManifest, + } as Record; + for (const { bundleId, - bundleRoutes, - ] of Object.entries(routesByServerBundleId)) { + entryName, + } of serverBundleEntries) { + const bundleRoutes = + routesByServerBundleId[bundleId]; if (!bundleRoutes) { continue; } @@ -1601,29 +1510,19 @@ export const pluginReactRouter = ( ([routeId]) => routeIds.has(routeId) ) ); - nextServerManifestsByBundleId[bundleId] = { + const bundleManifest = { ...baseServerManifest, routes: filteredRoutes, }; + latestServerManifestsByBundleId[bundleId] = + bundleManifest; + manifestsByEntryName[entryName] = bundleManifest; } - Object.assign( - latestServerManifestsByBundleId, - nextServerManifestsByBundleId - ); if (!isBuild) { - devGenerationCoordinator.stageWeb({ - compilation: manifestContext.compilation, - browserManifest: - manifest as ReactRouterDevManifest, - serverManifest: - baseServerManifest as ReactRouterDevManifest, - serverManifestsByBundleId: - nextServerManifestsByBundleId as Record< - string, - ReactRouterDevManifest - >, - moduleExportsByRouteId, - }); + devRuntime.captureWeb( + manifestContext.compilation, + manifestsByEntryName + ); } } ); @@ -1675,20 +1574,10 @@ export const pluginReactRouter = ( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); const bundleId = bundleMatch?.[1]?.replace(/\.js$/, ''); - const stagedWeb = !isBuild - ? devGenerationCoordinator.getLatestWebStage() - : null; - const manifest = - (!isBuild && stagedWeb - ? bundleId && stagedWeb.serverManifestsByBundleId[bundleId] - ? stagedWeb.serverManifestsByBundleId[bundleId] - : stagedWeb.serverManifest - : null) ?? - (isBuild && latestServerManifest - ? bundleId && latestServerManifestsByBundleId[bundleId] - ? latestServerManifestsByBundleId[bundleId] - : latestServerManifest + (latestServerManifest + ? ((bundleId && latestServerManifestsByBundleId[bundleId]) ?? + latestServerManifest) : null) ?? (await getReactRouterManifestForDev( routes, diff --git a/src/react-router-config.ts b/src/react-router-config.ts index 76ad529..238bb31 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -120,6 +120,7 @@ export const resolveReactRouterConfig = async ( ): Promise<{ resolved: ResolvedReactRouterConfig; presets: NonNullable; + hasConfiguredServerModuleFormat: boolean; }> => { const presets = await Promise.all( (reactRouterUserConfig.presets ?? []).map(async preset => { @@ -161,7 +162,6 @@ export const resolveReactRouterConfig = async ( DEFAULT_CONFIG.allowedActionOrigins, routes: DEFAULT_CONFIG.routes, unstable_routeConfig: DEFAULT_CONFIG.unstable_routeConfig, - serverBundles: DEFAULT_CONFIG.serverBundles, }; if (!resolved.ssr) { resolved = { @@ -173,5 +173,7 @@ export const resolveReactRouterConfig = async ( return { resolved, presets: reactRouterUserConfig.presets ?? [], + hasConfiguredServerModuleFormat: + userAndPresetConfigs.serverModuleFormat !== undefined, }; }; diff --git a/src/server-utils.ts b/src/server-utils.ts index 43d28f5..7f51791 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -1,4 +1,5 @@ import { resolve } from 'pathe'; +import type { ServerBuild } from 'react-router'; import type { Route } from './types.js'; /** @@ -105,30 +106,28 @@ const RESOLVABLE_BUILD_EXPORTS = new Set([ 'ssr', ]); -const isPromiseLike = (value: unknown): value is Promise => - typeof (value as Promise)?.then === 'function'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} -export const normalizeBuildModule = >( - build: T -): T => { - if (!build || typeof build !== 'object') { - return build; - } - if ( - 'default' in build && - Object.keys(build).length === 1 && - typeof build.default === 'object' && - build.default - ) { - return build.default as T; - } - return build; -}; +function isPromiseLike(value: unknown): value is PromiseLike { + return isRecord(value) && typeof value.then === 'function'; +} + +function isRouteDiscovery(value: unknown): boolean { + return ( + isRecord(value) && + (value.mode === 'initial' || + (value.mode === 'lazy' && + (value.manifestPath === undefined || + typeof value.manifestPath === 'string'))) + ); +} -export const resolveBuildExports = async >( - build: T -): Promise => { - const resolved: Record = { ...build }; +async function resolveBuildExports( + build: Record +): Promise> { + const resolved = { ...build }; for (const key of Object.keys(build)) { if (!RESOLVABLE_BUILD_EXPORTS.has(key)) { continue; @@ -143,7 +142,64 @@ export const resolveBuildExports = async >( resolved[key] = await value; } } - return resolved as T; -}; + return resolved; +} + +function isServerBuild(value: unknown): value is ServerBuild { + return Boolean( + isRecord(value) && + isRecord(value.entry) && + isRecord(value.entry.module) && + typeof value.entry.module.default === 'function' && + isRecord(value.routes) && + isRecord(value.assets) && + typeof value.assetsBuildDirectory === 'string' && + (value.basename === undefined || typeof value.basename === 'string') && + isRecord(value.future) && + typeof value.isSpaMode === 'boolean' && + Array.isArray(value.prerender) && + typeof value.publicPath === 'string' && + isRouteDiscovery(value.routeDiscovery) && + typeof value.ssr === 'boolean' + ); +} + +async function resolveServerBuildCandidate( + candidate: unknown +): Promise { + if (!isRecord(candidate)) { + return undefined; + } + const resolved = await resolveBuildExports(candidate); + return isServerBuild(resolved) ? resolved : undefined; +} + +export async function resolveServerBuildModule( + buildModule: unknown, + source: string +): Promise { + const moduleValue = await buildModule; + const direct = await resolveServerBuildCandidate(moduleValue); + if (direct) { + return direct; + } + if (isRecord(moduleValue) && 'default' in moduleValue) { + const fromDefault = await resolveServerBuildCandidate( + await moduleValue.default + ); + if (fromDefault) { + return fromDefault; + } + } + throw new Error( + `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` + ); +} + +export function resolveReactRouterServerBuild( + buildModule: unknown +): Promise { + return resolveServerBuildModule(buildModule, 'Imported module'); +} export { generateServerBuild }; diff --git a/tests/build-manifest.test.ts b/tests/build-manifest.test.ts index 0dd363e..64623a8 100644 --- a/tests/build-manifest.test.ts +++ b/tests/build-manifest.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from '@rstest/core'; import type { Config } from '../src/react-router-config'; -import { getBuildManifest } from '../src/build-manifest'; +import { + getBuildManifest, + getRoutesByServerBundleId, +} from '../src/build-manifest'; describe('build manifest', () => { it('returns routes only when serverBundles is not configured', async () => { @@ -52,6 +55,9 @@ describe('build manifest', () => { expect(result).toHaveProperty('serverBundles'); expect(result).toHaveProperty('routeIdToServerBundleId'); expect(result?.routes.root.file).toBeDefined(); + expect( + getRoutesByServerBundleId(result, routes).bundle_2['routes/about'].file + ).toBe('routes/about.tsx'); }); it('validates server bundle IDs based on vite environment API flag', async () => { diff --git a/tests/dev-generation-coordinator.test.ts b/tests/dev-generation-coordinator.test.ts deleted file mode 100644 index 43adf55..0000000 --- a/tests/dev-generation-coordinator.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; -import { - ReactRouterDevGenerationCoordinator, - loadReactRouterServerBuild, - registerReactRouterDevServer, -} from '../src/dev-generation-coordinator'; - -const createManifest = (hasLoader: boolean) => - ({ - version: hasLoader ? 'with-loader' : 'without-loader', - url: '/static/js/manifest.js', - hmr: undefined, - routes: { - 'routes/page': { - id: 'routes/page', - module: '/app/routes/page.tsx', - hasLoader, - }, - }, - }) as any; - -const createBuild = (assets: any, loader?: () => unknown) => - ({ - assets, - routes: { - 'routes/page': { - id: 'routes/page', - module: loader ? { loader } : {}, - }, - }, - }) as any; - -describe('ReactRouterDevGenerationCoordinator', () => { - it('normalizes stale embedded manifests to the staged web candidate', () => { - const coordinator = new ReactRouterDevGenerationCoordinator(); - const firstManifest = createManifest(false); - const firstWeb = coordinator.stageWeb({ - browserManifest: firstManifest, - serverManifest: firstManifest, - serverManifestsByBundleId: {}, - moduleExportsByRouteId: {}, - }); - const firstNode = coordinator.stageNode({ - buildsByEntryName: { - 'static/js/app': createBuild(firstManifest), - }, - }); - const firstGeneration = coordinator.commit(firstWeb, firstNode); - - const nextManifest = createManifest(true); - const nextWeb = coordinator.stageWeb({ - browserManifest: nextManifest, - serverManifest: nextManifest, - serverManifestsByBundleId: {}, - moduleExportsByRouteId: {}, - }); - const staleBuild = createBuild(firstManifest, () => 'data'); - const mismatchedNode = coordinator.stageNode({ - buildsByEntryName: { - 'static/js/app': staleBuild, - }, - }); - - const nextGeneration = coordinator.commit(nextWeb, mismatchedNode); - expect(nextGeneration).not.toBe(firstGeneration); - expect(staleBuild.assets).toBe(nextManifest); - expect(coordinator.getCommitted()).toBe(nextGeneration); - }); - - it('keeps serving the last committed build after a rejected candidate', async () => { - const coordinator = new ReactRouterDevGenerationCoordinator(); - const server = {}; - const manifest = createManifest(false); - const build = createBuild(manifest); - - registerReactRouterDevServer(server, coordinator); - coordinator.commit( - coordinator.stageWeb({ - browserManifest: manifest, - serverManifest: manifest, - serverManifestsByBundleId: {}, - moduleExportsByRouteId: {}, - }), - coordinator.stageNode({ - buildsByEntryName: { - 'static/js/app': build, - }, - }) - ); - coordinator.reject(new Error('candidate failed')); - - await expect(loadReactRouterServerBuild(server)).resolves.toBe(build); - }); - - it('rejects node-new web-old route export mismatches even when the embedded manifest is old-coherent', () => { - const coordinator = new ReactRouterDevGenerationCoordinator(); - const manifest = createManifest(false); - const web = coordinator.stageWeb({ - browserManifest: manifest, - serverManifest: manifest, - serverManifestsByBundleId: {}, - moduleExportsByRouteId: {}, - }); - const node = coordinator.stageNode({ - buildsByEntryName: { - 'static/js/app': createBuild(manifest, () => 'data'), - }, - }); - - expect(() => coordinator.commit(web, node)).toThrow( - 'loader export does not match the staged web manifest' - ); - expect(coordinator.getCommitted()).toBeNull(); - }); - - it('custom server helper waits for the initial committed generation', async () => { - const coordinator = new ReactRouterDevGenerationCoordinator(); - const server = {}; - const manifest = createManifest(false); - const build = createBuild(manifest); - registerReactRouterDevServer(server, coordinator); - - const pendingBuild = loadReactRouterServerBuild(server); - let resolved = false; - pendingBuild.then(() => { - resolved = true; - }); - await Promise.resolve(); - expect(resolved).toBe(false); - - coordinator.commit( - coordinator.stageWeb({ - browserManifest: manifest, - serverManifest: manifest, - serverManifestsByBundleId: {}, - moduleExportsByRouteId: {}, - }), - coordinator.stageNode({ - buildsByEntryName: { - 'static/js/app': build, - }, - }) - ); - - await expect(pendingBuild).resolves.toBe(build); - }); -}); diff --git a/tests/dev-generation-multi-entry.test.ts b/tests/dev-generation-multi-entry.test.ts new file mode 100644 index 0000000..64e3e7c --- /dev/null +++ b/tests/dev-generation-multi-entry.test.ts @@ -0,0 +1,284 @@ +import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import { describe, expect, it, rstest } from '@rstest/core'; +import type { ServerBuild } from 'react-router'; +import { + createReactRouterDevRuntime, + type DevGraphChanges, + type DevGraphIdentity, +} from '../src/dev-generation'; + +const noKnownChanges: DevGraphChanges = { + web: { known: false, files: new Set() }, + node: { known: false, files: new Set() }, +}; + +const identityByCompilation = new WeakMap(); + +const getCompilationIdentity = (compilation: Rspack.Compilation): symbol => { + const existing = identityByCompilation.get(compilation); + if (existing) { + return existing; + } + const identity = Symbol(); + identityByCompilation.set(compilation, identity); + return identity; +}; + +const graphIdentity = ( + webCompilation: Rspack.Compilation, + nodeCompilation: Rspack.Compilation +): DevGraphIdentity => ({ + web: getCompilationIdentity(webCompilation), + node: getCompilationIdentity(nodeCompilation), + nodeWeb: getCompilationIdentity(webCompilation), +}); + +type TestServerBuild = ServerBuild & { marker: string }; + +const createBuild = (marker: string): TestServerBuild => + ({ + entry: { module: { default: () => new Response() } }, + routes: {}, + assets: { routes: {}, version: marker }, + assetsBuildDirectory: '/app/build/client', + basename: '/', + future: {}, + isSpaMode: false, + marker, + prerender: [], + publicPath: '/', + routeDiscovery: { mode: 'initial' }, + ssr: true, + }) as unknown as TestServerBuild; + +const createCompilation = (name: 'web' | 'node') => + ({ + name, + buildDependencies: new Set(), + fileDependencies: new Set(), + contextDependencies: new Set(), + missingDependencies: new Set(), + }) as unknown as Rspack.Compilation; + +const createStats = (compilation: Rspack.Compilation) => + ({ compilation, hasErrors: () => false }) as Rspack.Stats; + +const createGraphStats = ( + webCompilation: Rspack.Compilation, + nodeCompilation: Rspack.Compilation +) => + ({ + stats: [createStats(webCompilation), createStats(nodeCompilation)], + }) as Rspack.MultiStats; + +describe('React Router multi-entry development generations', () => { + it('publishes and selects a complete multi-entry generation deterministically', async () => { + const defaultEntry = 'static/js/react-router-server-build'; + const bundleEntry = 'bundle/nested/index'; + const rawDefault = createBuild('default-raw'); + const rawBundle = createBuild('bundle-raw'); + const loadBundle = rstest.fn((entryName: string) => + entryName === defaultEntry ? rawDefault : rawBundle + ); + const server = { + environments: { node: { loadBundle } }, + } as unknown as RsbuildDevServer; + const runtime = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: defaultEntry, + // Deliberately list the bundle first: default selection must be + // explicit rather than depend on object insertion order. + entryNames: [bundleEntry, defaultEntry], + }, + onEvaluationError() {}, + }); + const web = createCompilation('web'); + const node = createCompilation('node'); + + runtime.beginAttempt(); + runtime.captureWeb(web, { + [defaultEntry]: { routes: {}, version: 'default-web' }, + [bundleEntry]: { routes: {}, version: 'bundle-web' }, + }); + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + await expect(runtime.load()).resolves.toMatchObject({ + marker: 'default-raw', + assets: { version: 'default-web' }, + }); + await expect(runtime.load(bundleEntry)).resolves.toMatchObject({ + marker: 'bundle-raw', + assets: { version: 'bundle-web' }, + }); + await expect(runtime.load('missing/entry')).rejects.toThrow( + 'not part of this development server build plan' + ); + expect(rawDefault.assets).toMatchObject({ version: 'default-raw' }); + expect(rawBundle.assets).toMatchObject({ version: 'bundle-raw' }); + }); + + it('keeps every last-good entry while a new multi-entry candidate is incomplete', async () => { + const defaultEntry = 'static/js/app'; + const bundleEntry = 'bundle/index'; + let generation = 'a'; + let resolveBundle!: (build: ServerBuild) => void; + const loadBundle = rstest.fn((entryName: string) => { + if (generation === 'b' && entryName === bundleEntry) { + return new Promise(resolve => { + resolveBundle = resolve; + }); + } + return createBuild(`${entryName}-${generation}`); + }); + const server = { + environments: { node: { loadBundle } }, + } as unknown as RsbuildDevServer; + const runtime = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: defaultEntry, + entryNames: [defaultEntry, bundleEntry], + }, + onEvaluationError() {}, + }); + const webA = createCompilation('web'); + const nodeA = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(webA, { + [defaultEntry]: { routes: {}, version: 'default-a' }, + [bundleEntry]: { routes: {}, version: 'bundle-a' }, + }); + await runtime.finishAttempt( + createGraphStats(webA, nodeA), + noKnownChanges, + graphIdentity(webA, nodeA) + ); + const committedDefault = await runtime.load(); + const committedBundle = await runtime.load(bundleEntry); + + generation = 'b'; + const webB = createCompilation('web'); + const nodeB = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(webB, { + [defaultEntry]: { routes: {}, version: 'default-b' }, + [bundleEntry]: { routes: {}, version: 'bundle-b' }, + }); + const finishing = runtime.finishAttempt( + createGraphStats(webB, nodeB), + noKnownChanges, + graphIdentity(webB, nodeB) + ); + await Promise.resolve(); + + expect(await runtime.load()).toBe(committedDefault); + expect(await runtime.load(bundleEntry)).toBe(committedBundle); + + resolveBundle(createBuild(`${bundleEntry}-b`)); + await finishing; + await expect(runtime.load()).resolves.toMatchObject({ + marker: `${defaultEntry}-b`, + assets: { version: 'default-b' }, + }); + await expect(runtime.load(bundleEntry)).resolves.toMatchObject({ + marker: `${bundleEntry}-b`, + assets: { version: 'bundle-b' }, + }); + }); + + it('rejects a multi-entry candidate atomically when any entry fails', async () => { + const defaultEntry = 'static/js/app'; + const bundleEntry = 'bundle/index'; + let generation = 'a'; + const loadBundle = rstest.fn((entryName: string) => { + if (generation === 'b' && entryName === bundleEntry) { + throw new Error('bundle evaluation failed'); + } + return createBuild(`${entryName}-${generation}`); + }); + const errors: Error[] = []; + const server = { + environments: { node: { loadBundle } }, + } as unknown as RsbuildDevServer; + const runtime = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: defaultEntry, + entryNames: [defaultEntry, bundleEntry], + }, + onEvaluationError: error => errors.push(error), + }); + const webA = createCompilation('web'); + const nodeA = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(webA, { + [defaultEntry]: { routes: {}, version: 'default-a' }, + [bundleEntry]: { routes: {}, version: 'bundle-a' }, + }); + await runtime.finishAttempt( + createGraphStats(webA, nodeA), + noKnownChanges, + graphIdentity(webA, nodeA) + ); + const committedDefault = await runtime.load(); + const committedBundle = await runtime.load(bundleEntry); + + generation = 'b'; + const webB = createCompilation('web'); + const nodeB = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(webB, { + [defaultEntry]: { routes: {}, version: 'default-b' }, + [bundleEntry]: { routes: {}, version: 'bundle-b' }, + }); + await runtime.finishAttempt( + createGraphStats(webB, nodeB), + noKnownChanges, + graphIdentity(webB, nodeB) + ); + + expect(await runtime.load()).toBe(committedDefault); + expect(await runtime.load(bundleEntry)).toBe(committedBundle); + expect(errors.at(-1)?.message).toContain('bundle evaluation failed'); + expect(committedDefault.assets).toMatchObject({ version: 'default-a' }); + expect(committedBundle.assets).toMatchObject({ version: 'bundle-a' }); + }); + + it('rejects an initial generation missing a required entry manifest', async () => { + const defaultEntry = 'static/js/app'; + const bundleEntry = 'bundle/index'; + const server = { + environments: { + node: { loadBundle: () => createBuild('raw') }, + }, + } as unknown as RsbuildDevServer; + const runtime = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: defaultEntry, + entryNames: [defaultEntry, bundleEntry], + }, + onEvaluationError() {}, + }); + const web = createCompilation('web'); + const node = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(web, { + [defaultEntry]: { routes: {}, version: 'default-only' }, + }); + const waiting = runtime.load(); + + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + await expect(waiting).rejects.toThrow('has no matching web manifest'); + }); +}); diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts new file mode 100644 index 0000000..8d13a9c --- /dev/null +++ b/tests/dev-generation.test.ts @@ -0,0 +1,832 @@ +import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import { describe, expect, it, rstest } from '@rstest/core'; +import type { ServerBuild } from 'react-router'; +import { loadReactRouterServerBuild } from '../src'; +import { + createReactRouterDevRuntime, + registerReactRouterDevRuntime, + unregisterReactRouterDevRuntime, + type DevGraphChanges, + type DevGraphIdentity, + type ReactRouterDevRuntime, +} from '../src/dev-generation'; + +const noKnownChanges: DevGraphChanges = { + web: { known: false, files: new Set() }, + node: { known: false, files: new Set() }, +}; + +const identityByCompilation = new WeakMap(); + +const getCompilationIdentity = (compilation: Rspack.Compilation): symbol => { + const existing = identityByCompilation.get(compilation); + if (existing) { + return existing; + } + const identity = Symbol(); + identityByCompilation.set(compilation, identity); + return identity; +}; + +const graphIdentity = ( + webCompilation: Rspack.Compilation, + nodeCompilation: Rspack.Compilation, + nodeWebCompilation: Rspack.Compilation = webCompilation +): DevGraphIdentity => ({ + web: getCompilationIdentity(webCompilation), + node: getCompilationIdentity(nodeCompilation), + nodeWeb: getCompilationIdentity(nodeWebCompilation), +}); + +type TestServerBuild = ServerBuild & { marker: string }; + +const createBuild = (marker: string): TestServerBuild => + ({ + entry: { module: { default: () => new Response() } }, + routes: {}, + assets: { routes: {}, version: marker }, + assetsBuildDirectory: '/app/build/client', + basename: '/', + future: {}, + isSpaMode: false, + marker, + prerender: [], + publicPath: '/', + routeDiscovery: { mode: 'initial' }, + ssr: true, + }) as unknown as TestServerBuild; + +const createCompilation = ( + name: 'web' | 'node', + dependencies: { + builds?: string[]; + files?: string[]; + contexts?: string[]; + missing?: string[]; + } = {} +) => + ({ + name, + buildDependencies: new Set(dependencies.builds), + fileDependencies: new Set(dependencies.files), + contextDependencies: new Set(dependencies.contexts), + missingDependencies: new Set(dependencies.missing), + }) as unknown as Rspack.Compilation; + +const createStats = (compilation: Rspack.Compilation, hasErrors = false) => + ({ + compilation, + hasErrors: () => hasErrors, + }) as Rspack.Stats; + +const createGraphStats = ( + webCompilation: Rspack.Compilation, + nodeCompilation: Rspack.Compilation, + errors: { web?: boolean; node?: boolean } = {} +) => + ({ + stats: [ + createStats(webCompilation, errors.web), + createStats(nodeCompilation, errors.node), + ], + }) as Rspack.MultiStats; + +const captureWeb = ( + runtime: ReactRouterDevRuntime, + compilation: Rspack.Compilation, + marker: string +) => { + runtime.captureWeb(compilation, { + 'static/js/app': { + routes: {}, + version: marker, + }, + }); +}; + +const createHarness = ( + loadBundle: (entryName: string) => Promise | unknown +) => { + const errors: Error[] = []; + const warnings: string[] = []; + const loadBundleMock = rstest.fn(loadBundle); + const server = { + environments: { + node: { loadBundle: loadBundleMock }, + }, + } as unknown as RsbuildDevServer; + const runtime = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: 'static/js/app', + entryNames: ['static/js/app'], + }, + onEvaluationError: error => errors.push(error), + onWarning: warning => warnings.push(warning), + }); + return { errors, loadBundle: loadBundleMock, runtime, server, warnings }; +}; + +describe('React Router development runtime', () => { + it('publishes a validated server build pinned to its exact web manifest', async () => { + const rawBuild = createBuild('raw'); + const { runtime } = createHarness(() => rawBuild); + const web = createCompilation('web'); + const node = createCompilation('node'); + const manifest = { routes: {}, version: 'web-1' }; + + runtime.beginAttempt(); + runtime.captureWeb(web, { 'static/js/app': manifest }); + const waiting = runtime.load(); + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + const committed = await waiting; + expect(committed).not.toBe(rawBuild); + expect(committed.assets).toEqual(manifest); + expect(committed.assets).not.toBe(manifest); + }); + + it('rejects initial waiters on evaluation failure and recovers on a new attempt', async () => { + let shouldFail = true; + const { runtime } = createHarness(() => { + if (shouldFail) { + throw new Error('top-level evaluation failed'); + } + return createBuild('recovered'); + }); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'first'); + const waiting = runtime.load(); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + + await expect(waiting).rejects.toThrow('top-level evaluation failed'); + await expect(runtime.load()).rejects.toThrow('top-level evaluation failed'); + + shouldFail = false; + const nextWeb = createCompilation('web'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, nextWeb, 'recovered'); + const recovery = runtime.load(); + await runtime.finishAttempt( + createGraphStats(nextWeb, nextNode), + noKnownChanges, + graphIdentity(nextWeb, nextNode) + ); + + await expect(recovery).resolves.toMatchObject({ + assets: { version: 'recovered' }, + }); + }); + + it('rejects initial waiters on a fatal compiler failure and recovers', async () => { + const { loadBundle, runtime } = createHarness(() => + createBuild('recovered') + ); + runtime.beginAttempt(); + const waiting = runtime.load(); + + runtime.failAttempt(new Error('fatal compiler failure')); + + await expect(waiting).rejects.toThrow('fatal compiler failure'); + const staleWeb = createCompilation('web'); + const staleNode = createCompilation('node'); + captureWeb(runtime, staleWeb, 'stale'); + await runtime.finishAttempt( + createGraphStats(staleWeb, staleNode), + noKnownChanges, + graphIdentity(staleWeb, staleNode) + ); + expect(loadBundle).not.toHaveBeenCalled(); + + const web = createCompilation('web'); + const node = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'recovered'); + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'recovered' }, + }); + }); + + it('rejects objects that are not React Router ServerBuild values', async () => { + const { runtime } = createHarness(() => ({})); + const web = createCompilation('web'); + const node = createCompilation('node'); + + runtime.beginAttempt(); + captureWeb(runtime, web, 'invalid'); + const waiting = runtime.load(); + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + await expect(waiting).rejects.toThrow('valid React Router ServerBuild'); + }); + + it('rejects a near-shaped build without a document request handler', async () => { + const invalid = { + ...createBuild('invalid-entry'), + entry: { module: {} }, + }; + const { runtime } = createHarness(() => invalid); + const web = createCompilation('web'); + const node = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'invalid-entry'); + const waiting = runtime.load(); + + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + await expect(waiting).rejects.toThrow('valid React Router ServerBuild'); + }); + + it('keeps serving last-good output when a later compilation or evaluation fails', async () => { + let build: unknown = createBuild('first'); + const { runtime } = createHarness(() => build); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'first'); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + const committed = await runtime.load(); + + runtime.beginAttempt(); + const failedWeb = createCompilation('web'); + const failedNode = createCompilation('node'); + captureWeb(runtime, failedWeb, 'compile-error'); + await runtime.finishAttempt( + createGraphStats(failedWeb, failedNode, { node: true }), + noKnownChanges, + graphIdentity(failedWeb, failedNode) + ); + expect(await runtime.load()).toBe(committed); + + build = {}; + runtime.beginAttempt(); + const invalidWeb = createCompilation('web'); + const invalidNode = createCompilation('node'); + captureWeb(runtime, invalidWeb, 'invalid-build'); + await runtime.finishAttempt( + createGraphStats(invalidWeb, invalidNode), + noKnownChanges, + graphIdentity(invalidWeb, invalidNode) + ); + expect(await runtime.load()).toBe(committed); + }); + + it('ignores stale async failure after supersession', async () => { + let resolveEvaluation: ((value: unknown) => void) | undefined; + let rejectEvaluation: ((error: Error) => void) | undefined; + let nextBuild: unknown = createBuild('first'); + const { errors, runtime } = createHarness( + () => + new Promise((resolve, reject) => { + resolveEvaluation = resolve; + rejectEvaluation = reject; + }) + ); + + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'first'); + const firstFinish = runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + resolveEvaluation?.(nextBuild); + await firstFinish; + const committed = await runtime.load(); + + const staleWeb = createCompilation('web'); + const staleNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, staleWeb, 'stale'); + const staleFinish = runtime.finishAttempt( + createGraphStats(staleWeb, staleNode), + noKnownChanges, + graphIdentity(staleWeb, staleNode) + ); + + runtime.beginAttempt(); + rejectEvaluation?.(new Error('stale rejection')); + await staleFinish; + + expect(await runtime.load()).toBe(committed); + expect(errors).toEqual([]); + }); + + it('ignores stale async success after a newer attempt commits', async () => { + let staleResolve: ((value: unknown) => void) | undefined; + let load: () => Promise | unknown = () => createBuild('base'); + const { runtime } = createHarness(() => load()); + const baseWeb = createCompilation('web'); + const baseNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, baseWeb, 'base'); + await runtime.finishAttempt( + createGraphStats(baseWeb, baseNode), + noKnownChanges, + graphIdentity(baseWeb, baseNode) + ); + + load = () => + new Promise(resolve => { + staleResolve = resolve; + }); + const staleWeb = createCompilation('web'); + const staleNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, staleWeb, 'stale'); + const staleFinish = runtime.finishAttempt( + createGraphStats(staleWeb, staleNode), + noKnownChanges, + graphIdentity(staleWeb, staleNode) + ); + + load = () => createBuild('newest'); + const newestWeb = createCompilation('web'); + const newestNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, newestWeb, 'newest'); + await runtime.finishAttempt( + createGraphStats(newestWeb, newestNode), + noKnownChanges, + graphIdentity(newestWeb, newestNode) + ); + staleResolve?.(createBuild('stale')); + await staleFinish; + + await expect(runtime.load()).resolves.toMatchObject({ + marker: 'newest', + assets: { version: 'newest' }, + }); + }); + + it('rejects mixed web and node results from overlapping compiler cycles', async () => { + let build = createBuild('base'); + const { runtime, warnings } = createHarness(() => build); + const baseWeb = createCompilation('web'); + const baseNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, baseWeb, 'base'); + await runtime.finishAttempt( + createGraphStats(baseWeb, baseNode), + noKnownChanges, + graphIdentity(baseWeb, baseNode) + ); + const committed = await runtime.load(); + + build = createBuild('node-a'); + const webB = createCompilation('web'); + const nodeA = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, webB, 'web-b'); + await runtime.finishAttempt( + createGraphStats(webB, nodeA), + noKnownChanges, + graphIdentity(webB, nodeA, baseWeb) + ); + + expect(await runtime.load()).toBe(committed); + expect(warnings.at(-1)).toContain('different compiler cycles'); + + build = createBuild('coherent-b'); + const nodeB = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(webB, nodeB), + noKnownChanges, + graphIdentity(webB, nodeB) + ); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'web-b' }, + }); + }); + + it('keeps initial readiness pending for a transient mixed result', async () => { + const { loadBundle, runtime } = createHarness(() => createBuild('web-b')); + const webA = createCompilation('web'); + const webB = createCompilation('web'); + const nodeA = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, webB, 'web-b'); + const waiting = runtime.load(); + + await runtime.finishAttempt( + createGraphStats(webB, nodeA), + noKnownChanges, + graphIdentity(webB, nodeA, webA) + ); + let published = false; + void waiting.then(() => { + published = true; + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(published).toBe(false); + expect(loadBundle).not.toHaveBeenCalled(); + + const nodeB = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(webB, nodeB), + noKnownChanges, + graphIdentity(webB, nodeB) + ); + + await expect(waiting).resolves.toMatchObject({ + marker: 'web-b', + assets: { version: 'web-b' }, + }); + }); + + it('rejects a node result compiled against an unseen web compilation', async () => { + let build = createBuild('base'); + const { loadBundle, runtime, warnings } = createHarness(() => build); + const web = createCompilation('web'); + const baseNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'web-a'); + await runtime.finishAttempt( + createGraphStats(web, baseNode), + noKnownChanges, + graphIdentity(web, baseNode) + ); + const committed = await runtime.load(); + + build = createBuild('node-b'); + const nextNode = createCompilation('node'); + const unseenWeb = createCompilation('web'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, nextNode), + { + web: { known: false, files: new Set() }, + node: { known: true, files: new Set(['/app/server-only.ts']) }, + }, + graphIdentity(web, nextNode, unseenWeb) + ); + + expect(await runtime.load()).toBe(committed); + expect(warnings.at(-1)).toContain('different compiler cycles'); + expect(loadBundle).toHaveBeenCalledOnce(); + }); + + it('does not let a stale unchanged callback consume the active attempt', async () => { + let build = createBuild('base'); + const { runtime } = createHarness(() => build); + const baseWeb = createCompilation('web'); + const baseNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, baseWeb, 'base'); + await runtime.finishAttempt( + createGraphStats(baseWeb, baseNode), + noKnownChanges, + graphIdentity(baseWeb, baseNode) + ); + + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(baseWeb, baseNode), + noKnownChanges, + graphIdentity(baseWeb, baseNode) + ); + + build = createBuild('next'); + const nextWeb = createCompilation('web'); + const nextNode = createCompilation('node'); + captureWeb(runtime, nextWeb, 'next'); + await runtime.finishAttempt( + createGraphStats(nextWeb, nextNode), + noKnownChanges, + graphIdentity(nextWeb, nextNode) + ); + + await expect(runtime.load()).resolves.toMatchObject({ + marker: 'next', + assets: { version: 'next' }, + }); + }); + + it('does not publish an intermediate additional compiler pass', async () => { + let build = createBuild('intermediate'); + const { loadBundle, runtime } = createHarness(() => build); + const web = createCompilation('web'); + const intermediateNode = createCompilation('node'); + intermediateNode.needAdditionalPass = true; + runtime.beginAttempt(); + captureWeb(runtime, web, 'web'); + const waiting = runtime.load(); + + await runtime.finishAttempt( + createGraphStats(web, intermediateNode, { node: true }), + noKnownChanges, + graphIdentity(web, intermediateNode) + ); + let published = false; + void waiting.then(() => { + published = true; + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(published).toBe(false); + expect(loadBundle).not.toHaveBeenCalled(); + + build = createBuild('final'); + const finalNode = createCompilation('node'); + await runtime.finishAttempt( + createGraphStats(web, finalNode), + noKnownChanges, + graphIdentity(web, finalNode) + ); + await expect(waiting).resolves.toMatchObject({ marker: 'final' }); + }); + + it('commits known server-only changes that do not touch web dependencies', async () => { + let build = createBuild('first'); + const { runtime } = createHarness(() => build); + const web = createCompilation('web', { + files: ['/app/shared.ts'], + contexts: ['/app/routes'], + missing: ['/app/generated.ts'], + }); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'web'); + await runtime.finishAttempt( + createGraphStats(web, firstNode), + noKnownChanges, + graphIdentity(web, firstNode) + ); + + build = createBuild('node-2'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, nextNode), + { + web: { known: false, files: new Set() }, + node: { known: true, files: new Set(['/app/server-only.ts']) }, + }, + graphIdentity(web, nextNode) + ); + + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'web' }, + marker: 'node-2', + }); + }); + + it('captures web dependencies after manifest generation finishes', async () => { + let build = createBuild('first'); + const { loadBundle, runtime, warnings } = createHarness(() => build); + const web = createCompilation('web'); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'web'); + web.fileDependencies.add('/app/late.ts'); + await runtime.finishAttempt( + createGraphStats(web, firstNode), + noKnownChanges, + graphIdentity(web, firstNode) + ); + const committed = await runtime.load(); + + build = createBuild('unsafe-node'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, nextNode), + { + web: { known: false, files: new Set() }, + node: { known: true, files: new Set(['/app/late.ts']) }, + }, + graphIdentity(web, nextNode) + ); + + expect(await runtime.load()).toBe(committed); + expect(loadBundle).toHaveBeenCalledOnce(); + expect(warnings).toHaveLength(1); + }); + + it('treats web build dependencies as unsafe node-only changes', async () => { + let build = createBuild('first'); + const { loadBundle, runtime } = createHarness(() => build); + const web = createCompilation('web', { + builds: ['/app/web-loader.config.ts'], + }); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'web'); + await runtime.finishAttempt( + createGraphStats(web, firstNode), + noKnownChanges, + graphIdentity(web, firstNode) + ); + const committed = await runtime.load(); + + build = createBuild('unsafe-node'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, nextNode), + { + web: { known: false, files: new Set() }, + node: { + known: true, + files: new Set(['/app/web-loader.config.ts']), + }, + }, + graphIdentity(web, nextNode) + ); + + expect(await runtime.load()).toBe(committed); + expect(loadBundle).toHaveBeenCalledOnce(); + }); + + it('treats node build dependencies as unsafe web-only changes', async () => { + const build = createBuild('first'); + const { runtime } = createHarness(() => build); + const firstWeb = createCompilation('web'); + const node = createCompilation('node', { + builds: ['/app/node-loader.config.ts'], + }); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'web-1'); + await runtime.finishAttempt( + createGraphStats(firstWeb, node), + noKnownChanges, + graphIdentity(firstWeb, node) + ); + const committed = await runtime.load(); + + const nextWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, nextWeb, 'web-2'); + await runtime.finishAttempt( + createGraphStats(nextWeb, node), + { + web: { + known: true, + files: new Set(['/app/node-loader.config.ts']), + }, + node: { known: false, files: new Set() }, + }, + graphIdentity(nextWeb, node) + ); + + expect(await runtime.load()).toBe(committed); + }); + + it('discards ambiguous or overlapping one-sided rebuilds', async () => { + let build = createBuild('first'); + const { errors, runtime, warnings } = createHarness(() => build); + const web = createCompilation('web', { files: ['/app/shared.ts'] }); + const firstNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'web'); + await runtime.finishAttempt( + createGraphStats(web, firstNode), + noKnownChanges, + graphIdentity(web, firstNode) + ); + const committed = await runtime.load(); + + build = createBuild('unsafe'); + const unsafeNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, unsafeNode), + { + web: { known: false, files: new Set() }, + node: { known: true, files: new Set(['/app/shared.ts']) }, + }, + graphIdentity(web, unsafeNode) + ); + + expect(await runtime.load()).toBe(committed); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('last-good'); + + const ambiguousNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, ambiguousNode), + { + web: { known: false, files: new Set() }, + node: { known: true, files: new Set() }, + }, + graphIdentity(web, ambiguousNode) + ); + expect(await runtime.load()).toBe(committed); + }); + + it('does not let cleanup from an old session unregister a replacement', async () => { + const server = { + environments: { node: { loadBundle: () => createBuild('replacement') } }, + } as unknown as RsbuildDevServer; + const first = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: 'static/js/app', + entryNames: ['static/js/app'], + }, + onEvaluationError() {}, + }); + const replacement = createReactRouterDevRuntime({ + server, + buildPlan: { + defaultEntryName: 'static/js/app', + entryNames: ['static/js/app'], + }, + onEvaluationError() {}, + }); + registerReactRouterDevRuntime(server, first); + registerReactRouterDevRuntime(server, replacement); + unregisterReactRouterDevRuntime(server, first); + expect( + Reflect.get( + server, + Symbol.for('rsbuild-plugin-react-router.dev-runtime.v1') + ) + ).toBe(replacement); + + const web = createCompilation('web'); + const node = createCompilation('node'); + replacement.beginAttempt(); + captureWeb(replacement, web, 'replacement'); + await replacement.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + await expect(loadReactRouterServerBuild(server)).resolves.toMatchObject({ + assets: { version: 'replacement' }, + }); + unregisterReactRouterDevRuntime(server, replacement); + expect( + Reflect.get( + server, + Symbol.for('rsbuild-plugin-react-router.dev-runtime.v1') + ) + ).toBeUndefined(); + }); + + it('closes pending waiters and ignores late completions', async () => { + let resolveEvaluation: ((value: unknown) => void) | undefined; + const { errors, runtime } = createHarness( + () => + new Promise(resolve => { + resolveEvaluation = resolve; + }) + ); + const web = createCompilation('web'); + const node = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'late'); + const waiting = runtime.load(); + const finishing = runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + runtime.close(); + resolveEvaluation?.(createBuild('late')); + await finishing; + + await expect(waiting).rejects.toThrow( + 'development server closed before a React Router build was ready' + ); + expect(errors).toEqual([]); + }); + +}); diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts new file mode 100644 index 0000000..749c699 --- /dev/null +++ b/tests/dev-runtime-controller.test.ts @@ -0,0 +1,881 @@ +import type { + OnAfterCreateCompilerFn, + OnAfterDevCompileFn, + OnBeforeDevCompileFn, + OnBeforeStartDevServerFn, + OnCloseDevServerFn, + RsbuildDevServer, + Rspack, +} from '@rsbuild/core'; +import { describe, expect, it, rstest } from '@rstest/core'; +import type { ServerBuild } from 'react-router'; +import { createReactRouterDevRuntimeController } from '../src/dev-runtime-controller'; + +type FailedCallback = (error: Error) => void; + +const afterDoneByCompiler = new WeakMap< + Rspack.Compiler, + (stats: Rspack.Stats) => void +>(); + +type TestServerBuild = ServerBuild & { marker: string }; + +const createBuild = (marker: string): TestServerBuild => + ({ + entry: { module: { default: () => new Response() } }, + routes: {}, + assets: { routes: {}, version: marker }, + assetsBuildDirectory: '/app/build/client', + basename: '/', + future: {}, + isSpaMode: false, + marker, + prerender: [], + publicPath: '/', + routeDiscovery: { mode: 'initial' }, + ssr: true, + }) as unknown as TestServerBuild; + +const createManifest = (version: string) => ({ + 'static/js/app': { + version, + url: '/manifest', + entry: { module: '/entry.js', imports: [], css: [] }, + routes: {}, + }, +}); + +const createStats = (compilation: Rspack.Compilation): Rspack.Stats => + ({ compilation, hasErrors: () => false }) as Rspack.Stats; + +const createGraphStats = ( + web: Rspack.Compilation, + node: Rspack.Compilation +): Rspack.MultiStats => + ({ stats: [createStats(web), createStats(node)] }) as Rspack.MultiStats; + +const createCompiler = (name: 'web' | 'node') => { + let failed: FailedCallback | undefined; + let invalid: (() => void) | undefined; + let thisCompilation: ((compilation: Rspack.Compilation) => void) | undefined; + const doneTaps: Array<{ + stage: number; + callback: (stats: Rspack.Stats) => void; + }> = []; + const compiler = { + name, + hooks: { + thisCompilation: { + tap(_name: string, callback: typeof thisCompilation) { + thisCompilation = callback; + }, + }, + done: { + tap(options: unknown, callback: (stats: Rspack.Stats) => void) { + const stage = + typeof options === 'object' && + options !== null && + 'stage' in options && + typeof options.stage === 'number' + ? options.stage + : 0; + doneTaps.push({ stage, callback }); + }, + }, + afterDone: { + tap(_name: unknown, callback: (stats: Rspack.Stats) => void) { + afterDoneByCompiler.set(compiler, callback); + }, + }, + failed: { + tap(_name: string, callback: FailedCallback) { + failed = callback; + }, + }, + invalid: { + tap(_name: string, callback: () => void) { + invalid = callback; + }, + }, + }, + } as unknown as Rspack.Compiler; + const compile = (): Rspack.Compilation => { + const compilation = { + name, + compiler, + buildDependencies: new Set(), + fileDependencies: new Set(), + contextDependencies: new Set(), + missingDependencies: new Set(), + } as unknown as Rspack.Compilation; + thisCompilation?.(compilation); + return compilation; + }; + const runDoneTaps = ( + compilation: Rspack.Compilation, + predicate: (stage: number) => boolean + ): void => { + const stats = { compilation } as Rspack.Stats; + for (const tap of doneTaps + .filter(({ stage }) => predicate(stage)) + .sort((left, right) => left.stage - right.stage)) { + tap.callback(stats); + } + }; + return { + compiler, + compile, + complete: (compilation: Rspack.Compilation) => + runDoneTaps(compilation, stage => stage < 0), + completeLate: (compilation: Rspack.Compilation) => + runDoneTaps(compilation, stage => stage >= 0), + fail: (error: Error) => failed?.(error), + invalidate: () => invalid?.(), + setChanges: (files: string[]) => { + compiler.modifiedFiles = new Set(files); + compiler.removedFiles = undefined; + }, + settle: (compilation: Rspack.Compilation) => + afterDoneByCompiler.get(compiler)?.(createStats(compilation)), + }; +}; + +type TestServerSetup = (context: { + action: 'dev'; + server: RsbuildDevServer; +}) => void; + +type TestConfig = { + server?: { setup?: TestServerSetup | TestServerSetup[] }; +}; + +const createHarness = (userSetup?: TestServerSetup) => { + let start!: OnBeforeStartDevServerFn; + let startOrder: 'pre' | 'post' | 'default' = 'default'; + let before!: OnBeforeDevCompileFn; + let beforeOrder: 'pre' | 'post' | 'default' = 'default'; + let closeHook: OnCloseDevServerFn | undefined; + let closeOrder: 'pre' | 'post' | 'default' = 'default'; + let created!: OnAfterCreateCompilerFn; + let after!: OnAfterDevCompileFn; + const closeRecords = new WeakMap(); + const warn = rstest.fn(); + let serverSetups = userSetup ? [userSetup] : []; + const api = { + logger: { error: rstest.fn(), warn }, + modifyRsbuildConfig: ( + callback: + | ((config: TestConfig) => TestConfig | void) + | { handler: (config: TestConfig) => TestConfig | void } + ) => { + const handler = + typeof callback === 'function' ? callback : callback.handler; + const config = handler({ server: { setup: serverSetups } }); + const setup = config?.server?.setup; + serverSetups = setup + ? Array.isArray(setup) + ? setup + : [setup] + : []; + }, + onBeforeStartDevServer: ( + callback: + | OnBeforeStartDevServerFn + | { + handler: OnBeforeStartDevServerFn; + order: 'pre' | 'post' | 'default'; + } + ) => { + if (typeof callback === 'function') { + start = callback; + return; + } + start = callback.handler; + startOrder = callback.order; + }, + onBeforeDevCompile: ( + callback: + | OnBeforeDevCompileFn + | { + handler: OnBeforeDevCompileFn; + order: 'pre' | 'post' | 'default'; + } + ) => { + if (typeof callback === 'function') { + before = callback; + return; + } + before = callback.handler; + beforeOrder = callback.order; + }, + onCloseDevServer: ( + callback: + | OnCloseDevServerFn + | { + handler: OnCloseDevServerFn; + order: 'pre' | 'post' | 'default'; + } + ) => { + if (typeof callback === 'function') { + closeHook = callback; + return; + } + closeHook = callback.handler; + closeOrder = callback.order; + }, + onAfterCreateCompiler: (callback: OnAfterCreateCompilerFn) => { + created = callback; + }, + onAfterDevCompile: (callback: OnAfterDevCompileFn) => { + after = callback; + }, + }; + const controller = createReactRouterDevRuntimeController({ + api: api as never, + isBuild: false, + buildPlan: { + defaultEntryName: 'static/js/app', + entryNames: ['static/js/app'], + }, + }); + const createServer = ( + loadBundle: (entryName: string) => Promise | unknown, + afterCloseHook?: () => Promise | void + ): RsbuildDevServer => { + let closing: Promise | undefined; + const record = { count: 0 }; + const server = { + close() { + record.count++; + closing ??= (async () => { + await closeHook?.(); + await afterCloseHook?.(); + })(); + return closing; + }, + environments: { node: { loadBundle } }, + sockWrite: rstest.fn(), + } as unknown as RsbuildDevServer; + closeRecords.set(server, record); + for (const setup of serverSetups) { + setup({ action: 'dev', server }); + } + return server; + }; + const loadBundle = rstest.fn(); + const server = createServer(loadBundle); + let currentServer = server; + const environments = {}; + const settleStats = (stats: Rspack.Stats | Rspack.MultiStats): void => { + const children = Array.isArray((stats as Rspack.MultiStats).stats) + ? (stats as Rspack.MultiStats).stats + : [stats as Rspack.Stats]; + for (const child of children) { + afterDoneByCompiler.get(child.compilation.compiler)?.(child); + } + }; + const callbacks = { + after: async ({ stats }: { stats: Rspack.Stats | Rspack.MultiStats }) => { + await after({ environments, isFirstCompile: false, stats }); + settleStats(stats); + await new Promise(resolve => setTimeout(resolve, 0)); + }, + aggregate: ({ stats }: { stats: Rspack.Stats | Rspack.MultiStats }) => + after({ environments, isFirstCompile: false, stats }), + before: () => + before({ + environments, + isFirstCompile: false, + isWatch: true, + }), + close: () => currentServer.close(), + created: ({ + compiler, + }: { + compiler: Rspack.Compiler | { compilers: Rspack.Compiler[] }; + }) => + created({ + compiler: compiler as Rspack.Compiler | Rspack.MultiCompiler, + environments, + }), + start: ({ server }: { server: RsbuildDevServer }) => { + const previous = currentServer; + currentServer = server; + return Promise.resolve(start({ environments, server })).catch(error => { + if (currentServer === server) { + currentServer = previous; + } + throw error; + }); + }, + settle: settleStats, + }; + return { + callbacks, + beforeOrder, + closeOrder, + controller, + createServer, + getCloseCount: (server: RsbuildDevServer) => + closeRecords.get(server)?.count ?? 0, + loadBundle, + server, + startOrder, + warn, + }; +}; + +describe('React Router development runtime controller', () => { + it('validates lifecycle state before default startup hooks', () => { + const { beforeOrder, closeOrder, startOrder } = createHarness(); + expect(beforeOrder).toBe('pre'); + expect(startOrder).toBe('pre'); + expect(closeOrder).toBe('pre'); + }); + + it('rejects readiness when Rsbuild does not create both compilers', async () => { + const { callbacks, controller, server } = createHarness(); + const compiler = createCompiler('web'); + callbacks.start({ server }); + + callbacks.created({ compiler: compiler.compiler }); + + await expect(controller.createBuildLoader()()).rejects.toThrow( + 'did not create a multi-compiler' + ); + }); + + it('rejects initial readiness when aggregate stats omit an environment', async () => { + const { callbacks, controller, server } = createHarness(); + const web = createCompiler('web'); + const node = createCompiler('node'); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + const waiting = controller.createBuildLoader()(); + const webCompilation = web.compile(); + controller.captureWeb(webCompilation, createManifest('web')); + + await callbacks.after({ stats: createStats(webCompilation) }); + + await expect(waiting).rejects.toThrow( + 'did not provide both web and node results' + ); + }); + + it('rejects a fatal child failure and recovers on the next compile', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + const web = createCompiler('web'); + const node = createCompiler('node'); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const waiting = controller.createBuildLoader()(); + + web.fail(new Error('fatal compiler failure')); + + await expect(waiting).rejects.toThrow('fatal compiler failure'); + + loadBundle.mockImplementation(() => createBuild('recovered')); + callbacks.before(); + const webCompilation = web.compile(); + controller.captureWeb(webCompilation, createManifest('recovered')); + web.complete(webCompilation); + const nodeCompilation = node.compile(); + const recovered = controller.createBuildLoader()(); + await callbacks.after({ + stats: createGraphStats(webCompilation, nodeCompilation), + }); + + await expect(recovered).resolves.toMatchObject({ + marker: 'recovered', + assets: { version: 'recovered' }, + }); + }); + + it('publishes a safe web-only compile after the aggregate pre-hook', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + loadBundle.mockImplementation(() => createBuild('base')); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifest('web-base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + web.setChanges(['/app/web-only.ts']); + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb(nextWeb, createManifest('web-next')); + web.complete(nextWeb); + await callbacks.after({ stats: createGraphStats(nextWeb, baseNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'web-next' }, + }); + expect(loadBundle).toHaveBeenCalledOnce(); + }); + + it('publishes a safe node-only compile after the aggregate pre-hook', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + let build = createBuild('base'); + loadBundle.mockImplementation(() => build); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifest('web-base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + build = createBuild('node-next'); + node.setChanges(['/app/node-only.ts']); + callbacks.before(); + const nextNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, nextNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'node-next', + assets: { version: 'web-base' }, + }); + expect(loadBundle).toHaveBeenCalledTimes(2); + }); + + it('waits for late done hooks before snapshotting dependencies', async () => { + const routePath = '/app/routes.ts'; + const { callbacks, controller, loadBundle, server, warn } = createHarness(); + loadBundle.mockImplementation(() => createBuild('base')); + const web = createCompiler('web'); + const node = createCompiler('node'); + node.compiler.hooks.done.tap( + { name: 'late-node-dependency', stage: 1000 }, + stats => stats.compilation.buildDependencies.add(routePath) + ); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifest('web-base')); + web.complete(baseWeb); + web.settle(baseWeb); + const baseNode = node.compile(); + await callbacks.aggregate({ stats: createGraphStats(baseWeb, baseNode) }); + node.completeLate(baseNode); + node.settle(baseNode); + const committed = await controller.createBuildLoader()(); + + web.setChanges([routePath]); + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb(nextWeb, createManifest('web-next')); + web.complete(nextWeb); + await callbacks.aggregate({ stats: createGraphStats(nextWeb, baseNode) }); + web.settle(nextWeb); + + expect(await controller.createBuildLoader()()).toBe(committed); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('incomplete web-only') + ); + }); + + it('requires the active server to close before replacement', async () => { + const { + callbacks, + controller, + createServer, + getCloseCount, + server, + } = createHarness(); + await callbacks.start({ server }); + const activeLoader = controller.createBuildLoader(); + const replacementServer = createServer(rstest.fn()); + + await expect( + callbacks.start({ server: replacementServer }) + ).rejects.toThrow('development server is already active'); + + expect(getCloseCount(server)).toBe(0); + await server.close(); + expect(getCloseCount(server)).toBe(1); + await expect(activeLoader()).rejects.toThrow('not registered'); + await callbacks.start({ server: replacementServer }); + const replacementLoader = controller.createBuildLoader(); + await callbacks.close(); + await expect(replacementLoader()).rejects.toThrow('not registered'); + }); + + it('observes one close promise and rejects replacement until it settles', async () => { + const { callbacks, createServer, getCloseCount } = createHarness(); + let releaseClose!: () => void; + const closeGate = new Promise(resolve => { + releaseClose = resolve; + }); + const closingServer = createServer(rstest.fn(), () => closeGate); + await callbacks.start({ server: closingServer }); + const closing = closingServer.close(); + expect(closingServer.close()).toBe(closing); + expect(getCloseCount(closingServer)).toBe(1); + + const replacement = createServer(rstest.fn()); + await expect(callbacks.start({ server: replacement })).rejects.toThrow( + 'still closing' + ); + + releaseClose(); + await closing; + await callbacks.start({ server: replacement }); + expect(getCloseCount(closingServer)).toBe(1); + await callbacks.close(); + }); + + it('observes close captured by an earlier server setup callback', async () => { + let capturedClose!: () => Promise; + const { callbacks, createServer, getCloseCount, server } = createHarness( + ({ server }) => { + capturedClose = server.close; + } + ); + const closeFirstServer = capturedClose; + await callbacks.start({ server }); + + await closeFirstServer(); + expect(getCloseCount(server)).toBe(1); + + const replacement = createServer(rstest.fn()); + await callbacks.start({ server: replacement }); + await callbacks.close(); + }); + + it('fails replacement closed after the active server cannot close', async () => { + const { callbacks, controller, createServer } = createHarness(); + const closeError = new Error('could not close abandoned server'); + const abandonedServer = createServer(rstest.fn(), () => { + throw closeError; + }); + await callbacks.start({ server: abandonedServer }); + const abandonedLoader = controller.createBuildLoader(); + + await expect(abandonedServer.close()).rejects.toThrow(closeError); + + await expect(abandonedLoader()).rejects.toThrow('not registered'); + await expect(controller.createBuildLoader()()).rejects.toThrow( + 'previous development server failed to close' + ); + + await expect( + callbacks.start({ server: createServer(rstest.fn()) }) + ).rejects.toThrow('previous development server failed to close'); + await expect(controller.createBuildLoader()()).rejects.toThrow( + 'previous development server failed to close' + ); + }); + + it('ignores a late fatal callback from a replaced server session', async () => { + const { callbacks, controller, createServer, server } = createHarness(); + const oldWeb = createCompiler('web'); + const oldNode = createCompiler('node'); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, + }); + const oldLoader = controller.createBuildLoader(); + + const replacementServer = createServer(rstest.fn()); + const newWeb = createCompiler('web'); + const newNode = createCompiler('node'); + await callbacks.close(); + await callbacks.start({ server: replacementServer }); + callbacks.created({ + compiler: { compilers: [newWeb.compiler, newNode.compiler] }, + }); + callbacks.before(); + const waiting = controller.createBuildLoader()(); + + oldWeb.fail(new Error('stale compiler failure')); + await expect(oldLoader()).rejects.toThrow('not registered'); + newWeb.fail(new Error('current compiler failure')); + + await expect(waiting).rejects.toThrow('current compiler failure'); + }); + + it('disposes the current session through the supported close hook', async () => { + const { callbacks, controller, server } = createHarness(); + callbacks.start({ server }); + const loadBuild = controller.createBuildLoader(); + + await callbacks.close(); + + await expect(loadBuild()).rejects.toThrow('not registered'); + await expect(controller.createBuildLoader()()).rejects.toThrow( + 'runtime is not ready' + ); + }); + + it('ignores manifest and completion callbacks from a replaced compiler', async () => { + const { callbacks, controller, createServer, server } = createHarness(); + const oldWeb = createCompiler('web'); + const oldNode = createCompiler('node'); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, + }); + const oldWebCompilation = oldWeb.compile(); + oldWeb.complete(oldWebCompilation); + const oldNodeCompilation = oldNode.compile(); + + const replacementLoadBundle = rstest.fn(); + const replacementServer = createServer(replacementLoadBundle); + const newWeb = createCompiler('web'); + const newNode = createCompiler('node'); + await callbacks.close(); + await callbacks.start({ server: replacementServer }); + callbacks.created({ + compiler: { compilers: [newWeb.compiler, newNode.compiler] }, + }); + callbacks.before(); + controller.captureWeb(oldWebCompilation, createManifest('old')); + + await callbacks.after({ + stats: createGraphStats(oldWebCompilation, oldNodeCompilation), + }); + + expect(replacementLoadBundle).not.toHaveBeenCalled(); + const waiting = controller.createBuildLoader()(); + newWeb.fail(new Error('current failure')); + await expect(waiting).rejects.toThrow('current failure'); + }); + + it('isolates web lineage callbacks from a replaced compiler pair', async () => { + const { callbacks, controller, createServer, server } = createHarness(); + const oldWeb = createCompiler('web'); + const oldNode = createCompiler('node'); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, + }); + const staleWebCompilation = oldWeb.compile(); + + const replacementLoadBundle = rstest.fn(() => createBuild('replacement')); + const replacementServer = createServer(replacementLoadBundle); + const newWeb = createCompiler('web'); + const newNode = createCompiler('node'); + await callbacks.close(); + await callbacks.start({ server: replacementServer }); + callbacks.created({ + compiler: { compilers: [newWeb.compiler, newNode.compiler] }, + }); + + const newWebCompilation = newWeb.compile(); + controller.captureWeb(newWebCompilation, createManifest('replacement')); + newWeb.complete(newWebCompilation); + oldWeb.complete(staleWebCompilation); + const newNodeCompilation = newNode.compile(); + + await callbacks.after({ + stats: createGraphStats(newWebCompilation, newNodeCompilation), + }); + + expect(replacementLoadBundle).toHaveBeenCalledOnce(); + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'replacement', + assets: { version: 'replacement' }, + }); + }); + + it('keeps the initial loader pending until a mixed result becomes coherent', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + loadBundle.mockImplementation(() => createBuild('web-b')); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + const waiting = controller.createBuildLoader()(); + + const webA = web.compile(); + web.complete(webA); + const nodeA = node.compile(); + const webB = web.compile(); + controller.captureWeb(webB, createManifest('web-b')); + web.complete(webB); + await callbacks.after({ stats: createGraphStats(webB, nodeA) }); + + let published = false; + void waiting.then(() => { + published = true; + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(published).toBe(false); + expect(loadBundle).not.toHaveBeenCalled(); + + const nodeB = node.compile(); + await callbacks.after({ stats: createGraphStats(webB, nodeB) }); + + await expect(waiting).resolves.toMatchObject({ + marker: 'web-b', + assets: { version: 'web-b' }, + }); + }); + + it('ignores a coherent aggregate older than an invalidated compilation', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + let build = createBuild('base'); + loadBundle.mockImplementation(() => build); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + const loadBuild = controller.createBuildLoader(); + + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifest('base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + callbacks.before(); + const webA = web.compile(); + controller.captureWeb(webA, createManifest('a')); + web.complete(webA); + const nodeA = node.compile(); + web.invalidate(); + build = createBuild('a'); + await callbacks.after({ stats: createGraphStats(webA, nodeA) }); + + expect(loadBundle).toHaveBeenCalledOnce(); + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'base' }, + }); + + const webB = web.compile(); + controller.captureWeb(webB, createManifest('b')); + web.complete(webB); + const nodeB = node.compile(); + build = createBuild('b'); + await callbacks.after({ stats: createGraphStats(webB, nodeB) }); + + expect(loadBundle).toHaveBeenCalledTimes(2); + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'b', + assets: { version: 'b' }, + }); + }); + + it('does not publish an evaluation superseded during invalidation', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + let resolveCandidate!: (build: TestServerBuild) => void; + const candidate = new Promise(resolve => { + resolveCandidate = resolve; + }); + loadBundle + .mockImplementationOnce(() => createBuild('base')) + .mockImplementationOnce(() => candidate); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + const loadBuild = controller.createBuildLoader(); + + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifest('base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + callbacks.before(); + const webA = web.compile(); + controller.captureWeb(webA, createManifest('a')); + web.complete(webA); + const nodeA = node.compile(); + const evaluating = callbacks.after({ + stats: createGraphStats(webA, nodeA), + }); + await Promise.resolve(); + expect(loadBundle).toHaveBeenCalledTimes(2); + + web.invalidate(); + resolveCandidate(createBuild('a')); + await evaluating; + + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'base' }, + }); + }); + + it('rejects web B with node A and publishes web B with node B', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + const web = createCompiler('web'); + const node = createCompiler('node'); + let build = createBuild('base'); + loadBundle.mockImplementation(() => build); + callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + const loadBuild = controller.createBuildLoader(); + + const webBase = web.compile(); + controller.captureWeb(webBase, createManifest('web-base')); + web.complete(webBase); + const nodeBase = node.compile(); + await callbacks.after({ stats: createGraphStats(webBase, nodeBase) }); + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'web-base' }, + }); + + callbacks.before(); + const webA = web.compile(); + controller.captureWeb(webA, createManifest('web-a')); + web.complete(webA); + const nodeA = node.compile(); + + web.invalidate(); + node.invalidate(); + const webB = web.compile(); + controller.captureWeb(webB, createManifest('web-b')); + web.complete(webB); + build = createBuild('node-a'); + await callbacks.after({ stats: createGraphStats(webB, nodeA) }); + + expect(loadBundle).toHaveBeenCalledTimes(1); + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'web-base' }, + }); + + const nodeB = node.compile(); + build = createBuild('node-b'); + await callbacks.after({ stats: createGraphStats(webB, nodeB) }); + + expect(loadBundle).toHaveBeenCalledTimes(2); + await expect(loadBuild()).resolves.toMatchObject({ + marker: 'node-b', + assets: { version: 'web-b' }, + }); + }); +}); diff --git a/tests/dev-runtime.integration.test.ts b/tests/dev-runtime.integration.test.ts new file mode 100644 index 0000000..5416aa4 --- /dev/null +++ b/tests/dev-runtime.integration.test.ts @@ -0,0 +1,549 @@ +import { EventEmitter } from 'node:events'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import { createServer, type Server as HttpServer } from 'node:http'; +import * as vm from 'node:vm'; +import { + chmodSync, + cpSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { delimiter, join } from 'node:path'; +import { + createLogger, + createRsbuild, + type RsbuildConfig, + type RsbuildDevServer, + type RsbuildPlugin, + type Rspack, +} from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { expect, it, rstest } from '@rstest/core'; +import { createRequestHandler, type ServerBuild } from 'react-router'; + +const ESM_SUBPROCESS_ENV = 'RR_DEV_RUNTIME_ESM_SUBPROCESS'; +const isEsmSubprocess = process.env[ESM_SUBPROCESS_ENV] === '1'; + +const INITIAL_COMPILATION_ERROR = ` +export const handle = { marker: ; + +export default function IndexRoute() { + return

broken

; +} +`; + +const EVALUATION_ERROR_MARKER = 'RR_TEST_EVALUATION_FAILURE'; +const EVALUATION_ERROR = ` +throw new Error('${EVALUATION_ERROR_MARKER}'); + +export const handle = { marker: 'uncommitted' }; + +export default function IndexRoute() { + return

uncommitted

; +} +`; + +const routeSource = (marker: string): string => ` +export const handle = { marker: '${marker}' }; + +export default function IndexRoute() { + return

${marker}

; +} +`; + +const withTimeout = async ( + promise: Promise, + timeoutMs: number, + label: string +): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${label}`)), + timeoutMs + ); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +const getBuildMarker = (build: ServerBuild): string | undefined => { + for (const route of Object.values(build.routes)) { + const handle = (route.module as { handle?: unknown }).handle as + | { marker?: unknown } + | undefined; + if (typeof handle?.marker === 'string') { + return handle.marker; + } + } + return undefined; +}; + +const createNoopWatcher = (): fs.FSWatcher => { + const watcher = new EventEmitter() as EventEmitter & { + close: () => void; + ref: () => fs.FSWatcher; + unref: () => fs.FSWatcher; + }; + watcher.close = () => undefined; + watcher.ref = () => watcher as fs.FSWatcher; + watcher.unref = () => watcher as fs.FSWatcher; + return watcher as fs.FSWatcher; +}; + +const runEsmIntegrationSubprocess = (repositoryRoot: string): Promise => + new Promise((resolve, reject) => { + const rstestCli = join( + repositoryRoot, + 'node_modules/@rstest/core/bin/rstest.js' + ); + const output: Buffer[] = []; + const child = spawn( + process.execPath, + [ + '--experimental-vm-modules', + rstestCli, + 'run', + 'tests/dev-runtime.integration.test.ts', + ], + { + cwd: repositoryRoot, + env: { ...process.env, [ESM_SUBPROCESS_ENV]: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + child.stdout.on('data', chunk => output.push(Buffer.from(chunk))); + child.stderr.on('data', chunk => output.push(Buffer.from(chunk))); + + let settled = false; + let timeout: ReturnType | undefined; + let forceKillTimeout: ReturnType | undefined; + let timedOut = false; + const finish = (error?: Error): void => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + error ? reject(error) : resolve(); + }; + timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + forceKillTimeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, 5_000); + }, 80_000); + child.once('error', error => finish(error)); + child.once('close', exitCode => { + if (timedOut) { + finish( + new Error( + `ESM development integration subprocess timed out.\n${Buffer.concat(output).toString('utf8')}` + ) + ); + return; + } + if (exitCode === 0) { + finish(); + return; + } + finish( + new Error( + `ESM development integration subprocess exited with ${exitCode}.\n${Buffer.concat(output).toString('utf8')}` + ) + ); + }); + }); + +const createDevRuntimeHarness = async (esm: boolean) => { + const repositoryRoot = process.cwd(); + const temporaryFixtures = join(repositoryRoot, 'tests/.tmp-dev-runtime'); + mkdirSync(temporaryFixtures, { recursive: true }); + const fixtureRoot = mkdtempSync(join(temporaryFixtures, 'case-')); + cpSync(join(repositoryRoot, 'tests/fixtures/dev-runtime'), fixtureRoot, { + recursive: true, + }); + const executableDirectory = join(fixtureRoot, '.bin'); + mkdirSync(executableDirectory, { recursive: true }); + const npxPath = join(executableDirectory, 'npx'); + writeFileSync(npxPath, '#!/bin/sh\nexit 0\n'); + chmodSync(npxPath, 0o755); + const routePath = join(fixtureRoot, 'app/routes/index.tsx'); + writeFileSync(routePath, INITIAL_COMPILATION_ERROR); + + const existsSyncMock = fs.existsSync as typeof fs.existsSync & { + mockRestore?: () => void; + }; + existsSyncMock.mockRestore?.(); + const watchMock = rstest + .spyOn(fs, 'watch') + .mockImplementation((() => createNoopWatcher()) as typeof fs.watch); + const logger = createLogger({ level: 'silent' }); + const loggerError = rstest.spyOn(logger, 'error'); + const consoleError = rstest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const originalPath = process.env.PATH; + let server: RsbuildDevServer | null = null; + let httpServer: HttpServer | null = null; + let builtInServerUrl: string | undefined; + let compiler: Rspack.MultiCompiler | undefined; + let compileAttempts = 0; + let completedCompiles = 0; + let cleaned = false; + + const closeBuiltInServer = async (): Promise => { + const current = httpServer; + httpServer = null; + builtInServerUrl = undefined; + if (current) { + await new Promise((resolve, reject) => + current.close(error => (error ? reject(error) : resolve())) + ); + } + }; + const closeDevServer = async (): Promise => { + const current = server; + server = null; + await current?.close(); + }; + const cleanup = async (): Promise => { + if (cleaned) { + return; + } + cleaned = true; + try { + await Promise.all([closeBuiltInServer(), closeDevServer()]); + } finally { + process.chdir(repositoryRoot); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + loggerError.mockRestore(); + consoleError.mockRestore(); + watchMock.mockRestore(); + rmSync(fixtureRoot, { force: true, recursive: true }); + } + }; + + process.env.PATH = `${executableDirectory}${delimiter}${originalPath ?? ''}`; + process.chdir(fixtureRoot); + try { + rstest.doUnmock('jiti'); + const { loadReactRouterServerBuild, pluginReactRouter } = + await import('../src/index'); + const captureCompilerPlugin: RsbuildPlugin = { + name: 'test:capture-real-multi-compiler', + setup(api) { + api.onBeforeDevCompile(() => { + compileAttempts += 1; + }); + api.onAfterDevCompile(() => { + completedCompiles += 1; + }); + api.onAfterCreateCompiler(({ compiler: createdCompiler }) => { + if (!('compilers' in createdCompiler)) { + throw new Error('Expected Rsbuild to create a MultiCompiler.'); + } + compiler = createdCompiler; + }); + }, + }; + const rsbuildConfig: RsbuildConfig = { + customLogger: logger, + dev: { cliShortcuts: false, hmr: false, liveReload: false }, + plugins: [ + pluginReactRouter({ + onRouteTopologyChange() {}, + ...(esm ? {} : { serverOutput: 'commonjs' as const }), + }), + pluginReact(), + captureCompilerPlugin, + ], + root: fixtureRoot, + server: { middlewareMode: true }, + tools: { + rspack(config) { + config.watchOptions = { + ...config.watchOptions, + aggregateTimeout: 10, + poll: 50, + }; + }, + }, + }; + const rsbuild = await createRsbuild({ cwd: fixtureRoot, rsbuildConfig }); + + const assertNodeCompiler = (): void => { + const nodeCompiler = compiler?.compilers.find( + child => child.name === 'node' + ); + expect(nodeCompiler?.options.output.module).toBe(esm); + expect(Object.keys(nodeCompiler?.options.entry ?? {})).toEqual( + expect.arrayContaining([ + 'static/js/app', + 'static/js/react-router-server-build', + 'index/index', + 'other/index', + ]) + ); + if (esm) { + expect(vm.SourceTextModule).toBeTypeOf('function'); + } + }; + const startDevServer = async (): Promise => { + server = await rsbuild.createDevServer({ getPortSilently: true }); + assertNodeCompiler(); + }; + const loadBuild = (entryName?: string): Promise => + server + ? loadReactRouterServerBuild(server, entryName) + : Promise.reject(new Error('The test dev server is closed.')); + const loadRawEntry = (entryName: string): Promise => + server + ? server.environments.node.loadBundle(entryName) + : Promise.reject(new Error('The test dev server is closed.')); + const requestHandler = createRequestHandler(loadBuild); + const requestDocument = async (): Promise => { + const response = await requestHandler(new Request('http://test.local/')); + expect(response.status).toBe(200); + return response.text(); + }; + const invalidate = async ( + source: string, + webOnly: boolean + ): Promise => { + await new Promise(resolve => setTimeout(resolve, 75)); + writeFileSync(routePath, source); + const watching = webOnly + ? compiler?.compilers.find(child => child.name === 'web')?.watching + : compiler?.watching; + if (!watching) { + throw new Error( + `Expected the real ${webOnly ? 'web compiler' : 'MultiCompiler'} to be watching.` + ); + } + await new Promise((resolve, reject) => { + watching.invalidateWithChangesAndRemovals( + new Set([routePath]), + new Set(), + error => (error ? reject(error) : resolve()) + ); + }); + }; + const startBuiltInServer = async (): Promise => { + if (!server) { + throw new Error('The test dev server is closed.'); + } + httpServer = createServer(server.middlewares); + await new Promise((resolve, reject) => { + httpServer!.once('error', reject); + httpServer!.listen(0, '127.0.0.1', resolve); + }); + const address = httpServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the built-in middleware server to listen.'); + } + builtInServerUrl = `http://127.0.0.1:${address.port}/`; + }; + const requestBuiltInDocument = async (): Promise => { + if (!builtInServerUrl) { + throw new Error('The built-in middleware server is not listening.'); + } + const response = await fetch(builtInServerUrl); + expect(response.status).toBe(200); + return response.text(); + }; + const restartDevServer = async (): Promise => { + await Promise.all([closeBuiltInServer(), closeDevServer()]); + await startDevServer(); + }; + + await startDevServer(); + return { + cleanup, + get compileAttempts() { + return compileAttempts; + }, + get completedCompiles() { + return completedCompiles; + }, + hasConsoleError: (marker: string) => + consoleError.mock.calls.some(args => + args.some(arg => String(arg).includes(marker)) + ), + hasLoggedError: (marker: string) => + loggerError.mock.calls.some(args => + args.some(arg => String(arg).includes(marker)) + ), + loadBuild, + loadRawEntry, + rebuildRoute: (source: string) => invalidate(source, false), + rebuildWebFirst: (source: string) => invalidate(source, true), + requestBuiltInDocument, + requestDocument, + restartDevServer, + startBuiltInServer, + }; + } catch (error) { + await cleanup(); + throw error; + } +}; + +type DevRuntimeHarness = Awaited>; + +const expectInitialCompilationFailure = async ( + harness: DevRuntimeHarness +): Promise => { + await expect( + withTimeout(harness.loadBuild(), 20_000, 'the initial compilation failure') + ).rejects.toThrow('development compilation failed'); + expect(harness.hasConsoleError('Unexpected token')).toBe(true); + expect(harness.compileAttempts).toBeGreaterThan(0); + expect(harness.completedCompiles).toBeGreaterThan(0); +}; + +const expectFirstCommittedGeneration = async ( + harness: DevRuntimeHarness +): Promise => { + const attemptsBeforeRecovery = harness.compileAttempts; + const completedBeforeRecovery = harness.completedCompiles; + await harness.rebuildRoute(routeSource('v1')); + expect(harness.compileAttempts).toBeGreaterThan(attemptsBeforeRecovery); + await expect + .poll(() => harness.completedCompiles, { + intervals: [50, 100, 250], + timeout: 20_000, + }) + .toBeGreaterThan(completedBeforeRecovery); + + const build = await withTimeout( + harness.loadBuild(), + 5_000, + `the first valid generation after ${harness.compileAttempts} attempts and ${harness.completedCompiles} completions` + ); + expect(getBuildMarker(build)).toBe('v1'); + expect(build.assets).toBeTypeOf('object'); + expect(build.entry).toBeTypeOf('object'); + expect(build.routes).toBeTypeOf('object'); + await expect(harness.loadBuild('index/index')).resolves.toMatchObject({ + assets: { + routes: { + root: expect.any(Object), + 'routes/index': expect.any(Object), + }, + }, + }); + await expect(harness.loadBuild('other/index')).resolves.toMatchObject({ + assets: { + routes: { + root: expect.any(Object), + 'routes/other': expect.any(Object), + }, + }, + }); + await expect(harness.loadBuild('missing/index')).rejects.toThrow( + 'not part of this development server build plan' + ); + await expect(harness.loadRawEntry('static/js/app')).resolves.toMatchObject({ + customServerMarker: 'custom-server-entry', + }); + await expect(harness.requestDocument()).resolves.toContain('v1'); + await harness.startBuiltInServer(); + await expect(harness.requestBuiltInDocument()).resolves.toContain('v1'); +}; + +const expectWebFirstRebuild = async ( + harness: DevRuntimeHarness +): Promise => { + await harness.rebuildWebFirst(routeSource('v2')); + await expect + .poll(async () => getBuildMarker(await harness.loadBuild()), { + intervals: [50, 100, 250], + timeout: 20_000, + }) + .toBe('v2'); + await expect(harness.requestDocument()).resolves.toContain('v2'); + await expect(harness.requestBuiltInDocument()).resolves.toContain('v2'); +}; + +const expectEvaluationFailurePreservesLastGood = async ( + harness: DevRuntimeHarness +): Promise => { + await harness.rebuildRoute(EVALUATION_ERROR); + await expect + .poll(() => harness.hasLoggedError(EVALUATION_ERROR_MARKER), { + intervals: [50, 100, 250], + timeout: 20_000, + }) + .toBe(true); + expect(getBuildMarker(await harness.loadBuild())).toBe('v2'); +}; + +const expectRecoveryAndRestart = async ( + harness: DevRuntimeHarness +): Promise => { + await harness.rebuildRoute(routeSource('v3')); + await expect + .poll(async () => getBuildMarker(await harness.loadBuild()), { + intervals: [50, 100, 250], + timeout: 20_000, + }) + .toBe('v3'); + await harness.restartDevServer(); + const build = await withTimeout( + harness.loadBuild(), + 20_000, + 'a generation after restarting the same Rsbuild instance' + ); + expect(getBuildMarker(build)).toBe('v3'); + await expect(harness.requestDocument()).resolves.toContain('v3'); +}; + +const runDevRuntimeScenario = async (esm: boolean): Promise => { + const harness = await createDevRuntimeHarness(esm); + try { + await expectInitialCompilationFailure(harness); + await expectFirstCommittedGeneration(harness); + await expectWebFirstRebuild(harness); + await expectEvaluationFailurePreservesLastGood(harness); + await expectRecoveryAndRestart(harness); + } finally { + await harness.cleanup(); + } +}; + +it( + `publishes recoverable generations through real Rsbuild ${ + isEsmSubprocess ? 'ESM' : 'CommonJS' + } server paths`, + () => runDevRuntimeScenario(isEsmSubprocess), + 90_000 +); + +if (!isEsmSubprocess) { + it('runs the real default ESM development path with VM modules enabled', async () => { + await runEsmIntegrationSubprocess(process.cwd()); + }, 90_000); +} diff --git a/tests/dev-server.test.ts b/tests/dev-server.test.ts new file mode 100644 index 0000000..afc6048 --- /dev/null +++ b/tests/dev-server.test.ts @@ -0,0 +1,68 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { describe, expect, it, rstest } from '@rstest/core'; +import type { ServerBuild } from 'react-router'; +import { createDevServerMiddleware } from '../src/dev-server'; + +const build = { + entry: { module: {} }, + routes: {}, + assets: {}, +} as unknown as ServerBuild; + +describe('React Router development middleware', () => { + it('constructs one lazy request handler over the committed build provider', async () => { + const loadBuild = rstest.fn(() => Promise.resolve(build)); + const requestHandler = rstest.fn(async () => { + await capturedBuildProvider?.(); + return new Response(); + }); + let capturedBuildProvider: (() => Promise) | undefined; + const createRequestHandler = rstest.fn( + (buildProvider: () => Promise) => { + capturedBuildProvider = buildProvider; + return requestHandler; + } + ); + const listener = rstest.fn( + async (_req: IncomingMessage, _res: ServerResponse) => { + await requestHandler(new Request('http://localhost/')); + } + ); + const createRequestListener = rstest.fn(() => listener); + const next = rstest.fn(); + const middleware = createDevServerMiddleware({ + loadBuild, + createRequestHandler, + createRequestListener, + }); + + await middleware({} as IncomingMessage, {} as ServerResponse, next); + await middleware({} as IncomingMessage, {} as ServerResponse, next); + + expect(createRequestHandler).toHaveBeenCalledTimes(1); + expect(createRequestHandler).toHaveBeenCalledWith( + loadBuild, + 'development' + ); + expect(createRequestListener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(2); + expect(loadBuild).toHaveBeenCalledTimes(2); + expect(next).not.toHaveBeenCalled(); + }); + + it('forwards listener failures to the next middleware', async () => { + const error = new Error('request failed'); + const next = rstest.fn(); + const middleware = createDevServerMiddleware({ + loadBuild: () => Promise.resolve(build), + createRequestHandler: () => () => Promise.reject(error), + createRequestListener: handler => async () => { + await handler(new Request('http://localhost/')); + }, + }); + + await middleware({} as IncomingMessage, {} as ServerResponse, next); + + expect(next).toHaveBeenCalledWith(error); + }); +}); diff --git a/tests/fixtures/dev-runtime/app/entry.client.tsx b/tests/fixtures/dev-runtime/app/entry.client.tsx new file mode 100644 index 0000000..58f3534 --- /dev/null +++ b/tests/fixtures/dev-runtime/app/entry.client.tsx @@ -0,0 +1,7 @@ +import { startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +startTransition(() => { + hydrateRoot(document, ); +}); diff --git a/tests/fixtures/dev-runtime/app/entry.server.tsx b/tests/fixtures/dev-runtime/app/entry.server.tsx new file mode 100644 index 0000000..0249f81 --- /dev/null +++ b/tests/fixtures/dev-runtime/app/entry.server.tsx @@ -0,0 +1,20 @@ +import { renderToString } from 'react-dom/server'; +import { ServerRouter, type EntryContext } from 'react-router'; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext +) { + responseHeaders.set('Content-Type', 'text/html'); + return new Response( + `${renderToString( + + )}`, + { + headers: responseHeaders, + status: responseStatusCode, + } + ); +} diff --git a/tests/fixtures/dev-runtime/app/root.tsx b/tests/fixtures/dev-runtime/app/root.tsx new file mode 100644 index 0000000..1dbb6b6 --- /dev/null +++ b/tests/fixtures/dev-runtime/app/root.tsx @@ -0,0 +1,14 @@ +import { Outlet } from 'react-router'; + +export default function Root() { + return ( + + + + + + + + + ); +} diff --git a/tests/fixtures/dev-runtime/app/routes.ts b/tests/fixtures/dev-runtime/app/routes.ts new file mode 100644 index 0000000..c26fe55 --- /dev/null +++ b/tests/fixtures/dev-runtime/app/routes.ts @@ -0,0 +1,6 @@ +import { index, route, type RouteConfig } from '@react-router/dev/routes'; + +export default [ + index('routes/index.tsx'), + route('other', 'routes/other.tsx'), +] satisfies RouteConfig; diff --git a/tests/fixtures/dev-runtime/app/routes/index.tsx b/tests/fixtures/dev-runtime/app/routes/index.tsx new file mode 100644 index 0000000..5b5390d --- /dev/null +++ b/tests/fixtures/dev-runtime/app/routes/index.tsx @@ -0,0 +1,5 @@ +export const handle = { marker: 'fixture' }; + +export default function IndexRoute() { + return

fixture

; +} diff --git a/tests/fixtures/dev-runtime/app/routes/other.tsx b/tests/fixtures/dev-runtime/app/routes/other.tsx new file mode 100644 index 0000000..18db7e8 --- /dev/null +++ b/tests/fixtures/dev-runtime/app/routes/other.tsx @@ -0,0 +1,3 @@ +export default function OtherRoute() { + return

other

; +} diff --git a/tests/fixtures/dev-runtime/react-router.config.ts b/tests/fixtures/dev-runtime/react-router.config.ts new file mode 100644 index 0000000..8520505 --- /dev/null +++ b/tests/fixtures/dev-runtime/react-router.config.ts @@ -0,0 +1,8 @@ +export default { + appDirectory: 'app', + buildDirectory: 'build', + routeDiscovery: { mode: 'lazy' }, + serverBundles: async ({ branch }) => + branch.at(-1)?.path === 'other' ? 'other' : 'index', + ssr: true, +}; diff --git a/tests/fixtures/dev-runtime/server/index.ts b/tests/fixtures/dev-runtime/server/index.ts new file mode 100644 index 0000000..4c3cb2a --- /dev/null +++ b/tests/fixtures/dev-runtime/server/index.ts @@ -0,0 +1 @@ +export const customServerMarker = 'custom-server-entry'; diff --git a/tests/index.test.ts b/tests/index.test.ts index 51d5a24..3e775c5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -19,6 +19,34 @@ describe('pluginReactRouter', () => { expect(config.dev.lazyCompilation).toBeUndefined(); }); + it('adds the committed custom-server build entry only in development', async () => { + const devRsbuild = await createStubRsbuild({ rsbuildConfig: {} }); + devRsbuild.addPlugins([pluginReactRouter({ customServer: true })]); + const devConfig = await devRsbuild.unwrapConfig(); + + expect( + devConfig.environments.node.source.entry[ + 'static/js/react-router-server-build' + ] + ).toBe('virtual/react-router/server-build'); + + const buildRsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + buildRsbuild.addPlugins([pluginReactRouter({ customServer: true })]); + const buildConfig = await buildRsbuild.unwrapConfig(); + + expect( + buildConfig.environments.node.source.entry[ + 'static/js/react-router-server-build' + ] + ).toBeUndefined(); + expect(buildRsbuild.onBeforeDevCompile).not.toHaveBeenCalled(); + expect(buildRsbuild.onAfterDevCompile).not.toHaveBeenCalled(); + expect(buildRsbuild.onAfterCreateCompiler).not.toHaveBeenCalled(); + }); + it('should restart the dev server when route entries are added', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: { diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 34f278e..951155e 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -25,6 +25,56 @@ const createAsset = (source: string) => ({ }); describe('modify browser manifest plugin', () => { + it('reports the exact compilation that produced the manifest', async () => { + const { root, appDir } = createTempApp(); + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + }; + let emit: ((compilation: unknown) => Promise) | undefined; + let reportedCompilation: unknown; + const compiler = { + hooks: { + emit: { + tapPromise(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + const compilation = { + namedChunks: new Map([ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['root', { files: new Set(['static/js/root.js']) }], + ]), + assets: { + 'static/js/virtual/react-router/browser-manifest.js': createAsset( + 'window.__reactRouterManifest="PLACEHOLDER";' + ), + }, + }; + + try { + createModifyBrowserManifestPlugin( + routes, + {}, + appDir, + '/', + undefined, + { + onManifest(_manifest, _sri, _exports, context) { + reportedCompilation = context.compilation; + }, + } + ).apply(compiler as never); + + await emit?.(compilation); + + expect(reportedCompilation).toBe(compilation); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('rejects the promise hook when build route analysis fails', async () => { const { root, appDir } = createTempApp(); writeFileSync(join(appDir, 'routes/page.tsx'), 'export const = broken;'); diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index ca403a8..d47083d 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -30,4 +30,22 @@ describe('resolveReactRouterConfig', () => { }); expect(buildEndCalls).toBe(2); }); + + it('preserves server bundle selection in SSR mode', async () => { + const serverBundles = async () => 'bundle'; + + const result = await resolveReactRouterConfig({ serverBundles }); + + expect(result.resolved.serverBundles).toBe(serverBundles); + }); + + it('distinguishes an explicit server module format from its default', async () => { + const defaultResult = await resolveReactRouterConfig({}); + const configuredResult = await resolveReactRouterConfig({ + serverModuleFormat: 'cjs', + }); + + expect(defaultResult.hasConfiguredServerModuleFormat).toBe(false); + expect(configuredResult.hasConfiguredServerModuleFormat).toBe(true); + }); }); diff --git a/tests/server-utils.test.ts b/tests/server-utils.test.ts new file mode 100644 index 0000000..47149c4 --- /dev/null +++ b/tests/server-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from '@rstest/core'; +import type { ServerBuild } from 'react-router'; +import { resolveReactRouterServerBuild } from '../src'; + +const createBuild = (version: string): ServerBuild => + ({ + entry: { module: { default: () => new Response() } }, + routes: {}, + assets: { routes: {}, version }, + assetsBuildDirectory: '/app/build/client', + basename: '/', + future: {}, + isSpaMode: false, + prerender: [], + publicPath: '/', + routeDiscovery: { mode: 'initial' }, + ssr: true, + }) as unknown as ServerBuild; + +describe('resolveReactRouterServerBuild', () => { + it('accepts a direct ESM server build', async () => { + const build = createBuild('esm'); + + await expect(resolveReactRouterServerBuild(build)).resolves.toMatchObject({ + assets: { version: 'esm' }, + }); + }); + + it('accepts lazy route discovery with its optional manifest path omitted', async () => { + const build = { + ...createBuild('lazy'), + routeDiscovery: { mode: 'lazy' as const }, + }; + + await expect(resolveReactRouterServerBuild(build)).resolves.toMatchObject({ + assets: { version: 'lazy' }, + routeDiscovery: { mode: 'lazy' }, + }); + }); + + it('unwraps CommonJS dynamic-import namespaces', async () => { + const build = createBuild('commonjs'); + + await expect( + resolveReactRouterServerBuild({ + default: build, + 'module.exports': build, + }) + ).resolves.toMatchObject({ assets: { version: 'commonjs' } }); + }); + + it('resolves recognized asynchronous build exports', async () => { + const build = createBuild('async'); + + await expect( + resolveReactRouterServerBuild({ + ...build, + assets: async () => build.assets, + }) + ).resolves.toMatchObject({ assets: { version: 'async' } }); + }); + + it('rejects modules without a React Router server build', async () => { + await expect( + resolveReactRouterServerBuild({ default: { routes: {} } }) + ).rejects.toThrow('valid React Router ServerBuild'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 8df5a63..9698e5a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -131,6 +131,9 @@ rstest.mock('@scripts/test-helper', () => ({ onCloseBuild: rstest.fn(), onBeforeBuild: rstest.fn(), onAfterBuild: rstest.fn(), + onBeforeDevCompile: rstest.fn(), + onAfterDevCompile: rstest.fn(), + onAfterCreateCompiler: rstest.fn(), getNormalizedConfig: rstest.fn().mockImplementation(() => mergedConfig), modifyRsbuildConfig: rstest.fn(), onAfterEnvironmentCompile: rstest.fn(), From 3794f911359cd9a8989f764432e2dfab3033e2e1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:01:51 +0000 Subject: [PATCH 44/64] fix: address route transform review feedback --- README.md | 4 +- .../scripts/smoke-production.mjs | 12 +- scripts/test-package-interop.mjs | 8 +- src/bounded-cache.ts | 4 + src/manifest.ts | 34 ++++- src/route-component-transform.ts | 44 ++++-- src/route-export-pruning.ts | 12 ++ src/route-transform-tasks.ts | 142 +++++++++++++++++- src/route-watch.ts | 7 +- tests/bounded-cache.test.ts | 8 + tests/build-manifest.test.ts | 6 +- tests/client-modules.test.ts | 65 +++++++- tests/dev-runtime-controller.test.ts | 22 +-- tests/modify-browser-manifest.test.ts | 11 +- tests/plugin-utils.test.ts | 26 ++++ tests/react-router-config.test.ts | 5 +- tests/remove-exports.test.ts | 9 +- tests/route-watch.test.ts | 45 +++++- tests/setup.ts | 3 +- 19 files changed, 420 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index b6678b5..a733acb 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ pluginReactRouter({ /** * Rsbuild dev-only lazy compilation behavior. - * @default false + * @default undefined */ lazyCompilation?: boolean | Rspack.LazyCompilationOptions, @@ -307,7 +307,7 @@ If no configuration is provided, the following defaults will be used: customServer: false, serverOutput: 'module', federation: false, - lazyCompilation: false, + lazyCompilation: undefined, logPerformance: false, parallelTransforms: undefined // adaptive: workers for 256+ resolved routes } diff --git a/examples/custom-node-server/scripts/smoke-production.mjs b/examples/custom-node-server/scripts/smoke-production.mjs index fe15161..8de3cdf 100644 --- a/examples/custom-node-server/scripts/smoke-production.mjs +++ b/examples/custom-node-server/scripts/smoke-production.mjs @@ -13,6 +13,16 @@ assert(address && typeof address !== 'string'); const { port } = address; await new Promise(resolve => probe.close(resolve)); +const fetchWithTimeout = async url => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +}; + const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { @@ -38,7 +48,7 @@ try { throw new Error(`Production server exited early.\n${output}`); } try { - response = await fetch(`http://127.0.0.1:${port}/`); + response = await fetchWithTimeout(`http://127.0.0.1:${port}/`); if (response.ok) { break; } diff --git a/scripts/test-package-interop.mjs b/scripts/test-package-interop.mjs index 4783046..d68d01e 100644 --- a/scripts/test-package-interop.mjs +++ b/scripts/test-package-interop.mjs @@ -45,8 +45,12 @@ async function verifyRegistration(writer, reader) { }; await writer.pluginReactRouter({ customServer: true }).setup(api); - const start = starts.find(hook => hook.order === 'pre').handler; - const close = closes.find(hook => hook.order === 'pre').handler; + const startHook = starts.find(hook => hook.order === 'pre'); + const closeHook = closes.find(hook => hook.order === 'pre'); + assert(startHook, 'Expected a pre dev-server start hook'); + assert(closeHook, 'Expected a pre dev-server close hook'); + const start = startHook.handler; + const close = closeHook.handler; const server = { close: async () => undefined, environments: { node: { loadBundle: async () => build } }, diff --git a/src/bounded-cache.ts b/src/bounded-cache.ts index b7d6df0..8c727d9 100644 --- a/src/bounded-cache.ts +++ b/src/bounded-cache.ts @@ -4,6 +4,10 @@ export const setBoundedCacheEntry = ( value: Value, maxEntries: number ): void => { + if (maxEntries <= 0) { + cache.clear(); + return; + } if (!cache.has(key) && cache.size >= maxEntries) { const oldestKey = cache.keys().next().value; if (oldestKey !== undefined) { diff --git a/src/manifest.ts b/src/manifest.ts index d8721b6..c6309b3 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,6 +15,33 @@ import { type RouteChunkConfig, } from './route-chunks.js'; import { getRouteModuleAnalysis } from './export-utils.js'; +import { getDefaultConcurrency } from './concurrency.js'; + +const ROUTE_ANALYSIS_CONCURRENCY = Math.max( + 1, + Math.min(16, getDefaultConcurrency() || 1) +); + +const mapWithConcurrency = async ( + items: readonly Item[], + worker: (item: Item, index: number) => Promise +): Promise => { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(ROUTE_ANALYSIS_CONCURRENCY, items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await worker(items[index], index); + } + }) + ); + return results; +}; export function configRoutesToRouteManifest( appDirectory: string, @@ -262,8 +289,9 @@ export async function generateReactRouterManifestForDev( return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined; }; - const manifestEntries = await Promise.all( - Object.entries(routes).map(async ([key, route]) => { + const manifestEntries = await mapWithConcurrency( + Object.entries(routes), + async ([key, route]) => { const routeEntryName = getRouteEntryName(route); const assets = getAssetsForChunk(routeEntryName); const jsAssets = assets.filter(asset => asset.endsWith('.js')); @@ -365,7 +393,7 @@ export async function generateReactRouterManifestForDev( }, routeModuleExports, ] as const; - }) + } ); const routeModuleExportsByRouteId: RouteManifestModuleExports = {}; diff --git a/src/route-component-transform.ts b/src/route-component-transform.ts index b1c3d83..6d921d1 100644 --- a/src/route-component-transform.ts +++ b/src/route-component-transform.ts @@ -107,6 +107,28 @@ const exportNamedDeclaration = (specifiers: AnyNode[]): AnyNode => ({ exportKind: 'value', }); +const getComponentExportName = (exportedName: string): string | null => { + if (exportedName === 'default') { + return 'Component'; + } + return isNamedComponentExport(exportedName) ? exportedName : null; +}; + +const getImportInsertionIndex = (program: AnyNode): number => { + let index = 0; + for (const statement of program.body ?? []) { + if ( + statement.type !== 'ExpressionStatement' || + (statement.directive === undefined && + statement.expression?.type !== 'Literal') + ) { + break; + } + index += 1; + } + return index; +}; + const getModuleExportName = ( node: AnyNode | null | undefined ): string | null => { @@ -331,16 +353,15 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { } const exportedName = getExportedName(specifier); const importedName = getModuleExportName(specifier.local); - if ( - !exportedName || - !importedName || - !isNamedComponentExport(exportedName) - ) { + const componentExportName = exportedName + ? getComponentExportName(exportedName) + : null; + if (!exportedName || !importedName || !componentExportName) { return true; } const sourceLocalName = getUid(`${exportedName}Source`); const wrappedLocalName = getUid(exportedName); - const uid = getHocUid(`with${exportedName}Props`); + const uid = getHocUid(`with${componentExportName}Props`); importSpecifiers.push({ imported: importedName, local: sourceLocalName, @@ -382,7 +403,10 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { continue; } const exportedName = getExportedName(specifier); - if (!exportedName || !isNamedComponentExport(exportedName)) { + const componentExportName = exportedName + ? getComponentExportName(exportedName) + : null; + if (!exportedName || !componentExportName) { continue; } const localName = specifier.local?.name; @@ -390,7 +414,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { continue; } const wrappedLocalName = getUid(exportedName); - const uid = getHocUid(`with${exportedName}Props`); + const uid = getHocUid(`with${componentExportName}Props`); componentWrapperDeclarations.push( variableDeclaration( wrappedLocalName, @@ -404,7 +428,9 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { program.body.push(...componentWrapperDeclarations); if (hocs.length > 0) { - program.body.unshift( + program.body.splice( + getImportInsertionIndex(program), + 0, importDeclaration( hocs.map(([name, local]) => ({ imported: name, local })), 'virtual/react-router/with-props' diff --git a/src/route-export-pruning.ts b/src/route-export-pruning.ts index 72fd89a..5cfd5fb 100644 --- a/src/route-export-pruning.ts +++ b/src/route-export-pruning.ts @@ -476,6 +476,9 @@ const hasRemovableExport = ( program: AnyNode, exportsToRemove: ReadonlySet ): boolean => { + const removesNamedExports = [...exportsToRemove].some( + name => name !== 'default' + ); for (const statement of program.body ?? []) { if (statement.type === 'ExportAllDeclaration') { const exportedName = statement.exported @@ -484,6 +487,9 @@ const hasRemovableExport = ( if (exportedName && exportsToRemove.has(exportedName)) { return true; } + if (!exportedName && removesNamedExports) { + return true; + } continue; } @@ -550,6 +556,7 @@ export const removeExports = ( let exportsChanged = false; const removedExportLocalNames = new Set(); const removedExportReferencedNames = new Set(); + const removesNamedExports = exportsToRemove.some(name => name !== 'default'); const trackRemovedExportReferences = (node: AnyNode | null | undefined) => { if (!node) { return; @@ -570,6 +577,11 @@ export const removeExports = ( exportsChanged = true; removeFromArray(program.body, statement); } + if (!exportedName && removesNamedExports) { + throw new Error( + 'Cannot remove named exports from `export *`; use explicit named re-exports.' + ); + } continue; } diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index 1bb134d..39b6479 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -1,4 +1,4 @@ -import { statSync, type Stats } from 'node:fs'; +import { readFileSync, 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'; @@ -40,6 +40,12 @@ type BaseRouteTransformTask = { resourcePath: string; }; +type PackageJson = { + exports?: unknown; + module?: unknown; + main?: unknown; +}; + export type RouteClientEntryTransformTask = BaseRouteTransformTask & { kind: 'routeClientEntry'; environmentName?: string; @@ -169,6 +175,135 @@ const resolvePathWithExtensions = (basePath: string): string | null => { return resolveIndexFile(basePath); }; +const parsePackageSpecifier = ( + specifier: string +): { packageName: string; subpath: string } | null => { + if ( + specifier.startsWith('.') || + specifier.startsWith('/') || + specifier.startsWith('node:') + ) { + return null; + } + const parts = specifier.split('/'); + const packageName = specifier.startsWith('@') + ? parts.slice(0, 2).join('/') + : parts[0]; + if (!packageName || (specifier.startsWith('@') && parts.length < 2)) { + return null; + } + const rest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/'); + return { + packageName, + subpath: rest ? `./${rest}` : '.', + }; +}; + +const findPackageDirectory = ( + packageName: string, + importerPath: string +): string | null => { + let currentDirectory = dirname(importerPath); + while (true) { + const candidate = resolve(currentDirectory, 'node_modules', packageName); + if (tryStat(candidate)?.isDirectory()) { + return candidate; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } +}; + +const readPackageJson = (packageDirectory: string): PackageJson | null => { + try { + return JSON.parse( + readFileSync(resolve(packageDirectory, 'package.json'), 'utf8') + ); + } catch { + return null; + } +}; + +const resolvePackageTarget = ( + packageDirectory: string, + target: unknown +): string | null => { + if (typeof target === 'string') { + return resolvePathWithExtensions(resolve(packageDirectory, target)); + } + if (Array.isArray(target)) { + for (const item of target) { + const resolved = resolvePackageTarget(packageDirectory, item); + if (resolved) { + return resolved; + } + } + return null; + } + if (target && typeof target === 'object') { + const conditions = target as Record; + for (const condition of ['import', 'default']) { + const resolved = resolvePackageTarget( + packageDirectory, + conditions[condition] + ); + if (resolved) { + return resolved; + } + } + } + return null; +}; + +const resolvePackageImport = ( + specifier: string, + importerPath: string +): string | null => { + const parsed = parsePackageSpecifier(specifier); + if (!parsed) { + return null; + } + const packageDirectory = findPackageDirectory( + parsed.packageName, + importerPath + ); + if (!packageDirectory) { + return null; + } + const packageJson = readPackageJson(packageDirectory); + if (!packageJson) { + return null; + } + const exportsField = packageJson.exports; + if (exportsField) { + const hasSubpathExports = + typeof exportsField === 'object' && + !Array.isArray(exportsField) && + Object.keys(exportsField).some(key => key.startsWith('.')); + const target = + parsed.subpath === '.' && !hasSubpathExports + ? exportsField + : hasSubpathExports + ? (exportsField as Record)[parsed.subpath] + : undefined; + const resolved = resolvePackageTarget(packageDirectory, target); + if (resolved) { + return resolved; + } + } + if (parsed.subpath !== '.') { + return resolvePathWithExtensions(resolve(packageDirectory, parsed.subpath)); + } + return ( + resolvePackageTarget(packageDirectory, packageJson.module) ?? + resolvePackageTarget(packageDirectory, packageJson.main) ?? + resolveIndexFile(packageDirectory) + ); +}; + const resolveExportAllModule = ( specifier: string, importerPath: string @@ -183,6 +318,11 @@ const resolveExportAllModule = ( } } + const packageImportPath = resolvePackageImport(specifier, importerPath); + if (packageImportPath) { + return packageImportPath; + } + try { const resolver = createRequire(pathToFileURL(importerPath).href); return resolver.resolve(specifier); diff --git a/src/route-watch.ts b/src/route-watch.ts index 7fbaaf0..500357f 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -234,9 +234,12 @@ export const createRouteTopologyWatcher = async ({ // This is a notification boundary, not part of the rescan // transaction. A custom-server callback may close this watcher while // replacing its compiler, so awaiting it here would deadlock close(). - const notification = onRouteTopologyChange(); state = nextState; - void Promise.resolve(notification).catch(onError); + try { + void Promise.resolve(onRouteTopologyChange()).catch(onError); + } catch (error) { + onError(error); + } return; } else { await touchRestartMarker(); diff --git a/tests/bounded-cache.test.ts b/tests/bounded-cache.test.ts index b26eeb2..1a29c21 100644 --- a/tests/bounded-cache.test.ts +++ b/tests/bounded-cache.test.ts @@ -20,4 +20,12 @@ describe('bounded cache helpers', () => { ['third', 3], ]); }); + + it('clears entries when the maximum size is not positive', () => { + const cache = new Map([['first', 1]]); + + setBoundedCacheEntry(cache, 'second', 2, 0); + + expect([...cache.entries()]).toEqual([]); + }); }); diff --git a/tests/build-manifest.test.ts b/tests/build-manifest.test.ts index 64623a8..5361c74 100644 --- a/tests/build-manifest.test.ts +++ b/tests/build-manifest.test.ts @@ -55,9 +55,9 @@ describe('build manifest', () => { expect(result).toHaveProperty('serverBundles'); expect(result).toHaveProperty('routeIdToServerBundleId'); expect(result?.routes.root.file).toBeDefined(); - expect( - getRoutesByServerBundleId(result, routes).bundle_2['routes/about'].file - ).toBe('routes/about.tsx'); + const bundleRoutes = getRoutesByServerBundleId(result, routes).bundle_2; + expect(bundleRoutes.root.file).toBe('root.tsx'); + expect(bundleRoutes['routes/about'].file).toBe('routes/about.tsx'); }); it('validates server bundle IDs based on vite environment API flag', async () => { diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts index 75fc22d..23b6b5e 100644 --- a/tests/client-modules.test.ts +++ b/tests/client-modules.test.ts @@ -1,4 +1,6 @@ -import { readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { resolve } from 'pathe'; import { describe, expect, it } from '@rstest/core'; import { createStubRsbuild } from '@scripts/test-helper'; @@ -34,4 +36,65 @@ describe('client-only module transforms', () => { expect(result.code).toContain('export const local = undefined;'); expect(result.code).not.toContain('export default undefined;'); }); + + it('uses import conditions for bare export-all modules', async () => { + const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-')); + const packageDirectory = join( + root, + 'node_modules', + 'conditional-client-lib' + ); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ + name: 'conditional-client-lib', + exports: { + '.': { + import: './esm.js', + require: './cjs.cjs', + }, + }, + type: 'module', + }) + ); + await writeFile( + join(packageDirectory, 'esm.js'), + 'export const esmOnly = true; export const shared = true;' + ); + await writeFile( + join(packageDirectory, 'cjs.cjs'), + 'exports.cjsOnly = true; exports.shared = true;' + ); + const resourcePath = join(root, 'app', 'example.client.ts'); + await mkdir(join(root, 'app'), { recursive: true }); + await writeFile(resourcePath, "export * from 'conditional-client-lib';"); + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as any); + + const transformCall = (rsbuild.transform as any).mock.calls.find( + (call: any[]) => call[0].test?.toString().includes('\\.client') + ); + expect(transformCall).toBeDefined(); + + const handler = transformCall?.[1]; + const result = await handler({ + environment: { name: 'node' }, + code: await readFile(resourcePath, 'utf8'), + resourcePath, + }); + + expect(result.code).toContain('export const esmOnly = undefined;'); + expect(result.code).toContain('export const shared = undefined;'); + expect(result.code).not.toContain('cjsOnly'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index 749c699..eb84766 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -243,16 +243,18 @@ const createHarness = (userSetup?: TestServerSetup) => { afterCloseHook?: () => Promise | void ): RsbuildDevServer => { let closing: Promise | undefined; - const record = { count: 0 }; - const server = { - close() { - record.count++; - closing ??= (async () => { - await closeHook?.(); - await afterCloseHook?.(); - })(); - return closing; - }, + const record = { count: 0 }; + const server = { + close() { + if (!closing) { + record.count++; + closing = (async () => { + await closeHook?.(); + await afterCloseHook?.(); + })(); + } + return closing; + }, environments: { node: { loadBundle } }, sockWrite: rstest.fn(), } as unknown as RsbuildDevServer; diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 951155e..05220c1 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -67,7 +67,8 @@ describe('modify browser manifest plugin', () => { } ).apply(compiler as never); - await emit?.(compilation); + expect(emit).toBeDefined(); + await emit(compilation); expect(reportedCompilation).toBe(compilation); } finally { @@ -105,7 +106,7 @@ describe('modify browser manifest plugin', () => { expect(emit).toBeDefined(); await expect( - emit?.({ + emit({ namedChunks: new Map(), assets: {}, }) @@ -149,7 +150,8 @@ describe('modify browser manifest plugin', () => { }, }); - await emit?.({ + expect(emit).toBeDefined(); + await emit({ namedChunks: new Map([ ['entry.client', { files: new Set(['static/js/entry.client.js']) }], ['root', { files: new Set(['static/js/root.js']) }], @@ -212,7 +214,8 @@ describe('modify browser manifest plugin', () => { }, }); - await emit?.({ + expect(emit).toBeDefined(); + await emit({ namedChunks: new Map([ ['entry.client', { files: new Set(['static/js/entry.client.js']) }], ['root', { files: new Set(['static/js/root.js']) }], diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 673ebd7..9915a9c 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -202,6 +202,32 @@ describe('plugin-utils', () => { expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); }); + it('wraps default route component re-exports', () => { + const result = transformRouteCode(` + export { default } from './Route'; + `); + + expect(result).toContain('withComponentProps'); + expect(result).not.toContain('withdefaultProps'); + expect(result).toContain('export { _default as default }'); + }); + + it('keeps directives before generated HOC imports', () => { + const result = transformRouteCode(` + "use client"; + function Route() { + return null; + } + export { Route as default }; + `); + + expect(result.indexOf("'use client'")).toBeLessThan( + result.indexOf('virtual/react-router/with-props') + ); + expect(result).toContain('withComponentProps'); + expect(result).not.toContain('withdefaultProps'); + }); + it('preserves side-effect import order before wrapped source re-exports', () => { const result = transformRouteCode(` import './setup'; diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index d47083d..b7ead78 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -34,7 +34,10 @@ describe('resolveReactRouterConfig', () => { it('preserves server bundle selection in SSR mode', async () => { const serverBundles = async () => 'bundle'; - const result = await resolveReactRouterConfig({ serverBundles }); + const result = await resolveReactRouterConfig({ + ssr: true, + serverBundles, + }); expect(result.resolved.serverBundles).toBe(serverBundles); }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index 6e690da..ca3a5d4 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -81,7 +81,7 @@ describe('removeExports', () => { expect(generate(ast).code).not.toContain('./theme.server'); }); - it('preserves export-all declarations that cannot be filtered safely', () => { + it('rejects unaliased export-all declarations when removing named exports', () => { const code = ` export * from './data.server'; export default function Route() { @@ -90,11 +90,10 @@ describe('removeExports', () => { `; const ast = parse(code, { sourceType: 'module' }); - removeExports(ast, ['loader']); - const result = generate(ast).code; - expect(result).toContain("export * from './data.server'"); - expect(result).toContain('Route'); + expect(() => removeExports(ast, ['loader'])).toThrowError( + 'Cannot remove named exports from `export *`; use explicit named re-exports.' + ); }); it('keeps lowercase JSX member imports after removing server exports', () => { diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts index 083c461..315f741 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { describe, expect, it, rstest } from '@rstest/core'; import { createRouteManifestSnapshot, @@ -112,7 +112,7 @@ 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' + resolve('/project/build/client', '.react-router/route-watch') ); }); @@ -205,6 +205,47 @@ describe('route watch restart marker', () => { } }); + it('advances topology before reporting synchronous notification failures', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + const markerPath = join(root, 'build/.react-router-route-watch'); + const watchedDirectory = join(root, 'app'); + mkdirSync(watchedDirectory, { recursive: true }); + let topology = new Set(['initial']); + let triggerChange!: () => void; + const onRouteTopologyChange = rstest.fn(() => { + throw new Error('topology notification failed'); + }); + const onError = rstest.fn(); + + try { + const close = await createRouteTopologyWatcher({ + watchDirectory: watchedDirectory, + restartMarkerPath: markerPath, + getRouteTopology: async () => topology, + onRouteTopologyChange, + onError, + watchDirectoryEntry: (_directory, onChange) => { + triggerChange = onChange; + return { close: () => {} }; + }, + }); + + topology = new Set(['changed']); + triggerChange(); + await expect + .poll(() => onError.mock.calls.length, { timeout: 2000 }) + .toBe(1); + + triggerChange(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(onRouteTopologyChange).toHaveBeenCalledTimes(1); + await close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('retains discovered recovery directories when startup topology evaluation fails', async () => { const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); const markerPath = join(root, 'build/.react-router-route-watch'); diff --git a/tests/setup.ts b/tests/setup.ts index 9698e5a..cc1f73f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -12,8 +12,9 @@ rstest.mock('jiti', () => ({ if (path.includes('routes.ts')) { const routeCount = Number(process.env.RR_TEST_ROUTE_COUNT ?? 0); if (routeCount > 0) { + const childRouteCount = Math.max(0, routeCount - 1); return Promise.resolve( - Array.from({ length: routeCount }, (_, index) => ({ + Array.from({ length: childRouteCount }, (_, index) => ({ id: `routes/route-${index}`, file: `routes/route-${index}.tsx`, index: index === 0, From e0da677959150b5dd4573f563786aae041a2b7be Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:08:19 +0000 Subject: [PATCH 45/64] refactor: centralize bounded concurrency helper --- src/concurrency.ts | 22 ++++++++++++++++++++++ src/manifest.ts | 24 ++---------------------- tests/parallel-route-transforms.test.ts | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/concurrency.ts b/src/concurrency.ts index a6cb619..0edbba2 100644 --- a/src/concurrency.ts +++ b/src/concurrency.ts @@ -10,3 +10,25 @@ const getAvailableCpuCount = (): number => export const getDefaultConcurrency = ( cpuCount: number = getAvailableCpuCount() ): number => Math.max(0, Math.floor(cpuCount) - DEFAULT_RESERVED_CORES); + +export const mapWithConcurrency = async ( + items: readonly Item[], + concurrency: number, + worker: (item: Item, index: number) => Promise +): Promise => { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await worker(items[index], index); + } + }) + ); + return results; +}; diff --git a/src/manifest.ts b/src/manifest.ts index c6309b3..02e68d7 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,34 +15,13 @@ import { type RouteChunkConfig, } from './route-chunks.js'; import { getRouteModuleAnalysis } from './export-utils.js'; -import { getDefaultConcurrency } from './concurrency.js'; +import { getDefaultConcurrency, mapWithConcurrency } from './concurrency.js'; const ROUTE_ANALYSIS_CONCURRENCY = Math.max( 1, Math.min(16, getDefaultConcurrency() || 1) ); -const mapWithConcurrency = async ( - items: readonly Item[], - worker: (item: Item, index: number) => Promise -): Promise => { - const results = new Array(items.length); - let nextIndex = 0; - const workerCount = Math.min(ROUTE_ANALYSIS_CONCURRENCY, items.length); - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (true) { - const index = nextIndex++; - if (index >= items.length) { - return; - } - results[index] = await worker(items[index], index); - } - }) - ); - return results; -}; - export function configRoutesToRouteManifest( appDirectory: string, routes: RouteConfigEntry[], @@ -291,6 +270,7 @@ export async function generateReactRouterManifestForDev( const manifestEntries = await mapWithConcurrency( Object.entries(routes), + ROUTE_ANALYSIS_CONCURRENCY, async ([key, route]) => { const routeEntryName = getRouteEntryName(route); const assets = getAssetsForChunk(routeEntryName); diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index 86bd683..7617790 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from '@rstest/core'; +import { mapWithConcurrency } from '../src/concurrency'; import { getExportNames } from '../src/export-utils'; import { executeRouteTransformTask, @@ -67,6 +68,21 @@ describe('parallel route transforms', () => { expect(getDefaultWorkerCount(cpus)).toBe(workers); }); + it('maps work with a concurrency cap while preserving result order', async () => { + let active = 0; + let maxActive = 0; + const result = await mapWithConcurrency([3, 1, 2], 2, async value => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => setTimeout(resolve, value)); + active -= 1; + return value * 2; + }); + + expect(result).toEqual([6, 2, 4]); + expect(maxActive).toBeLessThanOrEqual(2); + }); + it('rejects invalid explicit worker counts', () => { expect(() => createRouteTransformExecutor({ From ef5798ec79d0e7efa20c915c472811080ad8cefb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:55:05 +0200 Subject: [PATCH 46/64] Refactor React Router plugin follow-ups --- .changeset/lazy-entry-hydration.md | 5 + README.md | 5 +- examples/custom-node-server/package.json | 2 +- .../custom-node-server/playwright.config.ts | 3 +- examples/default-template/package.json | 3 +- examples/default-template/rsbuild.config.ts | 12 +- .../tests/e2e/lazy-compilation.test.ts | 68 ++++ scripts/compare-benchmarks.mjs | 7 +- src/bounded-cache.ts | 6 +- src/dev-runtime-compilation.ts | 92 +++++ src/dev-runtime-controller.ts | 254 ++---------- src/dev-runtime-session.ts | 184 +++++++++ src/export-utils.ts | 56 +-- src/index.ts | 383 ++++++------------ src/lazy-compilation.ts | 93 +++++ src/modify-browser-manifest.ts | 279 +++++++------ src/parallel-route-transforms.ts | 3 + src/performance.ts | 15 +- src/prerender.ts | 146 +++++++ src/route-ast.ts | 159 ++++++++ src/route-chunks.ts | 14 +- src/route-component-transform.ts | 174 +------- src/route-export-pruning.ts | 68 +--- src/route-export-resolution.ts | 285 +++++++++++++ src/route-transform-tasks.ts | 270 +----------- src/route-watch.ts | 3 + src/server-build-plan.ts | 91 +++++ src/server-utils.ts | 8 + tests/bounded-cache.test.ts | 14 + tests/client-modules.test.ts | 85 ++++ tests/dev-runtime-controller.test.ts | 21 +- tests/index.test.ts | 91 ++++- tests/lazy-compilation.test.ts | 102 +++++ tests/modify-browser-manifest.test.ts | 377 +++++++++++------ tests/performance.test.ts | 34 ++ tests/plugin-utils.test.ts | 23 ++ tests/prerender.test.ts | 202 +++++++++ tests/route-chunks.test.ts | 21 + tests/server-build-plan.test.ts | 123 ++++++ tests/server-utils.test.ts | 11 + 40 files changed, 2524 insertions(+), 1268 deletions(-) create mode 100644 .changeset/lazy-entry-hydration.md create mode 100644 examples/default-template/tests/e2e/lazy-compilation.test.ts create mode 100644 src/dev-runtime-compilation.ts create mode 100644 src/dev-runtime-session.ts create mode 100644 src/lazy-compilation.ts create mode 100644 src/route-ast.ts create mode 100644 src/route-export-resolution.ts create mode 100644 src/server-build-plan.ts create mode 100644 tests/lazy-compilation.test.ts create mode 100644 tests/server-build-plan.test.ts diff --git a/.changeset/lazy-entry-hydration.md b/.changeset/lazy-entry-hydration.md new file mode 100644 index 0000000..e833143 --- /dev/null +++ b/.changeset/lazy-entry-hydration.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Keep React Router hydration entries compatible with Rsbuild lazy compilation when `entries: true` is enabled. diff --git a/README.md b/README.md index a733acb..79854ac 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ pluginReactRouter({ /** * Rsbuild dev-only lazy compilation behavior. + * The plugin guards React Router hydration-critical modules so + * `lazyCompilation: { entries: true }` remains enabled without replacing + * manifest route modules with lazy entry proxies. * @default undefined */ lazyCompilation?: boolean | Rspack.LazyCompilationOptions, @@ -307,7 +310,7 @@ If no configuration is provided, the following defaults will be used: customServer: false, serverOutput: 'module', federation: false, - lazyCompilation: undefined, + lazyCompilation: undefined, // Rsbuild's dev defaults still apply logPerformance: false, parallelTransforms: undefined // adaptive: workers for 256+ resolved routes } diff --git a/examples/custom-node-server/package.json b/examples/custom-node-server/package.json index c395e14..7cc9bbc 100644 --- a/examples/custom-node-server/package.json +++ b/examples/custom-node-server/package.json @@ -10,7 +10,7 @@ "start": "NODE_ENV=production PORT=3003 node server.js", "build": "rsbuild build", "typecheck": "react-router typegen && tsc --noEmit", - "test:e2e": "playwright test && corepack pnpm run test:production", + "test:e2e": "RR_E2E_REUSE_EXISTING_SERVER=false playwright test && corepack pnpm run test:production", "test:production": "corepack pnpm run test:production:module && corepack pnpm run test:production:commonjs", "test:production:module": "RR_SERVER_OUTPUT=module corepack pnpm run build && node scripts/smoke-production.mjs", "test:production:commonjs": "RR_SERVER_OUTPUT=commonjs corepack pnpm run build && node scripts/smoke-production.mjs" diff --git a/examples/custom-node-server/playwright.config.ts b/examples/custom-node-server/playwright.config.ts index caf49c3..b8ad8c0 100644 --- a/examples/custom-node-server/playwright.config.ts +++ b/examples/custom-node-server/playwright.config.ts @@ -44,7 +44,8 @@ export default defineConfig({ webServer: { command: 'corepack pnpm run dev', url: 'http://localhost:3003', - reuseExistingServer: !process.env.CI, + reuseExistingServer: + !process.env.CI && process.env.RR_E2E_REUSE_EXISTING_SERVER !== 'false', timeout: 120000, }, }); diff --git a/examples/default-template/package.json b/examples/default-template/package.json index e83c36d..7d91fcc 100644 --- a/examples/default-template/package.json +++ b/examples/default-template/package.json @@ -8,7 +8,8 @@ "start:esm": "react-router-serve ./build/server/static/js/app.js", "start:cjs": "react-router-serve ./cjs-serve-patch.cjs", "typecheck": "react-router typegen && tsc", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:lazy": "cross-env RR_LAZY_COMPILATION=entries playwright test tests/e2e/lazy-compilation.test.ts" }, "dependencies": { "@react-router/express": "^7.13.0", diff --git a/examples/default-template/rsbuild.config.ts b/examples/default-template/rsbuild.config.ts index 3f25416..71f3c70 100644 --- a/examples/default-template/rsbuild.config.ts +++ b/examples/default-template/rsbuild.config.ts @@ -13,7 +13,17 @@ declare module 'react-router' { } export default defineConfig(() => { + const lazyCompilation = + process.env.RR_LAZY_COMPILATION === 'entries' + ? { entries: true } + : undefined; + return { - plugins: [pluginReactRouter(), pluginReact(), pluginLess(), pluginSass()], + plugins: [ + pluginReactRouter({ lazyCompilation }), + pluginReact(), + pluginLess(), + pluginSass(), + ], }; }); diff --git a/examples/default-template/tests/e2e/lazy-compilation.test.ts b/examples/default-template/tests/e2e/lazy-compilation.test.ts new file mode 100644 index 0000000..b4dca75 --- /dev/null +++ b/examples/default-template/tests/e2e/lazy-compilation.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; + +test.describe('lazy compilation', () => { + test('hydrates with entries:true while manifest route modules stay synchronous', async ({ + page, + }) => { + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()); + } + }); + page.on('pageerror', (error) => { + errors.push(error.message); + }); + + await page.goto('/'); + + await page.waitForFunction(() => { + return (window as any).__reactRouterRouteModules !== undefined; + }); + + const initialRouteModules = await page.evaluate(() => { + const modules = (window as any).__reactRouterRouteModules ?? {}; + return Object.fromEntries( + Object.entries(modules).map(([routeId, moduleValue]) => [ + routeId, + Object.keys(moduleValue as Record).sort(), + ]) + ); + }); + expect(initialRouteModules.root).toContain('default'); + expect(initialRouteModules['routes/home']).toContain('default'); + + const manifestRouteModules = await page.evaluate(() => { + const manifest = (window as any).__reactRouterManifest; + return Object.fromEntries( + Object.entries(manifest.routes).map(([routeId, route]) => [ + routeId, + (route as { module: string }).module, + ]) + ); + }); + const rootRouteAsset = await page.request.get(manifestRouteModules.root); + expect(await rootRouteAsset.text()).not.toContain( + 'lazy-compilation-proxy' + ); + + const documentRequests: string[] = []; + page.on('request', (request) => { + if ( + request.isNavigationRequest() && + request.frame() === page.mainFrame() + ) { + documentRequests.push(request.url()); + } + }); + + await page.locator('a[href="/about"]').first().click(); + + await expect(page).toHaveURL('/about'); + await expect( + page.locator('h1:has-text("About This Demo")') + ).toBeVisible(); + expect(documentRequests).toEqual([]); + expect(errors.join('\n')).not.toMatch(/hydration|Hydration|Component/); + }); +}); diff --git a/scripts/compare-benchmarks.mjs b/scripts/compare-benchmarks.mjs index 31192ac..c7da2aa 100644 --- a/scripts/compare-benchmarks.mjs +++ b/scripts/compare-benchmarks.mjs @@ -25,7 +25,12 @@ if (!values.before || !values.after) { 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 operations = new Set( + values.operations + .split(',') + .map(value => value.trim()) + .filter(Boolean) +); const findBenchmark = (result, id) => { const benchmark = result.benchmarks?.find(item => item.id === id); diff --git a/src/bounded-cache.ts b/src/bounded-cache.ts index 8c727d9..339d8cf 100644 --- a/src/bounded-cache.ts +++ b/src/bounded-cache.ts @@ -9,9 +9,9 @@ export const setBoundedCacheEntry = ( return; } if (!cache.has(key) && cache.size >= maxEntries) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); + const oldestEntry = cache.keys().next(); + if (!oldestEntry.done) { + cache.delete(oldestEntry.value); } } cache.set(key, value); diff --git a/src/dev-runtime-compilation.ts b/src/dev-runtime-compilation.ts new file mode 100644 index 0000000..d835aa4 --- /dev/null +++ b/src/dev-runtime-compilation.ts @@ -0,0 +1,92 @@ +import type { Rspack } from '@rsbuild/core'; +import type { + DevCompilationIdentity, + DevGraphChanges, + DevGraphIdentity, +} from './dev-runtime-artifacts.js'; + +export type DevCompilerPair = { + web: Rspack.Compiler; + node: Rspack.Compiler; + settledCompilations: WeakSet; + pendingAttempt?: PendingDevCompilation; + latestCompletedWebIdentity?: DevCompilationIdentity; + latestWebStart?: CompilationStart; + latestNodeStart?: CompilationStart; +}; + +export type PendingDevCompilation = { + stats: Rspack.Stats | Rspack.MultiStats; + changes: DevGraphChanges; + identity: DevGraphIdentity; + webCompilation: Rspack.Compilation; + nodeCompilation: Rspack.Compilation; +}; + +export type CompilationStart = + | { status: 'pending' } + | { status: 'started'; identity: DevCompilationIdentity }; + +export const isLatestStartedCompilation = ( + identity: DevCompilationIdentity | undefined, + start: CompilationStart | undefined +): boolean => + !identity || (start?.status === 'started' && start.identity === identity); + +export const hasPendingCompilation = (pair: DevCompilerPair): boolean => + pair.latestWebStart?.status === 'pending' || + pair.latestNodeStart?.status === 'pending'; + +export type CompilationIdentityTracker = { + getCompilationIdentity( + compilation: Rspack.Compilation + ): DevCompilationIdentity; + getWebIdentityForNodeCompilation( + compilation: Rspack.Compilation + ): DevCompilationIdentity | undefined; + setWebIdentityForNodeCompilation( + compilation: Rspack.Compilation, + identity: DevCompilationIdentity + ): void; +}; + +export const createCompilationIdentityTracker = + (): CompilationIdentityTracker => { + const identityByCompilation = new WeakMap< + Rspack.Compilation, + DevCompilationIdentity + >(); + const webIdentityByNodeCompilation = new WeakMap< + Rspack.Compilation, + DevCompilationIdentity + >(); + + return { + getCompilationIdentity( + compilation: Rspack.Compilation + ): DevCompilationIdentity { + const existing = identityByCompilation.get(compilation); + if (existing) { + return existing; + } + const identity = Symbol(); + // Keep compact lineage tokens in committed state without retaining entire + // Rspack compilation graphs across failed rebuilds. + identityByCompilation.set(compilation, identity); + return identity; + }, + + getWebIdentityForNodeCompilation( + compilation: Rspack.Compilation + ): DevCompilationIdentity | undefined { + return webIdentityByNodeCompilation.get(compilation); + }, + + setWebIdentityForNodeCompilation( + compilation: Rspack.Compilation, + identity: DevCompilationIdentity + ): void { + webIdentityByNodeCompilation.set(compilation, identity); + }, + }; + }; diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 34feef4..27cd284 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -1,76 +1,34 @@ -import type { - RsbuildConfig, - RsbuildPluginAPI, - RsbuildDevServer, - Rspack, -} from '@rsbuild/core'; +import type { RsbuildConfig, RsbuildPluginAPI, Rspack } from '@rsbuild/core'; import type { ServerBuild } from 'react-router'; import { PLUGIN_NAME } from './constants.js'; +import { + createCompilationIdentityTracker, + hasPendingCompilation, + isLatestStartedCompilation, + type DevCompilerPair, +} from './dev-runtime-compilation.js'; import { createReactRouterDevRuntime, loadReactRouterServerBuild, registerReactRouterDevRuntime, unregisterReactRouterDevRuntime, - type ReactRouterDevRuntime, } from './dev-generation.js'; import { getEnvironmentStats, snapshotDevChangedFiles, - type DevCompilationIdentity, - type DevGraphChanges, - type DevGraphIdentity, type ReactRouterDevBuildPlan, type ReactRouterDevManifestSet, } from './dev-runtime-artifacts.js'; - -type DevCompilerPair = { - web: Rspack.Compiler; - node: Rspack.Compiler; - settledCompilations: WeakSet; - pendingAttempt?: PendingDevCompilation; - latestCompletedWebIdentity?: DevCompilationIdentity; - latestWebStart?: CompilationStart; - latestNodeStart?: CompilationStart; -}; - -type PendingDevCompilation = { - stats: Rspack.Stats | Rspack.MultiStats; - changes: DevGraphChanges; - identity: DevGraphIdentity; - webCompilation: Rspack.Compilation; - nodeCompilation: Rspack.Compilation; -}; - -type CompilationStart = - | { status: 'pending' } - | { status: 'started'; identity: DevCompilationIdentity }; - -type RuntimeBinding = { - id: number; - server: RsbuildDevServer; - runtime: ReactRouterDevRuntime; - compilers?: DevCompilerPair; -}; - -type CloseOutcome = { ok: true } | { ok: false; cause: unknown }; - -type CloseObservation = { - binding?: RuntimeBinding; - promise?: Promise; - outcome?: CloseOutcome; -}; +import { + createDevRuntimeSessionManager, + type RuntimeBinding, +} from './dev-runtime-session.js'; type ServerSetup = Exclude< NonNullable['setup']>, unknown[] >; -type ControllerState = - | { status: 'idle' } - | { status: 'active'; binding: RuntimeBinding } - | { status: 'closing'; binding: RuntimeBinding } - | { status: 'terminal'; error: Error }; - export type ReactRouterDevRuntimeController = { captureWeb: ( compilation: Rspack.Compilation, @@ -91,16 +49,6 @@ const escapeHtml = (value: string): string => .replaceAll('<', '<') .replaceAll('>', '>'); -const isLatestStartedCompilation = ( - identity: DevCompilationIdentity | undefined, - start: CompilationStart | undefined -): boolean => - !identity || (start?.status === 'started' && start.identity === identity); - -const hasPendingCompilation = (pair: DevCompilerPair): boolean => - pair.latestWebStart?.status === 'pending' || - pair.latestNodeStart?.status === 'pending'; - export const createReactRouterDevRuntimeController = ({ api, isBuild, @@ -120,42 +68,6 @@ export const createReactRouterDevRuntimeController = ({ }; } - let state: ControllerState = { status: 'idle' }; - let nextSessionId = 1; - const identityByCompilation = new WeakMap< - Rspack.Compilation, - DevCompilationIdentity - >(); - const webIdentityByNodeCompilation = new WeakMap< - Rspack.Compilation, - DevCompilationIdentity - >(); - const closeObservationByServer = new WeakMap< - RsbuildDevServer, - CloseObservation - >(); - - const getCompilationIdentity = ( - compilation: Rspack.Compilation - ): DevCompilationIdentity => { - const existing = identityByCompilation.get(compilation); - if (existing) { - return existing; - } - const identity = Symbol(); - // Keep compact lineage tokens in committed state without retaining entire - // Rspack compilation graphs across failed rebuilds. - identityByCompilation.set(compilation, identity); - return identity; - }; - - const getActiveBinding = (): RuntimeBinding | undefined => - state.status === 'active' ? state.binding : undefined; - - const isCurrentBinding = (binding: RuntimeBinding): boolean => - (state.status === 'active' || state.status === 'closing') && - state.binding === binding; - const closeBinding = (binding: RuntimeBinding, error?: Error): void => { const pair = binding.compilers; if (pair) { @@ -169,80 +81,9 @@ export const createReactRouterDevRuntimeController = ({ unregisterReactRouterDevRuntime(binding.server, binding.runtime); }; - const completeClose = (binding: RuntimeBinding): void => { - if (!isCurrentBinding(binding)) { - return; - } - if (state.status === 'active') { - closeBinding(binding); - } - state = { status: 'idle' }; - }; - - const failClose = (binding: RuntimeBinding, cause: unknown): void => { - if (!isCurrentBinding(binding)) { - return; - } - const error = new Error( - `[${PLUGIN_NAME}] The previous development server failed to close. Restart the process before retrying because Rsbuild may not have finished tearing down its compiler and watchers.`, - { cause } - ); - closeBinding(binding, error); - state = { status: 'terminal', error }; - }; - - const applyCloseOutcome = ( - observation: CloseObservation, - outcome: CloseOutcome - ): void => { - observation.outcome = outcome; - const { binding } = observation; - if (!binding) { - return; - } - if (outcome.ok) { - completeClose(binding); - } else { - failClose(binding, outcome.cause); - } - observation.binding = undefined; - }; - - const observeClose = (server: RsbuildDevServer): CloseObservation => { - const existing = closeObservationByServer.get(server); - if (existing) { - return existing; - } - const observation: CloseObservation = {}; - const close = server.close.bind(server); - server.close = () => { - if (observation.promise) { - return observation.promise; - } - let closePromise: Promise; - try { - closePromise = close(); - } catch (cause) { - closePromise = Promise.reject(cause); - } - observation.promise = closePromise; - void closePromise.then( - () => applyCloseOutcome(observation, { ok: true }), - cause => applyCloseOutcome(observation, { ok: false, cause }) - ); - return closePromise; - }; - closeObservationByServer.set(server, observation); - return observation; - }; - - const bindCloseObservation = (binding: RuntimeBinding): void => { - const observation = observeClose(binding.server); - observation.binding = binding; - if (observation.outcome) { - applyCloseOutcome(observation, observation.outcome); - } - }; + const sessions = createDevRuntimeSessionManager(closeBinding); + const compilationIdentities = createCompilationIdentityTracker(); + const { getCompilationIdentity } = compilationIdentities; const flushSettledAttempt = ( binding: RuntimeBinding, @@ -251,7 +92,7 @@ export const createReactRouterDevRuntimeController = ({ const pending = pair.pendingAttempt; if ( !pending || - getActiveBinding()?.id !== binding.id || + sessions.getActiveBinding()?.id !== binding.id || !pair.settledCompilations.has(pending.webCompilation) || !pair.settledCompilations.has(pending.nodeCompilation) ) { @@ -267,7 +108,7 @@ export const createReactRouterDevRuntimeController = ({ void binding.runtime .finishAttempt(pending.stats, pending.changes, pending.identity) .catch(cause => { - if (getActiveBinding()?.id === binding.id) { + if (sessions.getActiveBinding()?.id === binding.id) { binding.runtime.failAttempt( cause instanceof Error ? cause : new Error(String(cause)) ); @@ -278,13 +119,12 @@ export const createReactRouterDevRuntimeController = ({ const rejectUnsupportedCompiler = (reason: string): void => { const message = `[${PLUGIN_NAME}] Could not coordinate React Router development output because ${reason}.`; api.logger.warn(message); - const binding = getActiveBinding(); + const binding = sessions.getActiveBinding(); if (!binding) { return; } const error = new Error(message); - closeBinding(binding, error); - state = { status: 'terminal', error }; + sessions.terminate(binding, error); }; // Rsbuild runs server.setup before onBeforeStartDevServer. Prepending the @@ -300,7 +140,7 @@ export const createReactRouterDevRuntimeController = ({ : []; const observeServer: ServerSetup = context => { if (context.action === 'dev') { - observeClose(context.server); + sessions.observeClose(context.server); } }; return { @@ -316,24 +156,12 @@ export const createReactRouterDevRuntimeController = ({ api.onBeforeStartDevServer({ order: 'pre', async handler({ server }) { - if (state.status === 'terminal') { - throw state.error; - } - if (state.status === 'active') { - throw new Error( - `[${PLUGIN_NAME}] A development server is already active. Await its close() before calling createDevServer() again. If startup failed before returning the server, restart the process before retrying.` - ); - } - if (state.status === 'closing') { - throw new Error( - `[${PLUGIN_NAME}] The previous development server is still closing. Await its close() before calling createDevServer() again.` - ); - } + sessions.assertCanStart(); const runtime = createReactRouterDevRuntime({ server, buildPlan, onEvaluationError(error) { - if (getActiveBinding()?.runtime !== runtime) { + if (sessions.getActiveBinding()?.runtime !== runtime) { return; } api.logger.error(error.message); @@ -344,29 +172,28 @@ export const createReactRouterDevRuntimeController = ({ }, onWarning: message => api.logger.warn(message), }); - const binding = { id: nextSessionId++, server, runtime }; - state = { status: 'active', binding }; + const binding = sessions.createBinding(server, runtime); registerReactRouterDevRuntime(server, runtime); - bindCloseObservation(binding); + sessions.bindCloseObservation(binding); }, }); api.onCloseDevServer({ order: 'pre', handler() { - if (state.status !== 'active') { + const binding = sessions.getActiveBinding(); + if (!binding) { return; } - const binding = state.binding; closeBinding(binding); - state = { status: 'closing', binding }; + sessions.markClosing(binding); }, }); api.onBeforeDevCompile({ order: 'pre', handler() { - const binding = getActiveBinding(); + const binding = sessions.getActiveBinding(); const pair = binding?.compilers; if (!binding || !pair || hasPendingCompilation(pair)) { return; @@ -387,7 +214,7 @@ export const createReactRouterDevRuntimeController = ({ rejectUnsupportedCompiler('the web or node compiler was missing'); return; } - const binding = getActiveBinding(); + const binding = sessions.getActiveBinding(); if (!binding) { return; } @@ -400,7 +227,7 @@ export const createReactRouterDevRuntimeController = ({ const sessionId = binding.id; const runtime = binding.runtime; const failCurrentAttempt = (side: 'web' | 'node', error: Error): void => { - if (getActiveBinding()?.id === sessionId) { + if (sessions.getActiveBinding()?.id === sessionId) { if (side === 'web') { pair.latestWebStart = undefined; } else { @@ -414,7 +241,7 @@ export const createReactRouterDevRuntimeController = ({ side: 'latestWebStart' | 'latestNodeStart' ): void => { if ( - getActiveBinding()?.id === sessionId && + sessions.getActiveBinding()?.id === sessionId && pair[side]?.status !== 'pending' ) { const attemptAlreadyPending = hasPendingCompilation(pair); @@ -436,7 +263,7 @@ export const createReactRouterDevRuntimeController = ({ web.hooks.done.tap( { name: `${PLUGIN_NAME}:dev-web-complete`, stage: -1000 }, stats => { - if (getActiveBinding()?.id !== sessionId) { + if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.latestCompletedWebIdentity = getCompilationIdentity( @@ -447,7 +274,7 @@ export const createReactRouterDevRuntimeController = ({ web.hooks.thisCompilation.tap( `${PLUGIN_NAME}:dev-web-compilation`, compilation => { - if (getActiveBinding()?.id === sessionId) { + if (sessions.getActiveBinding()?.id === sessionId) { pair.latestWebStart = { status: 'started', identity: getCompilationIdentity(compilation), @@ -458,7 +285,7 @@ export const createReactRouterDevRuntimeController = ({ node.hooks.thisCompilation.tap( `${PLUGIN_NAME}:dev-node-web-compilation`, compilation => { - if (getActiveBinding()?.id !== sessionId) { + if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.latestNodeStart = { @@ -466,7 +293,7 @@ export const createReactRouterDevRuntimeController = ({ identity: getCompilationIdentity(compilation), }; if (pair.latestCompletedWebIdentity) { - webIdentityByNodeCompilation.set( + compilationIdentities.setWebIdentityForNodeCompilation( compilation, pair.latestCompletedWebIdentity ); @@ -474,7 +301,7 @@ export const createReactRouterDevRuntimeController = ({ } ); const settleCompilation = (stats: Rspack.Stats): void => { - if (getActiveBinding()?.id !== sessionId) { + if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.settledCompilations.add(stats.compilation); @@ -497,7 +324,7 @@ export const createReactRouterDevRuntimeController = ({ }); api.onAfterDevCompile(async ({ stats }) => { - const binding = getActiveBinding(); + const binding = sessions.getActiveBinding(); const pair = binding?.compilers; if (!binding || !pair) { return; @@ -530,7 +357,9 @@ export const createReactRouterDevRuntimeController = ({ web: webIdentity, node: nodeIdentity, nodeWeb: nodeStats - ? webIdentityByNodeCompilation.get(nodeStats.compilation) + ? compilationIdentities.getWebIdentityForNodeCompilation( + nodeStats.compilation + ) : undefined, }; if (!webStats || !nodeStats) { @@ -549,17 +378,18 @@ export const createReactRouterDevRuntimeController = ({ return { captureWeb(compilation, manifestsByEntryName): void { - const binding = getActiveBinding(); + const binding = sessions.getActiveBinding(); if (binding?.compilers?.web === compilation.compiler) { binding.runtime.captureWeb(compilation, manifestsByEntryName); } }, createBuildLoader(entryName?: string): () => Promise { - const server = getActiveBinding()?.server; + const server = sessions.getActiveBinding()?.server; if (server) { return () => loadReactRouterServerBuild(server, entryName); } + const state = sessions.getState(); if (state.status === 'terminal') { const { error } = state; return () => Promise.reject(error); diff --git a/src/dev-runtime-session.ts b/src/dev-runtime-session.ts new file mode 100644 index 0000000..047a3c9 --- /dev/null +++ b/src/dev-runtime-session.ts @@ -0,0 +1,184 @@ +import type { RsbuildDevServer } from '@rsbuild/core'; +import { PLUGIN_NAME } from './constants.js'; +import type { ReactRouterDevRuntime } from './dev-generation.js'; +import type { DevCompilerPair } from './dev-runtime-compilation.js'; + +export type RuntimeBinding = { + id: number; + server: RsbuildDevServer; + runtime: ReactRouterDevRuntime; + compilers?: DevCompilerPair; +}; + +type CloseOutcome = { ok: true } | { ok: false; cause: unknown }; + +type CloseObservation = { + binding?: RuntimeBinding; + promise?: Promise; + outcome?: CloseOutcome; +}; + +export type ControllerState = + | { status: 'idle' } + | { status: 'active'; binding: RuntimeBinding } + | { status: 'closing'; binding: RuntimeBinding } + | { status: 'terminal'; error: Error }; + +type CloseBinding = (binding: RuntimeBinding, error?: Error) => void; + +export type DevRuntimeSessionManager = { + getState(): ControllerState; + getActiveBinding(): RuntimeBinding | undefined; + observeClose(server: RsbuildDevServer): void; + assertCanStart(): void; + createBinding( + server: RsbuildDevServer, + runtime: ReactRouterDevRuntime + ): RuntimeBinding; + bindCloseObservation(binding: RuntimeBinding): void; + markClosing(binding: RuntimeBinding): void; + terminate(binding: RuntimeBinding, error: Error): void; +}; + +export const createDevRuntimeSessionManager = ( + closeBinding: CloseBinding +): DevRuntimeSessionManager => { + let state: ControllerState = { status: 'idle' }; + let nextSessionId = 1; + const closeObservationByServer = new WeakMap< + RsbuildDevServer, + CloseObservation + >(); + + const getState = (): ControllerState => state; + + const getActiveBinding = (): RuntimeBinding | undefined => + state.status === 'active' ? state.binding : undefined; + + const isCurrentBinding = (binding: RuntimeBinding): boolean => + (state.status === 'active' || state.status === 'closing') && + state.binding === binding; + + const completeClose = (binding: RuntimeBinding): void => { + if (!isCurrentBinding(binding)) { + return; + } + if (state.status === 'active') { + closeBinding(binding); + } + state = { status: 'idle' }; + }; + + const failClose = (binding: RuntimeBinding, cause: unknown): void => { + if (!isCurrentBinding(binding)) { + return; + } + const error = new Error( + `[${PLUGIN_NAME}] The previous development server failed to close. Restart the process before retrying because Rsbuild may not have finished tearing down its compiler and watchers.`, + { cause } + ); + closeBinding(binding, error); + state = { status: 'terminal', error }; + }; + + const applyCloseOutcome = ( + observation: CloseObservation, + outcome: CloseOutcome + ): void => { + observation.outcome = outcome; + const { binding } = observation; + if (!binding) { + return; + } + if (outcome.ok) { + completeClose(binding); + } else { + failClose(binding, outcome.cause); + } + observation.binding = undefined; + }; + + const observeClose = (server: RsbuildDevServer): CloseObservation => { + const existing = closeObservationByServer.get(server); + if (existing) { + return existing; + } + const observation: CloseObservation = {}; + const close = server.close.bind(server); + server.close = () => { + if (observation.promise) { + return observation.promise; + } + let closePromise: Promise; + try { + closePromise = close(); + } catch (cause) { + closePromise = Promise.reject(cause); + } + observation.promise = closePromise; + void closePromise.then( + () => applyCloseOutcome(observation, { ok: true }), + cause => applyCloseOutcome(observation, { ok: false, cause }) + ); + return closePromise; + }; + closeObservationByServer.set(server, observation); + return observation; + }; + + return { + getState, + getActiveBinding, + + observeClose(server: RsbuildDevServer): void { + observeClose(server); + }, + + assertCanStart(): void { + if (state.status === 'terminal') { + throw state.error; + } + if (state.status === 'active') { + throw new Error( + `[${PLUGIN_NAME}] A development server is already active. Await its close() before calling createDevServer() again. If startup failed before returning the server, restart the process before retrying.` + ); + } + if (state.status === 'closing') { + throw new Error( + `[${PLUGIN_NAME}] The previous development server is still closing. Await its close() before calling createDevServer() again.` + ); + } + }, + + createBinding( + server: RsbuildDevServer, + runtime: ReactRouterDevRuntime + ): RuntimeBinding { + const binding = { id: nextSessionId++, server, runtime }; + state = { status: 'active', binding }; + return binding; + }, + + bindCloseObservation(binding: RuntimeBinding): void { + const observation = observeClose(binding.server); + observation.binding = binding; + if (observation.outcome) { + applyCloseOutcome(observation, observation.outcome); + } + }, + + markClosing(binding: RuntimeBinding): void { + if (isCurrentBinding(binding)) { + state = { status: 'closing', binding }; + } + }, + + terminate(binding: RuntimeBinding, error: Error): void { + if (!isCurrentBinding(binding)) { + return; + } + closeBinding(binding, error); + state = { status: 'terminal', error }; + }, + }; +}; diff --git a/src/export-utils.ts b/src/export-utils.ts index 403eb0f..667a5e9 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,6 +1,11 @@ import { readFile, stat } from 'node:fs/promises'; import { langFromPath, parse } from 'yuku-parser'; import { setBoundedCacheEntry } from './bounded-cache.js'; +import { + getExportedName, + getIdentifierNamesFromPattern, + type AnyNode, +} from './route-ast.js'; type ExportInfo = { readonly exportNames: readonly string[]; @@ -27,8 +32,6 @@ const routeModuleAnalysisCache = new Map< const MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048; -type AnyNode = Record; - const cachePromiseOnReject = ( promise: Promise, invalidate: () => void @@ -52,57 +55,10 @@ const parseProgram = (code: string, resourcePath?: string) => { return result.program; }; -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' || + node.declaration?.declare === true || (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'TSInterfaceDeclaration'); diff --git a/src/index.ts b/src/index.ts index 00d66db..2f1b8d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { JS_EXTENSIONS, PLUGIN_NAME, } from './constants.js'; +import { guardReactRouterLazyCompilation } from './lazy-compilation.js'; import { createDevServerMiddleware } from './dev-server.js'; import { generateWithProps, @@ -33,9 +34,13 @@ import { resolveServerBuildModule, } from './server-utils.js'; import { + createPrerenderRoutes, getPrerenderConcurrency, + getSsrFalsePrerenderExportErrors, + normalizePrerenderMatchPath, resolvePrerenderPaths, validatePrerenderConfig, + withBuildRequest, } from './prerender.js'; import { resolveReactRouterConfig, @@ -50,7 +55,7 @@ import { type ReactRouterManifestStats, type RouteManifestModuleExports, } from './manifest.js'; -import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; +import { registerModifyBrowserManifestAssets } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { getRouteChunkEntryName, @@ -76,6 +81,10 @@ import { getBuildManifest, getRoutesByServerBundleId, } from './build-manifest.js'; +import { + createReactRouterNodeEntries, + createReactRouterServerBuildPlan, +} from './server-build-plan.js'; import { isSourceMapEnabled, warnOnClientSourceMaps, @@ -545,6 +554,55 @@ export const pluginReactRouter = ( const latestServerManifestsByBundleId: Record = {}; + const stageLatestManifests = ( + manifest: ReactRouterManifest, + sri: Record | undefined, + moduleExportsByRouteId: RouteManifestModuleExports, + compilation: Rspack.Compilation + ) => { + performanceProfiler.recordSync( + 'web', + 'manifest:stage', + 'virtual/react-router/browser-manifest', + () => { + latestBrowserManifest = manifest; + latestBrowserManifestModuleExports = moduleExportsByRouteId; + const baseServerManifest = { + ...manifest, + sri, + }; + latestServerManifest = baseServerManifest; + const manifestsByEntryName: Record = { + [devServerBuildEntryName]: baseServerManifest, + }; + + for (const { bundleId, entryName } of serverBundleEntries) { + const bundleRoutes = routesByServerBundleId[bundleId]; + if (!bundleRoutes) { + continue; + } + + const routeIds = new Set(Object.keys(bundleRoutes)); + const filteredRoutes = Object.fromEntries( + Object.entries(manifest.routes).filter(([routeId]) => + routeIds.has(routeId) + ) + ); + const bundleManifest = { + ...baseServerManifest, + routes: filteredRoutes, + }; + latestServerManifestsByBundleId[bundleId] = bundleManifest; + manifestsByEntryName[entryName] = bundleManifest; + } + + if (!isBuild) { + devRuntime.captureWeb(compilation, manifestsByEntryName); + } + } + ); + }; + const routeByFilePath = new Map( Object.values(routes).map(route => [ resolve(appDirectory, route.file), @@ -591,42 +649,16 @@ export const pluginReactRouter = ( buildManifest, routes ); - const serverBuildFileBase = (serverBuildFile || 'index.js').replace( - /\.js$/, - '' - ); - const serverBundleEntries = Object.entries(routesByServerBundleId) - .filter(([, bundleRoutes]) => - Boolean(bundleRoutes && Object.keys(bundleRoutes).length > 0) - ) - .map(([bundleId]) => ({ - bundleId, - entryName: `${bundleId}/${serverBuildFileBase}`, - })); - const reservedNodeEntryNames = new Set([ - 'static/js/app', - 'static/js/entry.server', - devServerBuildEntryName, - ]); - for (const { entryName } of serverBundleEntries) { - if (reservedNodeEntryNames.has(entryName)) { - throw new Error( - `[${PLUGIN_NAME}] Server bundle entry ${JSON.stringify(entryName)} conflicts with a reserved node entry.` - ); - } - reservedNodeEntryNames.add(entryName); - } - const devBuildPlan = { + const serverBuildPlan = createReactRouterServerBuildPlan({ + routesByServerBundleId, + serverBuildFile, defaultEntryName: devServerBuildEntryName, - entryNames: [ - devServerBuildEntryName, - ...serverBundleEntries.map(({ entryName }) => entryName), - ], - }; + }); + const { serverBundleEntries } = serverBuildPlan; const devRuntime = createReactRouterDevRuntimeController({ api, isBuild, - buildPlan: devBuildPlan, + buildPlan: serverBuildPlan, }); let clientStats: ReactRouterManifestStats | undefined; @@ -662,60 +694,6 @@ export const pluginReactRouter = ( warn: message => api.logger.warn(message), } ); - const groupRoutesByParentId = (manifest: Record) => { - const grouped: Record = {}; - Object.values(manifest).forEach(route => { - if (!route) return; - const parentId = route.parentId || ''; - if (!grouped[parentId]) { - grouped[parentId] = []; - } - grouped[parentId].push(route); - }); - return grouped; - }; - - type MatchRouteObject = - Parameters[0] extends Array ? R : never; - - const createPrerenderRoutes = ( - manifest: Record, - parentId = '', - grouped = groupRoutesByParentId(manifest) - ): MatchRouteObject[] => { - return (grouped[parentId] || []).map(route => { - const common = { id: route.id, path: route.path }; - if (route.index) { - return { index: true, ...common } as MatchRouteObject; - } - return { - ...common, - children: createPrerenderRoutes(manifest, route.id, grouped), - } as MatchRouteObject; - }); - }; - - 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, @@ -927,78 +905,19 @@ export const pluginReactRouter = ( ); }; - const validateSsrFalsePrerenderExports = async ( - manifest: Awaited>, + const assertValidSsrFalsePrerenderExports = ( + manifestRoutes: Awaited< + ReturnType + >['routes'], routeExports: RouteManifestModuleExports, prerenderList: string[] ) => { - if (prerenderList.length === 0) { - return; - } - - const prerenderRoutes = createPrerenderRoutes(routes); - const prerenderedRoutes = new Set(); - for (const path of prerenderList) { - const matches = matchRoutes( - prerenderRoutes, - normalizePrerenderMatchPath(path) - ); - if (!matches) { - throw new Error( - `Unable to prerender path because it does not match any routes: ${path}` - ); - } - matches.forEach(match => - prerenderedRoutes.add(match.route.id as string) - ); - } - - const errors: string[] = []; - for (const [routeId, route] of Object.entries(manifest.routes)) { - const exports = routeExports[routeId] ?? []; - const invalidApis: string[] = []; - - if (exports.includes('headers')) invalidApis.push('headers'); - if (exports.includes('action')) invalidApis.push('action'); - - if (invalidApis.length > 0) { - errors.push( - `Prerender: ${invalidApis.length} invalid route export(s) in ` + - `\`${routeId}\` when pre-rendering with \`ssr:false\`: ` + - `${invalidApis.map(api => `\`${api}\``).join(', ')}. ` + - `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` - ); - } - - if (!prerenderedRoutes.has(routeId)) { - if (exports.includes('loader')) { - errors.push( - `Prerender: 1 invalid route export in \`${routeId}\` when pre-rendering with ` + - `\`ssr:false\`: \`loader\`. ` + - `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` - ); - } - - let parentRoute = - route.parentId && manifest.routes[route.parentId] - ? manifest.routes[route.parentId] - : null; - while (parentRoute && parentRoute.id !== 'root') { - if (parentRoute.hasLoader && !parentRoute.hasClientLoader) { - errors.push( - `Prerender: 1 invalid route export in \`${parentRoute.id}\` when ` + - `pre-rendering with \`ssr:false\`: \`loader\`. ` + - `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` - ); - } - parentRoute = - parentRoute.parentId && parentRoute.parentId !== 'root' - ? manifest.routes[parentRoute.parentId] - : null; - } - } - } - + const errors = getSsrFalsePrerenderExportErrors({ + routes, + manifestRoutes, + routeExports, + prerenderPaths: prerenderList, + }); if (errors.length > 0) { api.logger.error(errors.join('\n')); throw new Error( @@ -1073,8 +992,8 @@ export const pluginReactRouter = ( assetPrefix, routeChunkOptions ); - await validateSsrFalsePrerenderExports( - generated.manifest, + assertValidSsrFalsePrerenderExports( + generated.manifest.routes, generated.moduleExportsByRouteId, prerenderPaths ); @@ -1275,26 +1194,27 @@ export const pluginReactRouter = ( } else if (useAsyncNodeChunkLoading) { nodeChunkLoading = 'async-node'; } - const nodeEntries: Record = { - 'static/js/app': hasServerApp - ? serverAppPath - : 'virtual/react-router/server-build', - ...(hasServerApp && !isBuild - ? { - [devServerBuildEntryName]: 'virtual/react-router/server-build', - } - : {}), - 'static/js/entry.server': finalEntryServerPath, - }; - for (const { bundleId, entryName } of serverBundleEntries) { - nodeEntries[entryName] = - `virtual/react-router/server-build-${bundleId}`; - } + const nodeEntries = createReactRouterNodeEntries({ + hasServerApp, + isBuild, + serverAppPath, + entryServerPath: finalEntryServerPath, + defaultEntryName: devServerBuildEntryName, + serverBundleEntries, + }); - const lazyCompilation = + const configuredLazyCompilation = pluginOptions.lazyCompilation === undefined + ? config.dev?.lazyCompilation + : pluginOptions.lazyCompilation; + const guardedLazyCompilation = guardReactRouterLazyCompilation({ + lazyCompilation: configuredLazyCompilation, + entryClientPath: finalEntryClientPath, + }); + const lazyCompilation = + guardedLazyCompilation === undefined ? {} - : { lazyCompilation: pluginOptions.lazyCompilation }; + : { lazyCompilation: guardedLazyCompilation }; const shouldCompactFileSizeReport = isBuild && routeCount >= 256 && @@ -1460,77 +1380,6 @@ export const pluginReactRouter = ( } } - if (name === 'web' && rspackConfig.plugins) { - rspackConfig.plugins.push( - createModifyBrowserManifestPlugin( - routes, - pluginOptions, - appDirectory, - assetPrefix, - routeChunkOptions, - { - future, - manifestChunkNames, - onManifest: ( - manifest, - sri, - moduleExportsByRouteId, - manifestContext - ) => { - performanceProfiler.recordSync( - 'web', - 'manifest:stage', - 'virtual/react-router/browser-manifest', - () => { - latestBrowserManifest = manifest; - latestBrowserManifestModuleExports = - moduleExportsByRouteId; - const baseServerManifest = { - ...manifest, - sri, - }; - latestServerManifest = baseServerManifest; - const manifestsByEntryName = { - [devServerBuildEntryName]: baseServerManifest, - } as Record; - for (const { - bundleId, - entryName, - } of serverBundleEntries) { - const bundleRoutes = - routesByServerBundleId[bundleId]; - if (!bundleRoutes) { - continue; - } - const routeIds = new Set( - Object.keys(bundleRoutes) - ); - const filteredRoutes = Object.fromEntries( - Object.entries(manifest.routes).filter( - ([routeId]) => routeIds.has(routeId) - ) - ); - const bundleManifest = { - ...baseServerManifest, - routes: filteredRoutes, - }; - latestServerManifestsByBundleId[bundleId] = - bundleManifest; - manifestsByEntryName[entryName] = bundleManifest; - } - if (!isBuild) { - devRuntime.captureWeb( - manifestContext.compilation, - manifestsByEntryName - ); - } - } - ); - }, - } - ) - ); - } return rspackConfig; }, }, @@ -1538,6 +1387,26 @@ export const pluginReactRouter = ( } ); + registerModifyBrowserManifestAssets( + api, + routes, + pluginOptions, + appDirectory, + () => assetPrefix, + routeChunkOptions, + { + future, + manifestChunkNames, + onManifest: (manifest, sri, moduleExportsByRouteId, context) => + stageLatestManifests( + manifest, + sri, + moduleExportsByRouteId, + context.compilation + ), + } + ); + api.processAssets( { stage: 'additional', targets: ['node'] }, ({ sources, compilation }) => { @@ -1697,12 +1566,28 @@ export const pluginReactRouter = ( args.environment?.name, 'module:client-only-stub', args.resource, - async () => - routeTransformExecutor.run({ + async () => { + const resolveExportAllModule = + typeof args.resolve === 'function' + ? (specifier: string, importerPath: string) => + new Promise(resolveModule => { + args.resolve( + dirname(importerPath), + specifier, + (error, path) => { + resolveModule(error || !path ? null : path); + } + ); + }) + : undefined; + + return routeTransformExecutor.run({ kind: 'clientOnlyStub', code: args.code, resourcePath: args.resourcePath, - }) + resolveExportAllModule, + }); + } ) ); diff --git a/src/lazy-compilation.ts b/src/lazy-compilation.ts new file mode 100644 index 0000000..2fd8649 --- /dev/null +++ b/src/lazy-compilation.ts @@ -0,0 +1,93 @@ +import { BUILD_CLIENT_ROUTE_QUERY_STRING } from './constants.js'; +import type { PluginOptions } from './types.js'; + +type LazyCompilationOptions = Exclude< + NonNullable, + boolean +>; + +type LazyCompilationModule = { + request?: string; + userRequest?: string; + rawRequest?: string; + resource?: string; + identifier?: () => string; + nameForCondition?: () => string | null; +}; + +const normalizeSlashes = (value: string): string => value.replace(/\\/g, '/'); + +const getLazyCompilationModuleValues = ( + module: LazyCompilationModule +): string[] => { + const values = [ + module.request, + module.userRequest, + module.rawRequest, + module.resource, + module.identifier?.(), + module.nameForCondition?.(), + ]; + return values.filter((value): value is string => Boolean(value)); +}; + +const matchesLazyCompilationTest = ( + test: LazyCompilationOptions['test'] | undefined, + module: LazyCompilationModule +): boolean => { + if (!test) { + return true; + } + if (typeof test === 'function') { + return test(module as Parameters[0]); + } + return getLazyCompilationModuleValues(module).some(value => { + test.lastIndex = 0; + return test.test(value); + }); +}; + +const isReactRouterHydrationModule = ( + module: LazyCompilationModule, + entryClientPath: string +): boolean => { + const eagerPatterns = [ + normalizeSlashes(entryClientPath), + 'virtual/react-router/browser-manifest', + BUILD_CLIENT_ROUTE_QUERY_STRING, + '?react-router-route', + ]; + + return getLazyCompilationModuleValues(module) + .map(normalizeSlashes) + .some(value => eagerPatterns.some(pattern => value.includes(pattern))); +}; + +export const guardReactRouterLazyCompilation = ({ + lazyCompilation, + entryClientPath, +}: { + lazyCompilation: PluginOptions['lazyCompilation'] | undefined; + entryClientPath: string; +}): PluginOptions['lazyCompilation'] | undefined => { + if (lazyCompilation === undefined || lazyCompilation === false) { + return lazyCompilation; + } + + const options: LazyCompilationOptions = + lazyCompilation === true + ? { entries: true, imports: true } + : lazyCompilation; + const userTest = options.test; + + return { + ...options, + test(module) { + const lazyModule = module as LazyCompilationModule; + if (isReactRouterHydrationModule(lazyModule, entryClientPath)) { + return false; + } + return matchesLazyCompilationTest(userTest, lazyModule); + }, + }; +}; diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 35fa237..11311ac 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import type { Route, PluginOptions } from './types.js'; -import { rspack } from '@rsbuild/core'; -import type { Rspack } from '@rsbuild/core'; +import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core'; import { createReactRouterManifestStats, generateReactRouterManifestForDev, @@ -12,145 +11,171 @@ import { import { combineURLs } from './plugin-utils.js'; import jsesc from 'jsesc'; -/** - * Creates a Webpack/Rspack plugin that modifies the browser manifest - * @param routes - The routes configuration - * @param pluginOptions - The plugin options - * @param appDirectory - The application directory - * @returns A webpack/rspack plugin - */ -export function createModifyBrowserManifestPlugin( +type ModifyBrowserManifestOptions = { + future?: { unstable_subResourceIntegrity?: boolean }; + manifestChunkNames?: ReadonlySet; + onManifest?: ( + manifest: Awaited>, + sri: Record | undefined, + moduleExportsByRouteId: Awaited< + ReturnType + >['moduleExportsByRouteId'], + context: { + compilation: Rspack.Compilation; + manifestStats: ReturnType; + } + ) => void; +}; + +type ProcessAssetsApi = Pick; +type AssetPrefixInput = string | (() => string); +type ReactRouterManifest = Awaited< + ReturnType +>; +type RouteManifestModuleExports = Awaited< + ReturnType +>['moduleExportsByRouteId']; +type GeneratedManifest = { + manifest: ReactRouterManifest; + moduleExportsByRouteId: RouteManifestModuleExports; + manifestStats: ReturnType; + assetPrefix: string; +}; + +const BROWSER_MANIFEST_ASSET = + 'static/js/virtual/react-router/browser-manifest.js'; + +const createSubresourceIntegrity = ( + compilation: Rspack.Compilation, + assetPrefix: string +) => { + const sri: Record = {}; + for (const asset of compilation.getAssets()) { + if (!asset.name.endsWith('.js')) { + continue; + } + const source = asset.source.source().toString(); + const hash = createHash('sha384').update(source).digest('base64'); + sri[combineURLs(assetPrefix, asset.name)] = `sha384-${hash}`; + } + return sri; +}; + +export function registerModifyBrowserManifestAssets( + api: ProcessAssetsApi, routes: Record, pluginOptions: PluginOptions, appDirectory: string, - assetPrefix = '/', + assetPrefix: AssetPrefixInput = '/', routeChunkOptions?: Parameters[5], - options?: { - future?: { unstable_subResourceIntegrity?: boolean }; - manifestChunkNames?: ReadonlySet; - onManifest?: ( - manifest: Awaited>, - sri: Record | undefined, - moduleExportsByRouteId: Awaited< - ReturnType - >['moduleExportsByRouteId'], - context: { - compilation: Rspack.Compilation; - manifestStats: ReturnType; - } - ) => void; - } -) { + options?: ModifyBrowserManifestOptions +): void { + const getAssetPrefix = + typeof assetPrefix === 'function' ? assetPrefix : () => assetPrefix; const manifestChunkNames = options?.manifestChunkNames ?? getReactRouterManifestChunkNames( routes, routeChunkOptions?.splitRouteModules ); + const finalizeSri = + routeChunkOptions?.isBuild && + options?.future?.unstable_subResourceIntegrity; + const generatedManifests = finalizeSri + ? new WeakMap() + : undefined; - return { - apply(compiler: Rspack.Compiler): void { - compiler.hooks.emit.tapPromise( - 'ModifyBrowserManifest', - async (compilation: Rspack.Compilation) => { - const stats = createReactRouterManifestStats( - compilation, - manifestChunkNames - ); - const { manifest, moduleExportsByRouteId } = - await generateReactRouterManifestForDev( - routes, - pluginOptions, - stats, - appDirectory, - assetPrefix, - routeChunkOptions - ); + api.processAssets( + { stage: 'additions', environments: ['web'] }, + async ({ assets, sources, compilation }) => { + const currentAssetPrefix = getAssetPrefix(); + const stats = createReactRouterManifestStats( + compilation, + manifestChunkNames + ); + const { manifest, moduleExportsByRouteId } = + await generateReactRouterManifestForDev( + routes, + pluginOptions, + stats, + appDirectory, + currentAssetPrefix, + routeChunkOptions + ); - const virtualManifestPath = - 'static/js/virtual/react-router/browser-manifest.js'; - if (compilation.assets[virtualManifestPath]) { - const originalSource = compilation.assets[virtualManifestPath] - .source() - .toString(); - const newSource = originalSource.replace( - /["'`]PLACEHOLDER["'`]/, - jsesc(manifest, { es6: true }) - ); - compilation.assets[virtualManifestPath] = { - source: () => newSource, - size: () => newSource.length, - map: () => ({ - version: 3, - sources: [virtualManifestPath], - names: [], - mappings: '', - file: virtualManifestPath, - sourcesContent: [newSource], - }), - sourceAndMap: () => ({ - source: newSource, - map: { - version: 3, - sources: [virtualManifestPath], - names: [], - mappings: '', - file: virtualManifestPath, - sourcesContent: [newSource], - }, - }), - updateHash: hash => hash.update(newSource), - buffer: () => Buffer.from(newSource), - }; - } + const browserManifestAsset = assets[BROWSER_MANIFEST_ASSET]; + if (browserManifestAsset) { + const originalSource = browserManifestAsset.source().toString(); + const newSource = originalSource.replace( + /["'`]PLACEHOLDER["'`]/, + jsesc(manifest, { es6: true }) + ); + compilation.updateAsset( + BROWSER_MANIFEST_ASSET, + new sources.RawSource(newSource) + ); + } - if (routeChunkOptions?.isBuild) { - const entryAssets = stats?.assetsByChunkName?.['entry.client']; - const entryJsAssets = - entryAssets?.filter(asset => asset.endsWith('.js')) || []; - const manifestPath = getReactRouterManifestPath({ - version: manifest.version, - isBuild: true, - entryModulePath: entryJsAssets[0], - }); - const manifestSource = `window.__reactRouterManifest=${jsesc( - manifest, - { es6: true } - )};`; - compilation.assets[manifestPath] = new rspack.sources.RawSource( - manifestSource - ); - } + if (routeChunkOptions?.isBuild) { + const entryAssets = stats?.assetsByChunkName?.['entry.client']; + const entryJsAssets = + entryAssets?.filter(asset => asset.endsWith('.js')) || []; + const manifestPath = getReactRouterManifestPath({ + version: manifest.version, + isBuild: true, + entryModulePath: entryJsAssets[0], + }); + const manifestSource = `window.__reactRouterManifest=${jsesc(manifest, { + es6: true, + })};`; + const source = new sources.RawSource(manifestSource); + if (compilation.getAsset(manifestPath)) { + compilation.updateAsset(manifestPath, source); + } else { + compilation.emitAsset(manifestPath, source); + } + } - let sri: Record | undefined; - if ( - routeChunkOptions?.isBuild && - options?.future?.unstable_subResourceIntegrity - ) { - const assets = - typeof compilation.getAssets === 'function' - ? compilation.getAssets() - : Object.entries(compilation.assets).map(([name, asset]) => ({ - name, - source: asset, - })); - sri = {}; - for (const asset of assets) { - if (!asset.name.endsWith('.js')) { - continue; - } - const source = asset.source.source().toString(); - const hash = createHash('sha384').update(source).digest('base64'); - sri[combineURLs(assetPrefix, asset.name)] = `sha384-${hash}`; - } - } + if (generatedManifests) { + generatedManifests.set(compilation, { + manifest, + moduleExportsByRouteId, + manifestStats: stats, + assetPrefix: currentAssetPrefix, + }); + return; + } - options?.onManifest?.(manifest, sri, moduleExportsByRouteId, { - compilation, - manifestStats: stats, - }); + options?.onManifest?.(manifest, undefined, moduleExportsByRouteId, { + compilation, + manifestStats: stats, + }); + } + ); + + if (generatedManifests) { + api.processAssets( + { stage: 'report', environments: ['web'] }, + ({ compilation }) => { + const generatedManifest = generatedManifests.get(compilation); + if (!generatedManifest) { + return; } - ); - }, - }; + + generatedManifests.delete(compilation); + options?.onManifest?.( + generatedManifest.manifest, + createSubresourceIntegrity( + compilation, + generatedManifest.assetPrefix + ), + generatedManifest.moduleExportsByRouteId, + { + compilation, + manifestStats: generatedManifest.manifestStats, + } + ); + } + ); + } } diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 82af0ea..faad90a 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -131,6 +131,9 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { if (this.#closed) { return executeRouteTransformTask(task, this.options); } + if (task.kind === 'clientOnlyStub' && task.resolveExportAllModule) { + return executeRouteTransformTask(task, this.options); + } try { return await this.#runInWorker(task); diff --git a/src/performance.ts b/src/performance.ts index 1dedbd2..f15c8e2 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -71,7 +71,7 @@ export type ReactRouterPerformanceProfiler = { callback: () => T ): T; flush( - environment: string, + environment: string | undefined, details?: Pick ): void; }; @@ -171,7 +171,11 @@ export const createReactRouterPerformanceProfiler = ({ return { record(environment, operation, resource, callback) { if (!enabled) { - return callback(); + try { + return Promise.resolve(callback()); + } catch (error) { + return Promise.reject(error); + } } const resolvedEnvironment = environment ?? 'unknown'; @@ -226,7 +230,8 @@ export const createReactRouterPerformanceProfiler = ({ return; } - const timings = timingsByEnvironment.get(environment); + const resolvedEnvironment = environment ?? 'unknown'; + const timings = timingsByEnvironment.get(resolvedEnvironment); if (!timings || timings.size === 0) { return; } @@ -238,12 +243,12 @@ export const createReactRouterPerformanceProfiler = ({ ]) ); const report: ReactRouterPerformanceReport = { - environment, + environment: resolvedEnvironment, ...details, operations, }; log(`[react-router:performance] ${JSON.stringify(report)}`); - timingsByEnvironment.delete(environment); + timingsByEnvironment.delete(resolvedEnvironment); }, }; }; diff --git a/src/prerender.ts b/src/prerender.ts index 8b41ab0..db17003 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -1,7 +1,10 @@ import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; +import { matchRoutes } from 'react-router'; type PrerenderConfig = Config['prerender']; +type MatchRouteObject = + Parameters[0] extends Array ? R : never; type PrerenderPathsConfig = | boolean @@ -25,9 +28,152 @@ type StaticPrerenderPaths = { paramRoutes: string[]; }; +type SsrFalsePrerenderRoute = { + id: string; + parentId?: string; + path?: string; + index?: boolean; + hasLoader?: boolean; + hasClientLoader?: boolean; +}; + +type SsrFalsePrerenderExportOptions = { + routes: Record; + manifestRoutes: Record; + routeExports: Record; + prerenderPaths: string[]; +}; + const normalizePath = (value: string): string => value.replace(/\/\/+/g, '/').replace(/(.+)\/$/, '$1'); +const groupRoutesByParentId = ( + manifest: Record +): Record => { + const grouped: Record = {}; + Object.values(manifest).forEach(route => { + if (!route) { + return; + } + const parentId = route.parentId || ''; + grouped[parentId] ??= []; + grouped[parentId].push(route); + }); + return grouped; +}; + +export const createPrerenderRoutes = ( + manifest: Record, + parentId = '', + grouped: Record = groupRoutesByParentId(manifest) +): MatchRouteObject[] => { + return (grouped[parentId] || []).map(route => { + const common = { id: route.id, path: route.path }; + if (route.index) { + return { index: true, ...common } as MatchRouteObject; + } + return { + ...common, + children: createPrerenderRoutes(manifest, route.id, grouped), + } as MatchRouteObject; + }); +}; + +export const normalizePrerenderMatchPath = (path: string): string => + `/${path}/`.replace(/^\/\/+/, '/'); + +export 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(); + } +}; + +export const getSsrFalsePrerenderExportErrors = ({ + routes, + manifestRoutes, + routeExports, + prerenderPaths, +}: SsrFalsePrerenderExportOptions): string[] => { + if (prerenderPaths.length === 0) { + return []; + } + + const prerenderRoutes = createPrerenderRoutes(routes); + const prerenderedRoutes = new Set(); + for (const path of prerenderPaths) { + const matches = matchRoutes( + prerenderRoutes, + normalizePrerenderMatchPath(path) + ); + if (!matches) { + throw new Error( + `Unable to prerender path because it does not match any routes: ${path}` + ); + } + matches.forEach(match => prerenderedRoutes.add(match.route.id as string)); + } + + const errors: string[] = []; + for (const [routeId, route] of Object.entries(manifestRoutes)) { + const exports = routeExports[routeId] ?? []; + const invalidApis: string[] = []; + + if (exports.includes('headers')) invalidApis.push('headers'); + if (exports.includes('action')) invalidApis.push('action'); + + if (invalidApis.length > 0) { + errors.push( + `Prerender: ${invalidApis.length} invalid route export(s) in ` + + `\`${routeId}\` when pre-rendering with \`ssr:false\`: ` + + `${invalidApis.map(api => `\`${api}\``).join(', ')}. ` + + `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` + ); + } + + if (!prerenderedRoutes.has(routeId)) { + if (exports.includes('loader')) { + errors.push( + `Prerender: 1 invalid route export in \`${routeId}\` when pre-rendering with ` + + `\`ssr:false\`: \`loader\`. ` + + `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` + ); + } + + let parentRoute = + route.parentId && manifestRoutes[route.parentId] + ? manifestRoutes[route.parentId] + : null; + while (parentRoute) { + if (parentRoute.hasLoader && !parentRoute.hasClientLoader) { + errors.push( + `Prerender: 1 invalid route export in \`${parentRoute.id}\` when ` + + `pre-rendering with \`ssr:false\`: \`loader\`. ` + + `See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.` + ); + } + parentRoute = + parentRoute.parentId && manifestRoutes[parentRoute.parentId] + ? manifestRoutes[parentRoute.parentId] + : null; + } + } + } + + return errors; +}; + export const getStaticPrerenderPaths = ( routes: RouteConfigEntry[] ): StaticPrerenderPaths => { diff --git a/src/route-ast.ts b/src/route-ast.ts new file mode 100644 index 0000000..2793dcf --- /dev/null +++ b/src/route-ast.ts @@ -0,0 +1,159 @@ +import type { ParseResult } from 'yuku-parser'; + +export type AnyNode = Record; + +export const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; + +export const getPatternIdentifierNames = ( + pattern: AnyNode | null | undefined, + names: Set = 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; +}; + +export const getIdentifierNamesFromPattern = ( + pattern: AnyNode | null | undefined +): string[] => Array.from(getPatternIdentifierNames(pattern)); + +export const patternIncludesName = ( + pattern: AnyNode | null | undefined, + name: string +): boolean => getPatternIdentifierNames(pattern).has(name); + +export const getExportedName = ( + node: AnyNode | null | undefined +): string | null => { + const exported = node?.exported ?? node; + if (!exported) { + return null; + } + if (exported.type === 'Identifier') { + return exported.name; + } + if (exported.type === 'Literal' || exported.type === 'StringLiteral') { + return String(exported.value); + } + return null; +}; + +export const identifier = (name: string): AnyNode => ({ + type: 'Identifier', + start: 0, + end: 0, + name, + decorators: [], + optional: false, + typeAnnotation: null, +}); + +export const literal = (value: string): AnyNode => ({ + type: 'Literal', + start: 0, + end: 0, + value, + raw: JSON.stringify(value), +}); + +export const callExpression = (callee: AnyNode, args: AnyNode[]): AnyNode => ({ + type: 'CallExpression', + start: 0, + end: 0, + callee, + arguments: args, + optional: false, +}); + +export 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', +}); + +export const exportSpecifier = (local: string, exported: string): AnyNode => ({ + type: 'ExportSpecifier', + start: 0, + end: 0, + local: identifier(local), + exported: identifier(exported), + exportKind: 'value', +}); + +export const exportNamedDeclaration = (specifiers: AnyNode[]): AnyNode => ({ + type: 'ExportNamedDeclaration', + start: 0, + end: 0, + declaration: null, + specifiers, + source: null, + attributes: [], + exportKind: 'value', +}); + +export 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, + }, + ], +}); + +export const removeFromArray = (array: T[], value: T): void => { + const index = array.indexOf(value); + if (index >= 0) { + array.splice(index, 1); + } +}; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index e8c47b7..8f7dd86 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -70,7 +70,6 @@ const routeChunkQueryStrings: Record = { clientMiddleware: `${routeChunkQueryStringPrefix}clientMiddleware`, HydrateFallback: `${routeChunkQueryStringPrefix}HydrateFallback`, }; -const routeChunkQueryStringValues = Object.values(routeChunkQueryStrings); const routeChunkEntrySuffix: Record = { clientAction: 'client-action', @@ -209,6 +208,12 @@ const getExportDependencies = ( const exportDependencies = new Map(); const topLevelStatementCache = new Map(); const variableDeclaratorCache = new Map(); + const sharedTopLevelSideEffects = (module.ast as AnyNode).body.filter( + (statement: AnyNode) => + (statement.type === 'ImportDeclaration' && + statement.specifiers.length === 0) || + statement.type === 'ExpressionStatement' + ); const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => { const cached = topLevelStatementCache.get(node); @@ -321,6 +326,11 @@ const getExportDependencies = ( scanNode(statement); } + for (const statement of sharedTopLevelSideEffects) { + dependencies.topLevelStatements.add(statement); + dependencies.topLevelNonModuleStatements.add(statement); + } + exportDependencies.set(exportName, dependencies); }; @@ -706,7 +716,7 @@ export const getRouteChunkModuleId = ( ) => `${filePath}${routeChunkQueryStrings[chunkName]}`; export const isRouteChunkModuleId: (id: string) => boolean = (id: string) => - routeChunkQueryStringValues.some(queryString => id.endsWith(queryString)); + getRouteChunkNameFromModuleId(id) !== null; const isRouteChunkName = (name: string): name is RouteChunkName => name === 'main' || (routeChunkExportNames as string[]).includes(name); diff --git a/src/route-component-transform.ts b/src/route-component-transform.ts index 6d921d1..c1dbea6 100644 --- a/src/route-component-transform.ts +++ b/src/route-component-transform.ts @@ -3,25 +3,18 @@ import { NAMED_COMPONENT_EXPORTS_SET, } from './constants.js'; import type { ParseResult } from 'yuku-parser'; - -type AnyNode = Record; - -const getProgram = (ast: ParseResult | AnyNode): AnyNode => - (ast as ParseResult).program ?? ast; - -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; -}; +import { + callExpression, + exportNamedDeclaration, + exportSpecifier, + getExportedName, + getProgram, + identifier, + importDeclaration, + patternIncludesName, + variableDeclaration, + type AnyNode, +} from './route-ast.js'; export function toFunctionExpression(decl: AnyNode): AnyNode { return { @@ -39,74 +32,6 @@ export function toClassExpression(decl: AnyNode): AnyNode { }; } -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 exportSpecifier = (local: string, exported: string): AnyNode => ({ - type: 'ExportSpecifier', - start: 0, - end: 0, - local: identifier(local), - exported: identifier(exported), - exportKind: 'value', -}); - -const exportNamedDeclaration = (specifiers: AnyNode[]): AnyNode => ({ - type: 'ExportNamedDeclaration', - start: 0, - end: 0, - declaration: null, - specifiers, - source: null, - attributes: [], - exportKind: 'value', -}); - const getComponentExportName = (exportedName: string): string | null => { if (exportedName === 'default') { return 'Component'; @@ -129,70 +54,6 @@ const getImportInsertionIndex = (program: AnyNode): number => { return index; }; -const getModuleExportName = ( - node: AnyNode | null | undefined -): string | null => { - if (!node) { - return null; - } - if (node.type === 'Identifier') { - return node.name; - } - if (node.type === 'Literal') { - return String(node.value); - } - return null; -}; - -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 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 @@ -204,7 +65,8 @@ const declarationIncludesName = ( } if ( (declaration.type === 'FunctionDeclaration' || - declaration.type === 'ClassDeclaration') && + declaration.type === 'ClassDeclaration' || + declaration.type === 'TSEnumDeclaration') && declaration.id?.name ) { return declaration.id.name === name; @@ -283,6 +145,12 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { if (!declaration) { continue; } + if ( + declaration.declare === true || + declaration.type === 'TSInterfaceDeclaration' + ) { + continue; + } const uid = getHocUid('withComponentProps'); if ( (declaration.type === 'FunctionDeclaration' || @@ -352,7 +220,7 @@ export const transformRoute = (ast: ParseResult | AnyNode): void => { return true; } const exportedName = getExportedName(specifier); - const importedName = getModuleExportName(specifier.local); + const importedName = getExportedName(specifier.local); const componentExportName = exportedName ? getComponentExportName(exportedName) : null; diff --git a/src/route-export-pruning.ts b/src/route-export-pruning.ts index 5cfd5fb..698d64a 100644 --- a/src/route-export-pruning.ts +++ b/src/route-export-pruning.ts @@ -1,9 +1,11 @@ import { walk, type ParseResult } from 'yuku-parser'; - -type AnyNode = Record; - -const getProgram = (ast: ParseResult | AnyNode): AnyNode => - (ast as ParseResult).program ?? ast; +import { + getExportedName, + getPatternIdentifierNames, + getProgram, + removeFromArray, + type AnyNode, +} from './route-ast.js'; export function validateDestructuredExports( id: AnyNode, @@ -91,48 +93,6 @@ export function invalidDestructureError(name: string): Error { return new Error(`Cannot remove destructured export "${name}"`); } -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') { @@ -287,20 +247,6 @@ const collectReferencedNames = (node: AnyNode): Set => { 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; -}; - type TopLevelDeclaration = { referencedNames: Set; }; diff --git a/src/route-export-resolution.ts b/src/route-export-resolution.ts new file mode 100644 index 0000000..9cf3d63 --- /dev/null +++ b/src/route-export-resolution.ts @@ -0,0 +1,285 @@ +import { readFileSync, statSync, type Stats } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { dirname, relative, resolve } from 'pathe'; +import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js'; +import { + getExportNamesAndExportAll, + getRouteModuleAnalysis, +} from './export-utils.js'; + +type PackageJson = { + exports?: unknown; + module?: unknown; + main?: unknown; +}; + +type PackageImportResolution = + | { status: 'resolved'; path: string } + | { status: 'blocked-by-exports' } + | { status: 'not-found' }; + +const tryStat = (path: string): Stats | null => + statSync(path, { throwIfNoEntry: false }) ?? null; + +const resolveIndexFile = (dirPath: string): string | null => { + for (const ext of JS_EXTENSIONS) { + const candidate = resolve(dirPath, `index${ext}`); + const stats = tryStat(candidate); + if (stats?.isFile()) { + return candidate; + } + } + return null; +}; + +const resolvePathWithExtensions = (basePath: string): string | null => { + 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}`; + const candidateStats = tryStat(candidate); + if (candidateStats?.isFile()) { + return candidate; + } + } + + return resolveIndexFile(basePath); +}; + +const parsePackageSpecifier = ( + specifier: string +): { packageName: string; subpath: string } | null => { + if ( + specifier.startsWith('.') || + specifier.startsWith('/') || + specifier.startsWith('node:') + ) { + return null; + } + const parts = specifier.split('/'); + const packageName = specifier.startsWith('@') + ? parts.slice(0, 2).join('/') + : parts[0]; + if (!packageName || (specifier.startsWith('@') && parts.length < 2)) { + return null; + } + const rest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/'); + return { + packageName, + subpath: rest ? `./${rest}` : '.', + }; +}; + +const findPackageDirectory = ( + packageName: string, + importerPath: string +): string | null => { + let currentDirectory = dirname(importerPath); + while (true) { + const candidate = resolve(currentDirectory, 'node_modules', packageName); + if (tryStat(candidate)?.isDirectory()) { + return candidate; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } +}; + +const readPackageJson = (packageDirectory: string): PackageJson | null => { + try { + return JSON.parse( + readFileSync(resolve(packageDirectory, 'package.json'), 'utf8') + ); + } catch { + return null; + } +}; + +const resolvePackageTarget = ( + packageDirectory: string, + target: unknown +): string | null => { + if (typeof target === 'string') { + return resolvePathWithExtensions(resolve(packageDirectory, target)); + } + if (Array.isArray(target)) { + for (const item of target) { + const resolved = resolvePackageTarget(packageDirectory, item); + if (resolved) { + return resolved; + } + } + return null; + } + if (target && typeof target === 'object') { + const conditions = target as Record; + for (const condition of ['import', 'default']) { + const resolved = resolvePackageTarget( + packageDirectory, + conditions[condition] + ); + if (resolved) { + return resolved; + } + } + } + return null; +}; + +const resolvePackageImport = ( + specifier: string, + importerPath: string +): PackageImportResolution => { + const parsed = parsePackageSpecifier(specifier); + if (!parsed) { + return { status: 'not-found' }; + } + const packageDirectory = findPackageDirectory( + parsed.packageName, + importerPath + ); + if (!packageDirectory) { + return { status: 'not-found' }; + } + const packageJson = readPackageJson(packageDirectory); + if (!packageJson) { + return { status: 'not-found' }; + } + const exportsField = packageJson.exports; + if ('exports' in packageJson) { + const hasSubpathExports = + typeof exportsField === 'object' && + !Array.isArray(exportsField) && + exportsField !== null && + Object.keys(exportsField).some(key => key.startsWith('.')); + const target = + parsed.subpath === '.' && !hasSubpathExports + ? exportsField + : hasSubpathExports + ? (exportsField as Record)[parsed.subpath] + : undefined; + const resolved = resolvePackageTarget(packageDirectory, target); + if (resolved) { + return { status: 'resolved', path: resolved }; + } + return { status: 'blocked-by-exports' }; + } + if (parsed.subpath !== '.') { + const resolved = resolvePathWithExtensions( + resolve(packageDirectory, parsed.subpath) + ); + return resolved + ? { status: 'resolved', path: resolved } + : { status: 'not-found' }; + } + const resolved = + resolvePackageTarget(packageDirectory, packageJson.module) ?? + resolvePackageTarget(packageDirectory, packageJson.main) ?? + resolveIndexFile(packageDirectory); + return resolved + ? { status: 'resolved', path: resolved } + : { status: 'not-found' }; +}; + +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 packageImport = resolvePackageImport(specifier, importerPath); + if (packageImport.status === 'resolved') { + return packageImport.path; + } + if (packageImport.status === 'blocked-by-exports') { + return null; + } + + try { + const resolver = createRequire(pathToFileURL(importerPath).href); + return resolver.resolve(specifier); + } catch { + return null; + } +}; + +export type RouteExportResolver = ( + specifier: string, + importerPath: string +) => Promise | string | null; + +export const collectClientOnlyStubExportNames = async ( + code: string, + resourcePath: string, + resolveModule: RouteExportResolver = resolveExportAllModule +): Promise> => { + const { exportNames: directExportNames, exportAllModules } = + await getExportNamesAndExportAll(code); + 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 = await resolveModule(nestedSpecifier, modulePath); + if (!nestedPath) { + unresolvedExportAll.add(nestedSpecifier); + continue; + } + await collectExportNamesFromModule(nestedPath); + } + }; + + for (const specifier of exportAllModules) { + const resolvedPath = await resolveModule(specifier, 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(), resourcePath)}\`.` + ); + } + + return exportNames; +}; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index 39b6479..b484368 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -1,24 +1,19 @@ -import { readFileSync, 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'; +import { basename as pathBasename, relative } from 'pathe'; import { generate, parse } from './yuku.js'; import { - JS_EXTENSIONS, - PLUGIN_NAME, SERVER_ONLY_ROUTE_EXPORTS, SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; -import { - collectProgramExportNames, - getExportNamesAndExportAll, - getRouteModuleAnalysis, -} from './export-utils.js'; +import { collectProgramExportNames } from './export-utils.js'; import { removeExports, removeUnusedImports, transformRoute, } from './plugin-utils.js'; +import { + collectClientOnlyStubExportNames, + type RouteExportResolver, +} from './route-export-resolution.js'; import { createRouteChunkArtifact, createRouteClientEntryArtifact, @@ -40,12 +35,6 @@ type BaseRouteTransformTask = { resourcePath: string; }; -type PackageJson = { - exports?: unknown; - module?: unknown; - main?: unknown; -}; - export type RouteClientEntryTransformTask = BaseRouteTransformTask & { kind: 'routeClientEntry'; environmentName?: string; @@ -67,6 +56,7 @@ export type SplitRouteExportsTransformTask = BaseRouteTransformTask & { export type ClientOnlyStubTransformTask = BaseRouteTransformTask & { kind: 'clientOnlyStub'; + resolveExportAllModule?: RouteExportResolver; }; export type RouteModuleTransformTask = BaseRouteTransformTask & { @@ -96,9 +86,6 @@ 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 @@ -142,247 +129,14 @@ const splitRouteExports = async ( }; }; -const resolveIndexFile = (dirPath: string): string | null => { - for (const ext of JS_EXTENSIONS) { - const candidate = resolve(dirPath, `index${ext}`); - const stats = tryStat(candidate); - if (!stats?.isFile()) { - continue; - } - return candidate; - } - return null; -}; - -const resolvePathWithExtensions = (basePath: string): string | null => { - 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}`; - const candidateStats = tryStat(candidate); - if (!candidateStats?.isFile()) { - continue; - } - return candidate; - } - - return resolveIndexFile(basePath); -}; - -const parsePackageSpecifier = ( - specifier: string -): { packageName: string; subpath: string } | null => { - if ( - specifier.startsWith('.') || - specifier.startsWith('/') || - specifier.startsWith('node:') - ) { - return null; - } - const parts = specifier.split('/'); - const packageName = specifier.startsWith('@') - ? parts.slice(0, 2).join('/') - : parts[0]; - if (!packageName || (specifier.startsWith('@') && parts.length < 2)) { - return null; - } - const rest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/'); - return { - packageName, - subpath: rest ? `./${rest}` : '.', - }; -}; - -const findPackageDirectory = ( - packageName: string, - importerPath: string -): string | null => { - let currentDirectory = dirname(importerPath); - while (true) { - const candidate = resolve(currentDirectory, 'node_modules', packageName); - if (tryStat(candidate)?.isDirectory()) { - return candidate; - } - const parentDirectory = dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - return null; - } - currentDirectory = parentDirectory; - } -}; - -const readPackageJson = (packageDirectory: string): PackageJson | null => { - try { - return JSON.parse( - readFileSync(resolve(packageDirectory, 'package.json'), 'utf8') - ); - } catch { - return null; - } -}; - -const resolvePackageTarget = ( - packageDirectory: string, - target: unknown -): string | null => { - if (typeof target === 'string') { - return resolvePathWithExtensions(resolve(packageDirectory, target)); - } - if (Array.isArray(target)) { - for (const item of target) { - const resolved = resolvePackageTarget(packageDirectory, item); - if (resolved) { - return resolved; - } - } - return null; - } - if (target && typeof target === 'object') { - const conditions = target as Record; - for (const condition of ['import', 'default']) { - const resolved = resolvePackageTarget( - packageDirectory, - conditions[condition] - ); - if (resolved) { - return resolved; - } - } - } - return null; -}; - -const resolvePackageImport = ( - specifier: string, - importerPath: string -): string | null => { - const parsed = parsePackageSpecifier(specifier); - if (!parsed) { - return null; - } - const packageDirectory = findPackageDirectory( - parsed.packageName, - importerPath - ); - if (!packageDirectory) { - return null; - } - const packageJson = readPackageJson(packageDirectory); - if (!packageJson) { - return null; - } - const exportsField = packageJson.exports; - if (exportsField) { - const hasSubpathExports = - typeof exportsField === 'object' && - !Array.isArray(exportsField) && - Object.keys(exportsField).some(key => key.startsWith('.')); - const target = - parsed.subpath === '.' && !hasSubpathExports - ? exportsField - : hasSubpathExports - ? (exportsField as Record)[parsed.subpath] - : undefined; - const resolved = resolvePackageTarget(packageDirectory, target); - if (resolved) { - return resolved; - } - } - if (parsed.subpath !== '.') { - return resolvePathWithExtensions(resolve(packageDirectory, parsed.subpath)); - } - return ( - resolvePackageTarget(packageDirectory, packageJson.module) ?? - resolvePackageTarget(packageDirectory, packageJson.main) ?? - resolveIndexFile(packageDirectory) - ); -}; - -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 packageImportPath = resolvePackageImport(specifier, importerPath); - if (packageImportPath) { - return packageImportPath; - } - - try { - const resolver = createRequire(pathToFileURL(importerPath).href); - return resolver.resolve(specifier); - } catch { - return null; - } -}; - const createClientOnlyStub = async ( task: ClientOnlyStubTransformTask ): Promise => { - const { exportNames: directExportNames, exportAllModules } = - await getExportNamesAndExportAll(task.code); - 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)}\`.` - ); - } + const exportNames = await collectClientOnlyStubExportNames( + task.code, + task.resourcePath, + task.resolveExportAllModule + ); return { code: Array.from(exportNames) diff --git a/src/route-watch.ts b/src/route-watch.ts index 500357f..ec26780 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -260,6 +260,9 @@ export const createRouteTopologyWatcher = async ({ let nextDirectories: Set | undefined; try { nextDirectories = await readRouteDirectories(watchDirectory); + if (closed) { + return; + } const nextState = { directories: nextDirectories, routeTopology: await getRouteTopology(), diff --git a/src/server-build-plan.ts b/src/server-build-plan.ts new file mode 100644 index 0000000..60dadfd --- /dev/null +++ b/src/server-build-plan.ts @@ -0,0 +1,91 @@ +import { PLUGIN_NAME } from './constants.js'; +import type { ReactRouterDevBuildPlan } from './dev-runtime-artifacts.js'; +import type { Route } from './types.js'; + +export type ReactRouterServerBundleEntry = { + bundleId: string; + entryName: string; +}; + +export type ReactRouterServerBuildPlan = ReactRouterDevBuildPlan & { + serverBundleEntries: ReactRouterServerBundleEntry[]; +}; + +export const createReactRouterServerBuildPlan = ({ + routesByServerBundleId, + serverBuildFile, + defaultEntryName, +}: { + routesByServerBundleId: Record>; + serverBuildFile: string | undefined; + defaultEntryName: string; +}): ReactRouterServerBuildPlan => { + const serverBuildFileBase = (serverBuildFile || 'index.js').replace( + /\.js$/, + '' + ); + const serverBundleEntries = Object.entries(routesByServerBundleId) + .filter(([, bundleRoutes]) => + Boolean(bundleRoutes && Object.keys(bundleRoutes).length > 0) + ) + .map(([bundleId]) => ({ + bundleId, + entryName: `${bundleId}/${serverBuildFileBase}`, + })); + + const reservedNodeEntryNames = new Set([ + 'static/js/app', + 'static/js/entry.server', + defaultEntryName, + ]); + for (const { entryName } of serverBundleEntries) { + if (reservedNodeEntryNames.has(entryName)) { + throw new Error( + `[${PLUGIN_NAME}] Server bundle entry ${JSON.stringify(entryName)} conflicts with a reserved node entry.` + ); + } + reservedNodeEntryNames.add(entryName); + } + + return { + defaultEntryName, + entryNames: [ + defaultEntryName, + ...serverBundleEntries.map(({ entryName }) => entryName), + ], + serverBundleEntries, + }; +}; + +export const createReactRouterNodeEntries = ({ + hasServerApp, + isBuild, + serverAppPath, + entryServerPath, + defaultEntryName, + serverBundleEntries, +}: { + hasServerApp: boolean; + isBuild: boolean; + serverAppPath: string; + entryServerPath: string; + defaultEntryName: string; + serverBundleEntries: readonly ReactRouterServerBundleEntry[]; +}): Record => { + const entries: Record = { + 'static/js/app': hasServerApp + ? serverAppPath + : 'virtual/react-router/server-build', + 'static/js/entry.server': entryServerPath, + }; + + if (hasServerApp && !isBuild) { + entries[defaultEntryName] = 'virtual/react-router/server-build'; + } + + for (const { bundleId, entryName } of serverBundleEntries) { + entries[entryName] = `virtual/react-router/server-build-${bundleId}`; + } + + return entries; +}; diff --git a/src/server-utils.ts b/src/server-utils.ts index 7f51791..ecc37e8 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -191,6 +191,14 @@ export async function resolveServerBuildModule( return fromDefault; } } + if (isRecord(moduleValue) && 'module.exports' in moduleValue) { + const fromModuleExports = await resolveServerBuildCandidate( + await moduleValue['module.exports'] + ); + if (fromModuleExports) { + return fromModuleExports; + } + } throw new Error( `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` ); diff --git a/tests/bounded-cache.test.ts b/tests/bounded-cache.test.ts index 1a29c21..338b9ed 100644 --- a/tests/bounded-cache.test.ts +++ b/tests/bounded-cache.test.ts @@ -28,4 +28,18 @@ describe('bounded cache helpers', () => { expect([...cache.entries()]).toEqual([]); }); + + it('evicts an undefined oldest key when inserting past the maximum size', () => { + const cache = new Map([ + [undefined, 1], + ['second', 2], + ]); + + setBoundedCacheEntry(cache, 'third', 3, 2); + + expect([...cache.entries()]).toEqual([ + ['second', 2], + ['third', 3], + ]); + }); }); diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts index 23b6b5e..354e29f 100644 --- a/tests/client-modules.test.ts +++ b/tests/client-modules.test.ts @@ -5,6 +5,7 @@ import { resolve } from 'pathe'; import { describe, expect, it } from '@rstest/core'; import { createStubRsbuild } from '@scripts/test-helper'; import { pluginReactRouter } from '../src'; +import { collectClientOnlyStubExportNames } from '../src/route-export-resolution'; describe('client-only module transforms', () => { it('stubs exports for .client modules using export *', async () => { @@ -97,4 +98,88 @@ describe('client-only module transforms', () => { await rm(root, { recursive: true, force: true }); } }); + + it('does not bypass package exports for private export-all subpaths', async () => { + const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-private-')); + const packageDirectory = join(root, 'node_modules', 'private-client-lib'); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ + name: 'private-client-lib', + exports: { + '.': './public.js', + }, + type: 'module', + }) + ); + await writeFile(join(packageDirectory, 'public.js'), 'export const ok = true;'); + await writeFile( + join(packageDirectory, 'private.js'), + 'export const hidden = true;' + ); + const resourcePath = join(root, 'app', 'example.client.ts'); + await mkdir(join(root, 'app'), { recursive: true }); + await writeFile(resourcePath, "export * from 'private-client-lib/private';"); + + try { + await expect( + collectClientOnlyStubExportNames( + await readFile(resourcePath, 'utf8'), + resourcePath + ) + ).rejects.toThrow('private-client-lib/private'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('uses the Rsbuild transform resolver for export-all modules', async () => { + const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-resolve-')); + const appDirectory = join(root, 'app'); + const resourcePath = join(appDirectory, 'example.client.ts'); + const resolvedPath = join(root, 'generated', 'client-exports.ts'); + await mkdir(appDirectory, { recursive: true }); + await mkdir(join(root, 'generated'), { recursive: true }); + await writeFile(resourcePath, "export * from '@client/exports';"); + await writeFile( + resolvedPath, + 'export const fromResolver = true; export const alsoResolver = true;' + ); + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as any); + + const transformCall = (rsbuild.transform as any).mock.calls.find( + (call: any[]) => call[0].test?.toString().includes('\\.client') + ); + expect(transformCall).toBeDefined(); + + const handler = transformCall?.[1]; + const result = await handler({ + environment: { name: 'node' }, + code: await readFile(resourcePath, 'utf8'), + resourcePath, + resolve( + context: string, + specifier: string, + callback: (error: Error | null, resolved?: string) => void + ) { + expect(context).toBe(appDirectory); + expect(specifier).toBe('@client/exports'); + callback(null, resolvedPath); + }, + }); + + expect(result.code).toContain('export const fromResolver = undefined;'); + expect(result.code).toContain('export const alsoResolver = undefined;'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index eb84766..9d89b8d 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -170,8 +170,9 @@ const createHarness = (userSetup?: TestServerSetup) => { ) => { const handler = typeof callback === 'function' ? callback : callback.handler; - const config = handler({ server: { setup: serverSetups } }); - const setup = config?.server?.setup; + const nextConfig: TestConfig = { server: { setup: serverSetups } }; + const config = handler(nextConfig) ?? nextConfig; + const setup = config.server?.setup; serverSetups = setup ? Array.isArray(setup) ? setup @@ -338,7 +339,7 @@ describe('React Router development runtime controller', () => { it('rejects readiness when Rsbuild does not create both compilers', async () => { const { callbacks, controller, server } = createHarness(); const compiler = createCompiler('web'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: compiler.compiler }); @@ -351,7 +352,7 @@ describe('React Router development runtime controller', () => { const { callbacks, controller, server } = createHarness(); const web = createCompiler('web'); const node = createCompiler('node'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [web.compiler, node.compiler] }, }); @@ -370,7 +371,7 @@ describe('React Router development runtime controller', () => { const { callbacks, controller, loadBundle, server } = createHarness(); const web = createCompiler('web'); const node = createCompiler('node'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [web.compiler, node.compiler] }, }); @@ -593,7 +594,7 @@ describe('React Router development runtime controller', () => { const { callbacks, controller, createServer, server } = createHarness(); const oldWeb = createCompiler('web'); const oldNode = createCompiler('node'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, }); @@ -619,7 +620,7 @@ describe('React Router development runtime controller', () => { it('disposes the current session through the supported close hook', async () => { const { callbacks, controller, server } = createHarness(); - callbacks.start({ server }); + await callbacks.start({ server }); const loadBuild = controller.createBuildLoader(); await callbacks.close(); @@ -634,7 +635,7 @@ describe('React Router development runtime controller', () => { const { callbacks, controller, createServer, server } = createHarness(); const oldWeb = createCompiler('web'); const oldNode = createCompiler('node'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, }); @@ -668,7 +669,7 @@ describe('React Router development runtime controller', () => { const { callbacks, controller, createServer, server } = createHarness(); const oldWeb = createCompiler('web'); const oldNode = createCompiler('node'); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [oldWeb.compiler, oldNode.compiler] }, }); @@ -834,7 +835,7 @@ describe('React Router development runtime controller', () => { const node = createCompiler('node'); let build = createBuild('base'); loadBundle.mockImplementation(() => build); - callbacks.start({ server }); + await callbacks.start({ server }); callbacks.created({ compiler: { compilers: [web.compiler, node.compiler] }, }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 3e775c5..d7d383b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,6 +3,21 @@ import { describe, expect, it, rstest } from '@rstest/core'; import * as fs from 'node:fs'; import { pluginReactRouter } from '../src'; +const captureEnv = (keys: string[]) => { + const previousValues = new Map( + keys.map(key => [key, process.env[key]] as const) + ); + return () => { + for (const [key, value] of previousValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +}; + describe('pluginReactRouter', () => { it('should configure basic plugin options', async () => { const rsbuild = await createStubRsbuild({ @@ -194,6 +209,10 @@ describe('pluginReactRouter', () => { }); it('reduces file size reporting overhead for medium split route builds by default', async () => { + const restoreEnv = captureEnv([ + 'RR_TEST_SPLIT_ROUTE_MODULES', + 'RR_TEST_ROUTE_COUNT', + ]); process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; process.env.RR_TEST_ROUTE_COUNT = '256'; const readFileSync = rstest @@ -215,12 +234,12 @@ describe('pluginReactRouter', () => { }); } finally { readFileSync.mockRestore(); - delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; - delete process.env.RR_TEST_ROUTE_COUNT; + restoreEnv(); } }); it('reduces file size reporting overhead for medium route builds by default', async () => { + const restoreEnv = captureEnv(['RR_TEST_ROUTE_COUNT']); process.env.RR_TEST_ROUTE_COUNT = '256'; const readFileSync = rstest .spyOn(fs, 'readFileSync') @@ -241,11 +260,15 @@ describe('pluginReactRouter', () => { }); } finally { readFileSync.mockRestore(); - delete process.env.RR_TEST_ROUTE_COUNT; + restoreEnv(); } }); it('keeps explicit object file size reporting config for large split route builds', async () => { + const restoreEnv = captureEnv([ + 'RR_TEST_SPLIT_ROUTE_MODULES', + 'RR_TEST_ROUTE_COUNT', + ]); process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; process.env.RR_TEST_ROUTE_COUNT = '1024'; const readFileSync = rstest @@ -273,8 +296,7 @@ describe('pluginReactRouter', () => { }); } finally { readFileSync.mockRestore(); - delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; - delete process.env.RR_TEST_ROUTE_COUNT; + restoreEnv(); } }); @@ -293,10 +315,20 @@ describe('pluginReactRouter', () => { ]); const config = await rsbuild.unwrapConfig(); - expect(config.dev.lazyCompilation).toEqual({ + expect(config.dev.lazyCompilation).toMatchObject({ entries: true, imports: true, }); + expect( + config.dev.lazyCompilation.test({ + resource: '/project/app/root.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + config.dev.lazyCompilation.test({ + resource: '/project/app/components/card.tsx', + }) + ).toBe(true); }); it('should allow lazy compilation to be enabled with a boolean', async () => { @@ -307,7 +339,52 @@ describe('pluginReactRouter', () => { rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: true })]); const config = await rsbuild.unwrapConfig(); - expect(config.dev.lazyCompilation).toBe(true); + expect(config.dev.lazyCompilation).toMatchObject({ + entries: true, + imports: true, + }); + expect( + config.dev.lazyCompilation.test({ + resource: `${process.cwd()}/app/entry.client.tsx`, + }) + ).toBe(false); + }); + + it('guards direct Rsbuild lazy compilation config for React Router hydration entries', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: { + dev: { + lazyCompilation: { + entries: true, + imports: false, + test: /app/, + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toMatchObject({ + entries: true, + imports: false, + }); + expect( + config.dev.lazyCompilation.test({ + resource: '/project/app/routes/home.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + config.dev.lazyCompilation.test({ + resource: '/project/app/components/card.tsx', + }) + ).toBe(true); + expect( + config.dev.lazyCompilation.test({ + resource: '/project/vendor/react.tsx', + }) + ).toBe(false); }); it('should allow lazy compilation to be disabled', async () => { diff --git a/tests/lazy-compilation.test.ts b/tests/lazy-compilation.test.ts new file mode 100644 index 0000000..699bc3d --- /dev/null +++ b/tests/lazy-compilation.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from '@rstest/core'; +import { guardReactRouterLazyCompilation } from '../src/lazy-compilation'; + +describe('guardReactRouterLazyCompilation', () => { + const entryClientPath = '/project/app/entry.client.tsx'; + + it('leaves disabled and unspecified lazy compilation unchanged', () => { + expect( + guardReactRouterLazyCompilation({ + lazyCompilation: undefined, + entryClientPath, + }) + ).toBeUndefined(); + expect( + guardReactRouterLazyCompilation({ + lazyCompilation: false, + entryClientPath, + }) + ).toBe(false); + }); + + it('keeps boolean lazy compilation enabled while guarding hydration modules', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: true, + entryClientPath, + }); + + expect(guarded).toMatchObject({ + entries: true, + imports: true, + }); + expect( + guarded?.test?.({ + resource: `${entryClientPath}!lazy-compilation-proxy`, + }) + ).toBe(false); + expect( + guarded?.test?.({ + resource: '/project/app/components/card.tsx', + }) + ).toBe(true); + }); + + it('preserves user tests for non-React Router hydration modules', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: { + entries: true, + imports: false, + test: /app/g, + }, + entryClientPath, + }); + + expect(guarded).toMatchObject({ + entries: true, + imports: false, + }); + expect( + guarded?.test?.({ + resource: '/project/app/root.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + guarded?.test?.({ + resource: '/project/app/components/card.tsx', + }) + ).toBe(true); + expect( + guarded?.test?.({ + resource: '/project/vendor/react.tsx', + }) + ).toBe(false); + }); + + it('guards all React Router hydration-critical module shapes', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: { + entries: true, + imports: true, + }, + entryClientPath, + }); + + expect( + guarded?.test?.({ + request: 'virtual/react-router/browser-manifest', + }) + ).toBe(false); + expect( + guarded?.test?.({ + identifier: () => + '/project/app/routes/home.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + guarded?.test?.({ + nameForCondition: () => + '/project/app/routes/home.tsx?react-router-route', + }) + ).toBe(false); + }); +}); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 05220c1..0544dda 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -1,8 +1,14 @@ +import { createHash } from 'node:crypto'; 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'; +import { registerModifyBrowserManifestAssets } from '../src/modify-browser-manifest'; + +const BROWSER_MANIFEST_PATH = + 'static/js/virtual/react-router/browser-manifest.js'; +const PLACEHOLDER_MANIFEST_SOURCE = + 'window.__reactRouterManifest="PLACEHOLDER";'; const createTempApp = () => { const root = mkdtempSync(join(tmpdir(), 'rr-modify-manifest-')); @@ -24,38 +30,156 @@ const createAsset = (source: string) => ({ size: () => source.length, }); +class RawSource { + constructor(private readonly value: string) {} + source() { + return this.value; + } + size() { + return this.value.length; + } +} + +type Asset = ReturnType; +type ProcessAssetsContext = { + assets: Record; + compilation: unknown; +}; +type ProcessAssetsDescriptor = { + stage: string; + environments?: string[]; +}; +type ProcessAssetsHandler = ( + context: ProcessAssetsContext & { sources: { RawSource: typeof RawSource } } +) => Promise | void; +type ProcessAssetsRegistration = { + descriptor: ProcessAssetsDescriptor; + handler: ProcessAssetsHandler; +}; + +const createProcessAssetsHarness = () => { + const registrations: ProcessAssetsRegistration[] = []; + + return { + api: { + processAssets( + processAssetsDescriptor: ProcessAssetsDescriptor, + processAssetsHandler: ProcessAssetsHandler + ) { + registrations.push({ + descriptor: processAssetsDescriptor, + handler: processAssetsHandler, + }); + }, + }, + getDescriptor: () => registrations[0]?.descriptor, + getDescriptors: () => + registrations.map(registration => registration.descriptor), + run(context: ProcessAssetsContext) { + const registration = registrations[0]; + expect(registration).toBeDefined(); + return registration!.handler({ ...context, sources: { RawSource } }); + }, + runStage(stage: string, context: ProcessAssetsContext) { + const registration = registrations.find( + registration => registration.descriptor.stage === stage + ); + expect(registration).toBeDefined(); + return registration!.handler({ ...context, sources: { RawSource } }); + }, + }; +}; + +const createCompilation = ( + namedChunks: Array<[string, unknown]>, + assets: Record = {} +) => ({ + namedChunks: new Map(namedChunks), + assets, + getAsset(name: string) { + const asset = assets[name]; + return asset ? { name, source: asset } : undefined; + }, + updateAsset(name: string, source: Asset) { + assets[name] = source; + }, + emitAsset(name: string, source: Asset) { + assets[name] = source; + }, + getAssets() { + return Object.entries(assets).map(([name, source]) => ({ name, source })); + }, +}); + +const rootRoute = { id: 'root', file: 'root.tsx', path: '' }; + +const createRoutesWithPage = () => ({ + root: rootRoute, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, +}); + +const createBrowserManifestAssets = () => ({ + [BROWSER_MANIFEST_PATH]: createAsset(PLACEHOLDER_MANIFEST_SOURCE), +}); + +const createSriHash = (source: string) => + `sha384-${createHash('sha384').update(source).digest('base64')}`; + describe('modify browser manifest plugin', () => { + it('registers browser manifest mutation with Rsbuild processAssets', async () => { + const { root, appDir } = createTempApp(); + const harness = createProcessAssetsHarness(); + const assets = createBrowserManifestAssets(); + const compilation = createCompilation( + [ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['root', { files: new Set(['static/js/root.js']) }], + ], + assets + ); + + try { + registerModifyBrowserManifestAssets( + harness.api as never, + { root: rootRoute }, + {}, + appDir + ); + + expect(harness.getDescriptor()).toEqual({ + stage: 'additions', + environments: ['web'], + }); + await harness.run({ assets, compilation }); + + expect(assets[BROWSER_MANIFEST_PATH].source()).toContain('routes'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('reports the exact compilation that produced the manifest', async () => { const { root, appDir } = createTempApp(); - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - }; - let emit: ((compilation: unknown) => Promise) | undefined; - let reportedCompilation: unknown; - const compiler = { - hooks: { - emit: { - tapPromise(_name: string, callback: typeof emit) { - emit = callback; - }, - }, - }, - }; - const compilation = { - namedChunks: new Map([ + const harness = createProcessAssetsHarness(); + const assets = createBrowserManifestAssets(); + const compilation = createCompilation( + [ ['entry.client', { files: new Set(['static/js/entry.client.js']) }], ['root', { files: new Set(['static/js/root.js']) }], - ]), - assets: { - 'static/js/virtual/react-router/browser-manifest.js': createAsset( - 'window.__reactRouterManifest="PLACEHOLDER";' - ), - }, - }; + ], + assets + ); + let reportedCompilation: unknown; try { - createModifyBrowserManifestPlugin( - routes, + registerModifyBrowserManifestAssets( + harness.api as never, + { root: rootRoute }, {}, appDir, '/', @@ -65,10 +189,9 @@ describe('modify browser manifest plugin', () => { reportedCompilation = context.compilation; }, } - ).apply(compiler as never); + ); - expect(emit).toBeDefined(); - await emit(compilation); + await harness.run({ assets, compilation }); expect(reportedCompilation).toBe(compilation); } finally { @@ -76,39 +199,85 @@ describe('modify browser manifest plugin', () => { } }); - it('rejects the promise hook when build route analysis fails', async () => { + it('hashes build SRI after later asset stages mutate JavaScript', async () => { const { root, appDir } = createTempApp(); - writeFileSync(join(appDir, 'routes/page.tsx'), 'export const = broken;'); - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - 'routes/page': { - id: 'routes/page', - parentId: 'root', - file: 'routes/page.tsx', - path: 'page', - }, + const harness = createProcessAssetsHarness(); + const originalEntrySource = 'console.log("before optimize");'; + const optimizedEntrySource = 'console.log("after optimize");'; + const assets = { + ...createBrowserManifestAssets(), + 'static/js/entry.client.js': createAsset(originalEntrySource), + 'static/js/root.js': createAsset('console.log("root");'), }; - let emit: ((compilation: unknown) => Promise) | undefined; - const compiler = { - hooks: { - emit: { - tapPromise(_name: string, callback: typeof emit) { - emit = callback; + const compilation = createCompilation( + [ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['root', { files: new Set(['static/js/root.js']) }], + ], + assets + ); + let reportedSri: Record | undefined; + + try { + registerModifyBrowserManifestAssets( + harness.api as never, + { root: rootRoute }, + {}, + appDir, + '/', + { isBuild: true }, + { + future: { unstable_subResourceIntegrity: true }, + onManifest(_manifest, sri) { + reportedSri = sri; }, - }, - }, - }; + } + ); + + expect(harness.getDescriptors()).toEqual([ + { stage: 'additions', environments: ['web'] }, + { stage: 'report', environments: ['web'] }, + ]); + + await harness.runStage('additions', { assets, compilation }); + expect(reportedSri).toBeUndefined(); + + compilation.updateAsset( + 'static/js/entry.client.js', + createAsset(optimizedEntrySource) + ); + await harness.runStage('report', { assets, compilation }); + + expect(reportedSri?.['/static/js/entry.client.js']).toBe( + createSriHash(optimizedEntrySource) + ); + expect(reportedSri?.['/static/js/entry.client.js']).not.toBe( + createSriHash(originalEntrySource) + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects the promise hook when build route analysis fails', async () => { + const { root, appDir } = createTempApp(); + const harness = createProcessAssetsHarness(); + writeFileSync(join(appDir, 'routes/page.tsx'), 'export const = broken;'); try { - createModifyBrowserManifestPlugin(routes, {}, appDir, '/', { - isBuild: true, - }).apply(compiler as never); + registerModifyBrowserManifestAssets( + harness.api as never, + createRoutesWithPage(), + {}, + appDir, + '/', + { isBuild: true } + ); - expect(emit).toBeDefined(); await expect( - emit({ - namedChunks: new Map(), + harness.run({ assets: {}, + compilation: createCompilation([]), }) ).rejects.toThrow(); } finally { @@ -118,51 +287,31 @@ 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) => Promise) | undefined; - const compiler = { - hooks: { - emit: { - tapPromise(_name: string, callback: typeof emit) { - emit = callback; - }, - }, + const harness = createProcessAssetsHarness(); + const assets = createBrowserManifestAssets(); + const ignoredChunk = {}; + Object.defineProperty(ignoredChunk, 'files', { + get() { + throw new Error('ignored chunk files should not be read'); }, - }; + }); try { - createModifyBrowserManifestPlugin(routes, {}, appDir).apply( - compiler as never + registerModifyBrowserManifestAssets( + harness.api as never, + createRoutesWithPage(), + {}, + appDir ); - const ignoredChunk = {}; - Object.defineProperty(ignoredChunk, 'files', { - get() { - throw new Error('ignored chunk files should not be read'); - }, - }); - - expect(emit).toBeDefined(); - await emit({ - namedChunks: new Map([ + await harness.run({ + assets, + compilation: createCompilation([ ['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";' - ), - }, }); } finally { rmSync(root, { recursive: true, force: true }); @@ -171,29 +320,19 @@ describe('modify browser manifest plugin', () => { 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) => Promise) | undefined; - const compiler = { - hooks: { - emit: { - tapPromise(_name: string, callback: typeof emit) { - emit = callback; - }, - }, + const harness = createProcessAssetsHarness(); + const assets = createBrowserManifestAssets(); + const theoreticalSplitChunk = {}; + Object.defineProperty(theoreticalSplitChunk, 'files', { + get() { + throw new Error('theoretical split chunk files should not be read'); }, - }; + }); try { - createModifyBrowserManifestPlugin( - routes, + registerModifyBrowserManifestAssets( + harness.api as never, + createRoutesWithPage(), {}, appDir, '/', @@ -205,28 +344,16 @@ describe('modify browser manifest plugin', () => { { 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'); - }, - }); + ); - expect(emit).toBeDefined(); - await emit({ - namedChunks: new Map([ + await harness.run({ + assets, + compilation: createCompilation([ ['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";' - ), - }, }); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/tests/performance.test.ts b/tests/performance.test.ts index 9738cef..ab9179d 100644 --- a/tests/performance.test.ts +++ b/tests/performance.test.ts @@ -239,4 +239,38 @@ describe('React Router performance profiler', () => { performance.now = originalNow; } }); + + it('returns a rejected promise for synchronous record failures when disabled', async () => { + const profiler = createReactRouterPerformanceProfiler({ + enabled: false, + log: () => {}, + }); + + await expect( + profiler.record('web', 'route:module', 'app/routes/a.tsx', () => { + throw new Error('sync failure'); + }) + ).rejects.toThrow('sync failure'); + }); + + it('flushes timings recorded before an environment is known', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + await profiler.record( + undefined, + 'route:module', + 'app/routes/a.tsx', + async () => 'route-module' + ); + profiler.flush(undefined); + + expect(logs).toHaveLength(1); + const report = parsePerformanceReport(logs[0]); + expect(report.environment).toBe('unknown'); + expect(report.operations['route:module'].count).toBe(1); + }); }); diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 9915a9c..31e0b1a 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -251,6 +251,17 @@ describe('plugin-utils', () => { expect(result).not.toContain('const _ErrorBoundary'); }); + it('does not wrap erased default interface exports', () => { + const result = transformRouteCode(` + export default interface Route { + value: string; + } + `); + + expect(result).not.toContain('withComponentProps'); + expect(result).toContain('export default interface Route'); + }); + it('avoids top-level generated helper name collisions', () => { const result = transformRouteCode(` const _withComponentProps = 'reserved'; @@ -285,5 +296,17 @@ describe('plugin-utils', () => { expect(result).toContain('export default _withComponentProps'); expect(result).not.toContain('_withComponentProps2'); }); + + it('avoids top-level generated helper name collisions with enums', () => { + const result = transformRouteCode(` + enum _withComponentProps { + reserved = 'reserved' + } + export default function Route() { return null; } + `); + + expect(result).toContain('withComponentProps as _withComponentProps2'); + expect(result).toContain('export default _withComponentProps2(Route)'); + }); }); }); diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 17bc814..2e827c6 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from '@rstest/core'; import { + createPrerenderRoutes, getPrerenderConcurrency, getStaticPrerenderPaths, + getSsrFalsePrerenderExportErrors, + normalizePrerenderMatchPath, resolvePrerenderPaths, + withBuildRequest, } from '../src/prerender'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -95,4 +99,202 @@ describe('prerender helpers', () => { expect(getPrerenderConcurrency({ paths: ['/'] }, 3)).toBe(1); expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(1); }); + + it('creates React Router match routes from a route manifest', () => { + expect( + createPrerenderRoutes({ + root: { id: 'root', file: 'root.tsx', path: '' }, + layout: { + id: 'layout', + parentId: 'root', + file: 'routes/layout.tsx', + path: 'dashboard', + }, + index: { + id: 'index', + parentId: 'layout', + file: 'routes/index.tsx', + index: true, + }, + }) + ).toEqual([ + { + id: 'root', + path: '', + children: [ + { + id: 'layout', + path: 'dashboard', + children: [{ id: 'index', path: undefined, index: true }], + }, + ], + }, + ]); + }); + + it('normalizes prerender paths for React Router matching', () => { + expect(normalizePrerenderMatchPath('/')).toBe('/'); + expect(normalizePrerenderMatchPath('about')).toBe('/about/'); + expect(normalizePrerenderMatchPath('/about')).toBe('/about/'); + }); + + it('aborts build request signals after the handler settles', async () => { + let signal: AbortSignal | undefined; + + const result = await withBuildRequest( + 'http://localhost/about', + { + headers: { + 'x-test': 'yes', + }, + }, + async request => { + signal = request.signal; + expect(request.headers.get('x-test')).toBe('yes'); + expect(signal.aborted).toBe(false); + return 'handled'; + } + ); + + expect(result).toBe('handled'); + expect(signal?.aborted).toBe(true); + }); + + it('returns no ssr:false prerender export errors for valid prerendered routes', () => { + const manifestRoutes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + dashboard: { + id: 'dashboard', + parentId: 'root', + file: 'routes/dashboard.tsx', + path: 'dashboard', + hasLoader: true, + hasClientLoader: true, + }, + }; + + expect( + getSsrFalsePrerenderExportErrors({ + routes: manifestRoutes, + manifestRoutes, + routeExports: { + dashboard: ['clientLoader'], + }, + prerenderPaths: ['/dashboard'], + }) + ).toEqual([]); + }); + + it('rejects ssr:false prerender paths that do not match routes', () => { + expect(() => + getSsrFalsePrerenderExportErrors({ + routes: { + root: { id: 'root', file: 'root.tsx', path: '' }, + }, + manifestRoutes: { + root: { id: 'root', file: 'root.tsx', path: '' }, + }, + routeExports: {}, + prerenderPaths: ['/missing'], + }) + ).toThrow('Unable to prerender path because it does not match any routes'); + }); + + it('reports invalid ssr:false prerender action and headers exports', () => { + const manifestRoutes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + dashboard: { + id: 'dashboard', + parentId: 'root', + file: 'routes/dashboard.tsx', + path: 'dashboard', + }, + }; + + expect( + getSsrFalsePrerenderExportErrors({ + routes: manifestRoutes, + manifestRoutes, + routeExports: { + dashboard: ['action', 'headers'], + }, + prerenderPaths: ['/dashboard'], + }) + ).toEqual([ + expect.stringContaining( + '`dashboard` when pre-rendering with `ssr:false`: `headers`, `action`' + ), + ]); + }); + + it('reports loader exports on routes outside the ssr:false prerender set', () => { + const manifestRoutes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + dashboard: { + id: 'dashboard', + parentId: 'root', + file: 'routes/dashboard.tsx', + path: 'dashboard', + hasLoader: true, + }, + reports: { + id: 'reports', + parentId: 'dashboard', + file: 'routes/reports.tsx', + path: 'reports', + }, + about: { + id: 'about', + parentId: 'root', + file: 'routes/about.tsx', + path: 'about', + }, + }; + + expect( + getSsrFalsePrerenderExportErrors({ + routes: manifestRoutes, + manifestRoutes, + routeExports: { + reports: ['loader'], + }, + prerenderPaths: ['/about'], + }) + ).toEqual([ + expect.stringContaining('`reports` when pre-rendering'), + expect.stringContaining('`dashboard` when pre-rendering'), + ]); + }); + + it('reports root loaders for unprerendered ssr:false descendants', () => { + const manifestRoutes = { + root: { + id: 'root', + file: 'root.tsx', + path: '', + hasLoader: true, + }, + dashboard: { + id: 'dashboard', + parentId: 'root', + file: 'routes/dashboard.tsx', + path: 'dashboard', + }, + about: { + id: 'about', + parentId: 'root', + file: 'routes/about.tsx', + path: 'about', + }, + }; + + expect( + getSsrFalsePrerenderExportErrors({ + routes: manifestRoutes, + manifestRoutes, + routeExports: {}, + prerenderPaths: ['/about'], + }) + ).toEqual([expect.stringContaining('`root` when pre-rendering')]); + }); }); diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index 643edca..da7f237 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -270,6 +270,19 @@ describe('route chunks', () => { 'HydrateFallback', ]); }); + + it('does not split client exports away from top-level side effects', async () => { + const code = ` + import './polyfill'; + initialize(); + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectNoRouteChunks(result, ['clientAction', 'default']); + }); }); describe('generate route chunk code', () => { @@ -416,6 +429,14 @@ describe('route chunks', () => { expect(moduleId).toBe('/app/routes/r.tsx?route-chunk=clientAction'); expect(isRouteChunkModuleId(moduleId)).toBe(true); expect(getRouteChunkNameFromModuleId(moduleId)).toBe('clientAction'); + expect( + isRouteChunkModuleId('/app/routes/r.tsx?route-chunk=clientAction&foo=1') + ).toBe(true); + expect( + getRouteChunkNameFromModuleId( + '/app/routes/r.tsx?route-chunk=clientAction&foo=1' + ) + ).toBe('clientAction'); expect(getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=main')).toBe( 'main' ); diff --git a/tests/server-build-plan.test.ts b/tests/server-build-plan.test.ts new file mode 100644 index 0000000..9d6b39c --- /dev/null +++ b/tests/server-build-plan.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from '@rstest/core'; +import { + createReactRouterNodeEntries, + createReactRouterServerBuildPlan, +} from '../src/server-build-plan'; + +describe('React Router server build plan', () => { + it('creates a default-only plan when no server bundles are configured', () => { + expect( + createReactRouterServerBuildPlan({ + routesByServerBundleId: {}, + serverBuildFile: undefined, + defaultEntryName: 'static/js/app', + }) + ).toEqual({ + defaultEntryName: 'static/js/app', + entryNames: ['static/js/app'], + serverBundleEntries: [], + }); + }); + + it('creates deterministic bundle entries from the configured server build file', () => { + expect( + createReactRouterServerBuildPlan({ + routesByServerBundleId: { + admin: { + root: { id: 'root', file: 'root.tsx', path: '' }, + }, + empty: {}, + shop: { + root: { id: 'root', file: 'root.tsx', path: '' }, + }, + }, + serverBuildFile: 'server-entry.js', + defaultEntryName: 'static/js/app', + }) + ).toEqual({ + defaultEntryName: 'static/js/app', + entryNames: [ + 'static/js/app', + 'admin/server-entry', + 'shop/server-entry', + ], + serverBundleEntries: [ + { bundleId: 'admin', entryName: 'admin/server-entry' }, + { bundleId: 'shop', entryName: 'shop/server-entry' }, + ], + }); + }); + + it('rejects bundle entries that collide with reserved node entries', () => { + expect(() => + createReactRouterServerBuildPlan({ + routesByServerBundleId: { + 'static/js': { + root: { id: 'root', file: 'root.tsx', path: '' }, + }, + }, + serverBuildFile: 'app.js', + defaultEntryName: 'static/js/react-router-server-build', + }) + ).toThrow('conflicts with a reserved node entry'); + }); +}); + +describe('React Router node entries', () => { + const serverBundleEntries = [ + { bundleId: 'admin', entryName: 'admin/index' }, + ]; + + it('uses the generated server build as the app entry without a custom server', () => { + expect( + createReactRouterNodeEntries({ + hasServerApp: false, + isBuild: false, + serverAppPath: '/project/server/index.ts', + entryServerPath: '/project/app/entry.server.tsx', + defaultEntryName: 'static/js/app', + serverBundleEntries, + }) + ).toEqual({ + 'static/js/app': 'virtual/react-router/server-build', + 'static/js/entry.server': '/project/app/entry.server.tsx', + 'admin/index': 'virtual/react-router/server-build-admin', + }); + }); + + it('adds a private generated server build only for custom-server development', () => { + expect( + createReactRouterNodeEntries({ + hasServerApp: true, + isBuild: false, + serverAppPath: '/project/server/index.ts', + entryServerPath: '/project/app/entry.server.tsx', + defaultEntryName: 'static/js/react-router-server-build', + serverBundleEntries, + }) + ).toEqual({ + 'static/js/app': '/project/server/index.ts', + 'static/js/react-router-server-build': + 'virtual/react-router/server-build', + 'static/js/entry.server': '/project/app/entry.server.tsx', + 'admin/index': 'virtual/react-router/server-build-admin', + }); + }); + + it('omits the private generated server build during custom-server production builds', () => { + expect( + createReactRouterNodeEntries({ + hasServerApp: true, + isBuild: true, + serverAppPath: '/project/server/index.ts', + entryServerPath: '/project/app/entry.server.tsx', + defaultEntryName: 'static/js/react-router-server-build', + serverBundleEntries, + }) + ).toEqual({ + 'static/js/app': '/project/server/index.ts', + 'static/js/entry.server': '/project/app/entry.server.tsx', + 'admin/index': 'virtual/react-router/server-build-admin', + }); + }); +}); diff --git a/tests/server-utils.test.ts b/tests/server-utils.test.ts index 47149c4..5547ffe 100644 --- a/tests/server-utils.test.ts +++ b/tests/server-utils.test.ts @@ -49,6 +49,17 @@ describe('resolveReactRouterServerBuild', () => { ).resolves.toMatchObject({ assets: { version: 'commonjs' } }); }); + it('prefers module.exports when a CommonJS namespace default is not a server build', async () => { + const build = createBuild('module-exports'); + + await expect( + resolveReactRouterServerBuild({ + default: { routes: {} }, + 'module.exports': build, + }) + ).resolves.toMatchObject({ assets: { version: 'module-exports' } }); + }); + it('resolves recognized asynchronous build exports', async () => { const build = createBuild('async'); From 021b411b9befac4e1046317c1f523d2cc6e62cf5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:17:37 +0200 Subject: [PATCH 47/64] Refactor plugin architecture boundaries (#52) --- src/build-output-transforms.ts | 265 ++++++++++++ src/export-utils.ts | 8 +- src/index.ts | 754 +++------------------------------ src/prerender-build.ts | 623 +++++++++++++++++++++++++++ src/route-ast.ts | 8 +- src/route-export-pruning.ts | 214 +++++----- src/route-export-resolution.ts | 224 ++++++---- src/route-transform-tasks.ts | 3 +- src/typegen.ts | 49 +++ tests/client-modules.test.ts | 60 +++ tests/remove-exports.test.ts | 69 +++ 11 files changed, 1374 insertions(+), 903 deletions(-) create mode 100644 src/build-output-transforms.ts create mode 100644 src/prerender-build.ts create mode 100644 src/typegen.ts diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts new file mode 100644 index 0000000..8adbf2d --- /dev/null +++ b/src/build-output-transforms.ts @@ -0,0 +1,265 @@ +import type { RsbuildPluginAPI } from '@rsbuild/core'; +import jsesc from 'jsesc'; +import { relative } from 'pathe'; +import { PLUGIN_NAME } from './constants.js'; +import { + getReactRouterManifestForDev, + type ReactRouterManifestStats, +} from './manifest.js'; +import type { RouteTransformExecutor } from './parallel-route-transforms.js'; +import type { ReactRouterPerformanceProfiler } from './performance.js'; +import { createBundlerRouteExportResolver } from './route-export-resolution.js'; +import type { RouteChunkConfig } from './route-chunks.js'; +import type { PluginOptions, Route } from './types.js'; +import { isSourceMapEnabled } from './warnings/warn-on-client-source-maps.js'; + +type ReactRouterManifest = Awaited< + ReturnType +>; + +type RegisterBuildOutputTransformsOptions = { + api: RsbuildPluginAPI; + resolvedServerOutput: 'module' | 'commonjs'; + performanceProfiler: ReactRouterPerformanceProfiler; + getLatestServerManifest: () => ReactRouterManifest | null; + getLatestServerManifestByBundleId: ( + bundleId: string + ) => ReactRouterManifest | undefined; + routes: Record; + pluginOptions: PluginOptions; + getClientStats: () => ReactRouterManifestStats | undefined; + appDirectory: string; + getAssetPrefix: () => string; + routeChunkOptions: Parameters[5]; + routeTransformExecutor: RouteTransformExecutor; + routeByFilePath: Map; + routeChunkConfig: RouteChunkConfig; + isBuild: boolean; + splitRouteModules: boolean; + ssr: boolean; + isSpaMode: boolean; + rootRoutePath: string; +}; + +export const registerBuildOutputTransforms = ({ + api, + resolvedServerOutput, + performanceProfiler, + getLatestServerManifest, + getLatestServerManifestByBundleId, + routes, + pluginOptions, + getClientStats, + appDirectory, + getAssetPrefix, + routeChunkOptions, + routeTransformExecutor, + routeByFilePath, + routeChunkConfig, + isBuild, + splitRouteModules, + ssr, + isSpaMode, + rootRoutePath, +}: RegisterBuildOutputTransformsOptions): void => { + api.processAssets( + { stage: 'additional', targets: ['node'] }, + ({ sources, compilation }) => { + const packageJsonPath = 'package.json'; + const source = new sources.RawSource( + `{"type": "${resolvedServerOutput}"}` + ); + + if (compilation.getAsset(packageJsonPath)) { + compilation.updateAsset(packageJsonPath, source); + } else { + compilation.emitAsset(packageJsonPath, source); + } + } + ); + + api.transform( + { + test: /virtual\/react-router\/(browser|server)-manifest/, + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'manifest:transform', + args.resource, + async () => { + 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 latestServerManifest = getLatestServerManifest(); + const manifest = + (latestServerManifest + ? ((bundleId && getLatestServerManifestByBundleId(bundleId)) ?? + latestServerManifest) + : null) ?? + (await getReactRouterManifestForDev( + routes, + pluginOptions, + getClientStats(), + appDirectory, + getAssetPrefix(), + routeChunkOptions + )); + return { + code: `export default ${jsesc(manifest, { es6: true })};`, + }; + } + ) + ); + + api.transform( + { + resourceQuery: /__react-router-build-client-route/, + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'route:client-entry', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeClientEntry', + code: args.code, + resourcePath: args.resourcePath, + environmentName: args.environment?.name, + isBuild, + routeChunkConfig, + }) + ) + ); + + api.transform( + { + resourceQuery: /route-chunk=/, + environments: ['web'], + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'route:chunk', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeChunk', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + isBuild, + routeChunkConfig, + }) + ) + ); + + 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, + }); + } + ) + ); + } + + api.transform( + { + test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/, + environments: ['web'], + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'module:server-only-guard', + args.resource, + async () => { + 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?)?$/, + environments: ['node'], + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'module:client-only-stub', + args.resource, + async () => { + return routeTransformExecutor.run({ + kind: 'clientOnlyStub', + code: args.code, + resourcePath: args.resourcePath, + resolveExportAllModule: + typeof args.resolve === 'function' + ? createBundlerRouteExportResolver(args.resolve) + : undefined, + }); + } + ) + ); + + api.transform( + { + resourceQuery: /\?react-router-route/, + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'route:module', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeModule', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + environmentName: args.environment.name, + sourceMaps: isSourceMapEnabled( + args.environment.config.output.sourceMap + ), + ssr, + isBuild, + isSpaMode, + rootRoutePath, + }) + ) + ); +}; diff --git a/src/export-utils.ts b/src/export-utils.ts index 667a5e9..6a4c526 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -4,7 +4,9 @@ import { setBoundedCacheEntry } from './bounded-cache.js'; import { getExportedName, getIdentifierNamesFromPattern, + getProgram, type AnyNode, + type ProgramNode, } from './route-ast.js'; type ExportInfo = { @@ -41,7 +43,7 @@ const cachePromiseOnReject = ( throw error; }); -const parseProgram = (code: string, resourcePath?: string) => { +const parseProgram = (code: string, resourcePath?: string): ProgramNode => { const result = parse(code, { sourceType: 'module', lang: resourcePath ? langFromPath(resourcePath) : 'tsx', @@ -52,7 +54,7 @@ const parseProgram = (code: string, resourcePath?: string) => { if (errors.length > 0) { throw new Error(errors.map(error => error.message).join('\n')); } - return result.program; + return getProgram(result); }; const isTypeOnlyExport = (node: AnyNode): boolean => @@ -62,7 +64,7 @@ const isTypeOnlyExport = (node: AnyNode): boolean => (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'TSInterfaceDeclaration'); -export const collectProgramExportNames = (program: AnyNode): string[] => { +export const collectProgramExportNames = (program: ProgramNode): string[] => { const exportNames = new Set(); for (const statement of program.body ?? []) { if (isTypeOnlyExport(statement)) { diff --git a/src/index.ts b/src/index.ts index 2f1b8d0..e96aa2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -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 { ResultPromise } from 'execa'; import { rspack, type RsbuildEntryDescription, @@ -12,8 +9,7 @@ import { type Rspack, } from '@rsbuild/core'; import { createJiti } from 'jiti'; -import jsesc from 'jsesc'; -import { dirname, relative, resolve } from 'pathe'; +import { relative, resolve } from 'pathe'; import { BUILD_CLIENT_ROUTE_QUERY_STRING, @@ -31,24 +27,15 @@ import type { PluginOptions } from './types.js'; import { generateServerBuild, resolveReactRouterServerBuild, - resolveServerBuildModule, } from './server-utils.js'; -import { - createPrerenderRoutes, - getPrerenderConcurrency, - getSsrFalsePrerenderExportErrors, - normalizePrerenderMatchPath, - resolvePrerenderPaths, - validatePrerenderConfig, - withBuildRequest, -} from './prerender.js'; +import { resolvePrerenderPaths, validatePrerenderConfig } from './prerender.js'; +import { runReactRouterPrerenderBuild } from './prerender-build.js'; import { resolveReactRouterConfig, type ResolvedReactRouterConfig, } from './react-router-config.js'; import { getReactRouterManifestForDev, - generateReactRouterManifestForDev, configRoutesToRouteManifest, configRoutesToRouteManifestEntries, createReactRouterManifestStats, @@ -56,7 +43,7 @@ import { type RouteManifestModuleExports, } from './manifest.js'; import { registerModifyBrowserManifestAssets } from './modify-browser-manifest.js'; -import { createRequestHandler, matchRoutes } from 'react-router'; +import { registerBuildOutputTransforms } from './build-output-transforms.js'; import { getRouteChunkEntryName, getRouteChunkModuleId, @@ -85,10 +72,7 @@ import { createReactRouterNodeEntries, createReactRouterServerBuildPlan, } from './server-build-plan.js'; -import { - isSourceMapEnabled, - warnOnClientSourceMaps, -} from './warnings/warn-on-client-source-maps.js'; +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 { @@ -97,12 +81,11 @@ import { } from './performance.js'; import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; +import { registerReactRouterTypegen } from './typegen.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; -const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); - type ModuleFederationPluginLike = { name?: string; _options?: { experiments?: { asyncStartup?: boolean } }; @@ -191,54 +174,7 @@ export const pluginReactRouter = ( warnOnClientSourceMaps(normalized, msg => api.logger.warn(msg), 'web'); }); - let typegenProcess: ResultPromise | undefined; - - // Run typegen on build/dev - api.onBeforeStartDevServer(async () => { - if (typegenProcess) { - return; - } - const { execa } = await import('execa'); - // Run typegen in background (non-blocking) for watch mode - const process = execa( - 'npx', - ['--yes', 'react-router', 'typegen', '--watch'], - { - stdio: 'inherit', - detached: false, - cleanup: true, - } - ); - typegenProcess = process; - // Don't await - let it run in the background - process - .catch(() => { - // Silently ignore errors when the process is killed on server shutdown - }) - .finally(() => { - if (typegenProcess === process) { - typegenProcess = undefined; - } - }); - }); - - api.onCloseDevServer(async () => { - const process = typegenProcess; - typegenProcess = undefined; - if (!process) { - return; - } - process.kill('SIGTERM'); - await process.catch(() => undefined); - }); - - api.onBeforeBuild(async () => { - const { execa } = await import('execa'); - // Run typegen synchronously before build - await execa('npx', ['--yes', 'react-router', 'typegen'], { - stdio: 'inherit', - }); - }); + registerReactRouterTypegen(api); const jiti = createJiti(process.cwd(), { moduleCache: false, @@ -694,431 +630,31 @@ export const pluginReactRouter = ( warn: message => api.logger.warn(message), } ); - const prerenderData = async ( - handler: (request: Request) => Promise, - prerenderPath: string, - onlyRoutes: string[] | null, - clientBuildDir: string, - requestInit?: RequestInit - ): Promise => { - let dataRequestPath: string; - if (future?.unstable_trailingSlashAwareDataRequests) { - if (prerenderPath.endsWith('/')) { - dataRequestPath = `${prerenderPath}_.data`; - } else { - dataRequestPath = `${prerenderPath}.data`; - } - } else { - dataRequestPath = - prerenderPath === '/' - ? '/_root.data' - : `${prerenderPath.replace(/\/$/, '')}.data`; - } - - const normalizedPath = `${basename}${dataRequestPath}`.replace( - /\/\/+/g, - '/' - ); - const url = new URL(`http://localhost${normalizedPath}`); - if (onlyRoutes?.length) { - url.searchParams.set('_routes', onlyRoutes.join(',')); - } - 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}` - ); - } - - 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 ( - handler: (request: Request) => Promise, - prerenderPath: string, - clientBuildDir: string, - requestInit?: RequestInit - ): Promise => { - const normalizedPath = `${basename}${prerenderPath}/`.replace( - /\/\/+/g, - '/' - ); - await withBuildRequest( - `http://localhost${normalizedPath}`, - 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} - - - - -\t - Redirecting from ${normalizedPath} to ${location} - - -`; - } 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 prerenderResourceRoute = async ( - handler: (request: Request) => Promise, - prerenderPath: string, - clientBuildDir: string, - requestInit?: RequestInit - ): Promise => { - const normalizedPath = `${basename}${prerenderPath}/` - .replace(/\/\/+/g, '/') - .replace(/\/$/g, ''); - await withBuildRequest( - `http://localhost${normalizedPath}`, - 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') - ); - } - 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 handleSpaMode = async ( - handler: (request: Request) => Promise, - build: any, - clientBuildDir: string - ): Promise => { - await withBuildRequest( - `http://localhost${basename}`, - { - headers: { - 'X-React-Router-SPA-Mode': 'yes', - }, - }, - 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 ``.' - ); - } - - 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 assertValidSsrFalsePrerenderExports = ( - manifestRoutes: Awaited< - ReturnType - >['routes'], - routeExports: RouteManifestModuleExports, - prerenderList: string[] - ) => { - const errors = getSsrFalsePrerenderExportErrors({ + api.onAfterBuild(async ({ environments }) => { + await runReactRouterPrerenderBuild({ + api, + hasWebEnvironment: Boolean(environments.web), + buildDirectory, + serverBuildFile, + ssr, + isPrerenderEnabled, + prerenderConfig, + prerenderPaths, + basename, + future, routes, - manifestRoutes, - routeExports, - prerenderPaths: prerenderList, + latestBrowserManifest, + latestBrowserManifestModuleExports, + clientStats, + pluginOptions, + appDirectory, + assetPrefix, + routeChunkOptions, + buildManifest, + resolvedConfigWithRoutes, + buildEnd, }); - if (errors.length > 0) { - api.logger.error(errors.join('\n')); - throw new Error( - 'Invalid route exports found when prerendering with `ssr:false`' - ); - } - }; - - api.onAfterBuild(async ({ environments }) => { - const webEnv = environments.web; - if (!webEnv) { - return; - } - - const serverBuildDir = resolve(buildDirectory, 'server'); - const defaultServerBuildFile = 'static/js/app.js'; - const configuredServerBuildFile = serverBuildFile || 'index.js'; - const configuredServerBuildPath = resolve( - serverBuildDir, - configuredServerBuildFile - ); - const defaultServerBuildPath = resolve( - serverBuildDir, - defaultServerBuildFile - ); - if ( - configuredServerBuildFile !== defaultServerBuildFile && - existsSync(defaultServerBuildPath) && - !existsSync(configuredServerBuildPath) - ) { - await mkdir(dirname(configuredServerBuildPath), { recursive: true }); - await fsExtra.copy(defaultServerBuildPath, configuredServerBuildPath); - } - const serverBuildPath = existsSync(configuredServerBuildPath) - ? configuredServerBuildPath - : defaultServerBuildPath; - const clientBuildDir = resolve(buildDirectory, 'client'); - - if (!existsSync(serverBuildPath)) { - console.warn( - `[${PLUGIN_NAME}] Server build not found at ${serverBuildPath}. ` + - 'Skipping prerendering.' - ); - return; - } - - await mkdir(clientBuildDir, { recursive: true }); - - if (!ssr || isPrerenderEnabled) { - process.env.IS_RR_BUILD_REQUEST = 'yes'; - const buildModule = await import( - pathToFileURL(serverBuildPath).toString() - ); - const build = await resolveServerBuildModule( - buildModule, - `Server build ${JSON.stringify(serverBuildPath)}` - ); - const requestHandler = createRequestHandler(build, 'production'); - - if (isPrerenderEnabled) { - if (!ssr) { - const generated = latestBrowserManifest - ? { - manifest: latestBrowserManifest, - moduleExportsByRouteId: latestBrowserManifestModuleExports, - } - : await generateReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - ); - assertValidSsrFalsePrerenderExports( - generated.manifest.routes, - generated.moduleExportsByRouteId, - prerenderPaths - ); - } - - const routeTree = createPrerenderRoutes(routes); - for (const path of prerenderPaths) { - const matches = matchRoutes( - routeTree, - normalizePrerenderMatchPath(path) - ); - if (!matches) { - throw new Error( - `Unable to prerender path because it does not match any routes: ${path}` - ); - } - } - - if (prerenderPaths.length > 0) { - api.logger.info( - `Prerender (html): ${prerenderPaths.length} path(s)...` - ); - } - - const buildRoutes = createPrerenderRoutes(build.routes); - const concurrency = getPrerenderConcurrency(prerenderConfig); - const pending = new Set>(); - const enqueue = async (path: string) => { - const matches = matchRoutes( - buildRoutes, - normalizePrerenderMatchPath(path) - ); - if (!matches) return; - - const leafRoute = matches[matches.length - 1]?.route as any; - const manifestRoute = leafRoute - ? build.routes?.[leafRoute.id]?.module - : null; - const isResourceRoute = - manifestRoute && - !manifestRoute.default && - !manifestRoute.ErrorBoundary; - - if (isResourceRoute) { - if (manifestRoute.loader) { - await prerenderData( - requestHandler, - path, - [leafRoute.id], - clientBuildDir - ); - await prerenderResourceRoute( - requestHandler, - path, - clientBuildDir - ); - } else { - api.logger.warn( - `⚠️ Skipping prerendering for resource route without a loader: ${leafRoute?.id}` - ); - } - } else { - const hasLoaders = matches.some(match => { - const routeId = match.route.id; - if (!routeId) { - return false; - } - return build.assets?.routes?.[routeId]?.hasLoader; - }); - let data: string | undefined; - if (hasLoaders) { - data = await prerenderData( - requestHandler, - path, - null, - clientBuildDir - ); - } - await prerenderRoute( - requestHandler, - path, - clientBuildDir, - data - ? { - headers: { - 'X-React-Router-Prerender-Data': encodeURI(data), - }, - } - : undefined - ); - } - }; - - for (const path of prerenderPaths) { - const task = enqueue(path); - pending.add(task); - task.finally(() => pending.delete(task)); - if (pending.size >= concurrency) { - await Promise.race(pending); - } - } - await Promise.all(pending); - } - - if (!ssr) { - await handleSpaMode(requestHandler, build, clientBuildDir); - } - } - - // Remove server output for SPA mode and when not using SSR - // This makes the build deployable as static assets - if (!ssr) { - await fsExtra.remove(serverBuildDir); - api.logger.info( - `[${PLUGIN_NAME}] Removed server build (static deployment)` - ); - } - - if (buildEnd) { - await buildEnd({ - buildManifest, - reactRouterConfig: resolvedConfigWithRoutes, - viteConfig: api.getNormalizedConfig(), - }); - } }); const allowedActionOriginsForBuild = @@ -1407,215 +943,27 @@ export const pluginReactRouter = ( } ); - api.processAssets( - { stage: 'additional', targets: ['node'] }, - ({ sources, compilation }) => { - const packageJsonPath = 'package.json'; - const source = new sources.RawSource( - `{"type": "${resolvedServerOutput}"}` - ); - - if (compilation.getAsset(packageJsonPath)) { - compilation.updateAsset(packageJsonPath, source); - } else { - compilation.emitAsset(packageJsonPath, source); - } - } - ); - - api.transform( - { - test: /virtual\/react-router\/(browser|server)-manifest/, - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'manifest:transform', - args.resource, - async () => { - 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 = - (latestServerManifest - ? ((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 => - performanceProfiler.record( - args.environment?.name, - 'route:client-entry', - args.resource, - async () => - routeTransformExecutor.run({ - kind: 'routeClientEntry', - code: args.code, - resourcePath: args.resourcePath, - environmentName: args.environment?.name, - isBuild, - routeChunkConfig, - }) - ) - ); - - api.transform( - { - resourceQuery: /route-chunk=/, - environments: ['web'], - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'route:chunk', - args.resource, - async () => - routeTransformExecutor.run({ - kind: 'routeChunk', - code: args.code, - resource: args.resource, - resourcePath: args.resourcePath, - isBuild, - routeChunkConfig, - }) - ) - ); - - 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, - }); - } - ) - ); - } - - api.transform( - { - test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/, - environments: ['web'], - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'module:server-only-guard', - args.resource, - async () => { - 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?)?$/, - environments: ['node'], - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'module:client-only-stub', - args.resource, - async () => { - const resolveExportAllModule = - typeof args.resolve === 'function' - ? (specifier: string, importerPath: string) => - new Promise(resolveModule => { - args.resolve( - dirname(importerPath), - specifier, - (error, path) => { - resolveModule(error || !path ? null : path); - } - ); - }) - : undefined; - - return routeTransformExecutor.run({ - kind: 'clientOnlyStub', - code: args.code, - resourcePath: args.resourcePath, - resolveExportAllModule, - }); - } - ) - ); - - api.transform( - { - resourceQuery: /\?react-router-route/, - }, - async args => - performanceProfiler.record( - args.environment?.name, - 'route:module', - args.resource, - async () => - routeTransformExecutor.run({ - kind: 'routeModule', - code: args.code, - resource: args.resource, - resourcePath: args.resourcePath, - environmentName: args.environment.name, - sourceMaps: isSourceMapEnabled( - args.environment.config.output.sourceMap - ), - ssr, - isBuild, - isSpaMode, - rootRoutePath, - }) - ) - ); + registerBuildOutputTransforms({ + api, + resolvedServerOutput, + performanceProfiler, + getLatestServerManifest: () => latestServerManifest, + getLatestServerManifestByBundleId: bundleId => + latestServerManifestsByBundleId[bundleId], + routes, + pluginOptions, + getClientStats: () => clientStats, + appDirectory, + getAssetPrefix: () => assetPrefix, + routeChunkOptions, + routeTransformExecutor, + routeByFilePath, + routeChunkConfig, + isBuild, + splitRouteModules: Boolean(splitRouteModules), + ssr, + isSpaMode, + rootRoutePath, + }); }, }); diff --git a/src/prerender-build.ts b/src/prerender-build.ts new file mode 100644 index 0000000..098782b --- /dev/null +++ b/src/prerender-build.ts @@ -0,0 +1,623 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import fsExtra from 'fs-extra'; +import type { RsbuildPluginAPI } from '@rsbuild/core'; +import { + createRequestHandler, + matchRoutes, + type ServerBuild, +} from 'react-router'; +import { dirname, relative, resolve } from 'pathe'; +import { PLUGIN_NAME } from './constants.js'; +import { getBuildManifest } from './build-manifest.js'; +import { + generateReactRouterManifestForDev, + getReactRouterManifestForDev, + type ReactRouterManifestStats, + type RouteManifestModuleExports, +} from './manifest.js'; +import { + createPrerenderRoutes, + getPrerenderConcurrency, + getSsrFalsePrerenderExportErrors, + normalizePrerenderMatchPath, + withBuildRequest, +} from './prerender.js'; +import type { + Config, + ResolvedReactRouterConfig, +} from './react-router-config.js'; +import { resolveServerBuildModule } from './server-utils.js'; +import type { PluginOptions, Route } from './types.js'; + +type ReactRouterManifest = Awaited< + ReturnType +>; + +type BuildRouteModule = { + loader?: unknown; + default?: unknown; + ErrorBoundary?: unknown; +}; + +type PrerenderServerBuild = ServerBuild & { + routes: Record; + assets?: { + routes?: Record; + }; + prerender?: string[]; +}; + +type PrerenderBuildApi = Pick< + RsbuildPluginAPI, + 'logger' | 'getNormalizedConfig' +>; + +type RunReactRouterPrerenderBuildOptions = { + api: PrerenderBuildApi; + hasWebEnvironment: boolean; + buildDirectory: string; + serverBuildFile?: string; + ssr: boolean; + isPrerenderEnabled: boolean; + prerenderConfig: Config['prerender']; + prerenderPaths: string[]; + basename: string; + future: ResolvedReactRouterConfig['future']; + routes: Record; + latestBrowserManifest: ReactRouterManifest | null; + latestBrowserManifestModuleExports: RouteManifestModuleExports; + clientStats: ReactRouterManifestStats | undefined; + pluginOptions: PluginOptions; + appDirectory: string; + assetPrefix: string; + routeChunkOptions: Parameters[5]; + buildManifest: Awaited>; + resolvedConfigWithRoutes: ResolvedReactRouterConfig; + buildEnd: Config['buildEnd']; +}; + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); + +const getServerBuildPath = async ({ + buildDirectory, + serverBuildFile, +}: { + buildDirectory: string; + serverBuildFile?: string; +}): Promise<{ serverBuildDir: string; serverBuildPath: string }> => { + const serverBuildDir = resolve(buildDirectory, 'server'); + const defaultServerBuildFile = 'static/js/app.js'; + const configuredServerBuildFile = serverBuildFile || 'index.js'; + const configuredServerBuildPath = resolve( + serverBuildDir, + configuredServerBuildFile + ); + const defaultServerBuildPath = resolve( + serverBuildDir, + defaultServerBuildFile + ); + + if ( + configuredServerBuildFile !== defaultServerBuildFile && + existsSync(defaultServerBuildPath) && + !existsSync(configuredServerBuildPath) + ) { + await mkdir(dirname(configuredServerBuildPath), { recursive: true }); + await fsExtra.copy(defaultServerBuildPath, configuredServerBuildPath); + } + + return { + serverBuildDir, + serverBuildPath: existsSync(configuredServerBuildPath) + ? configuredServerBuildPath + : defaultServerBuildPath, + }; +}; + +const createDataRequestPath = ( + prerenderPath: string, + trailingSlashAwareDataRequests: boolean +): string => { + if (trailingSlashAwareDataRequests) { + return prerenderPath.endsWith('/') + ? `${prerenderPath}_.data` + : `${prerenderPath}.data`; + } + + return prerenderPath === '/' + ? '/_root.data' + : `${prerenderPath.replace(/\/$/, '')}.data`; +}; + +const prerenderData = async ({ + handler, + prerenderPath, + onlyRoutes, + clientBuildDir, + basename, + trailingSlashAwareDataRequests, + api, + requestInit, +}: { + handler: (request: Request) => Promise; + prerenderPath: string; + onlyRoutes: string[] | null; + clientBuildDir: string; + basename: string; + trailingSlashAwareDataRequests: boolean; + api: PrerenderBuildApi; + requestInit?: RequestInit; +}): Promise => { + const dataRequestPath = createDataRequestPath( + prerenderPath, + trailingSlashAwareDataRequests + ); + const normalizedPath = `${basename}${dataRequestPath}`.replace(/\/\/+/g, '/'); + const url = new URL(`http://localhost${normalizedPath}`); + if (onlyRoutes?.length) { + url.searchParams.set('_routes', onlyRoutes.join(',')); + } + + 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}` + ); + } + + 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 ({ + handler, + prerenderPath, + clientBuildDir, + basename, + api, + requestInit, +}: { + handler: (request: Request) => Promise; + prerenderPath: string; + clientBuildDir: string; + basename: string; + api: PrerenderBuildApi; + requestInit?: RequestInit; +}): Promise => { + const normalizedPath = `${basename}${prerenderPath}/`.replace(/\/\/+/g, '/'); + await withBuildRequest( + `http://localhost${normalizedPath}`, + 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} + + + + +\t + Redirecting from ${normalizedPath} to ${location} + + +`; + } 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 prerenderResourceRoute = async ({ + handler, + prerenderPath, + clientBuildDir, + basename, + api, + requestInit, +}: { + handler: (request: Request) => Promise; + prerenderPath: string; + clientBuildDir: string; + basename: string; + api: PrerenderBuildApi; + requestInit?: RequestInit; +}): Promise => { + const normalizedPath = `${basename}${prerenderPath}/` + .replace(/\/\/+/g, '/') + .replace(/\/$/g, ''); + await withBuildRequest( + `http://localhost${normalizedPath}`, + 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') + ); + } + + 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 handleSpaMode = async ({ + handler, + build, + clientBuildDir, + basename, + api, +}: { + handler: (request: Request) => Promise; + build: PrerenderServerBuild; + clientBuildDir: string; + basename: string; + api: PrerenderBuildApi; +}): Promise => { + await withBuildRequest( + `http://localhost${basename}`, + { + headers: { + 'X-React-Router-SPA-Mode': 'yes', + }, + }, + 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 ``.' + ); + } + + 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 assertValidSsrFalsePrerenderExports = ({ + routes, + manifestRoutes, + routeExports, + prerenderPaths, + api, +}: { + routes: Record; + manifestRoutes: ReactRouterManifest['routes']; + routeExports: RouteManifestModuleExports; + prerenderPaths: string[]; + api: PrerenderBuildApi; +}) => { + const errors = getSsrFalsePrerenderExportErrors({ + routes, + manifestRoutes, + routeExports, + prerenderPaths, + }); + if (errors.length > 0) { + api.logger.error(errors.join('\n')); + throw new Error( + 'Invalid route exports found when prerendering with `ssr:false`' + ); + } +}; + +const validatePrerenderPathMatches = ( + routes: Record, + prerenderPaths: string[] +): void => { + const routeTree = createPrerenderRoutes(routes); + for (const path of prerenderPaths) { + const matches = matchRoutes(routeTree, normalizePrerenderMatchPath(path)); + if (!matches) { + throw new Error( + `Unable to prerender path because it does not match any routes: ${path}` + ); + } + } +}; + +const runPrerenderPaths = async ({ + build, + requestHandler, + clientBuildDir, + options, +}: { + build: PrerenderServerBuild; + requestHandler: (request: Request) => Promise; + clientBuildDir: string; + options: RunReactRouterPrerenderBuildOptions; +}): Promise => { + const { api, basename, future, prerenderConfig, prerenderPaths } = options; + const buildRoutes = createPrerenderRoutes(build.routes); + const concurrency = getPrerenderConcurrency(prerenderConfig); + const pending = new Set>(); + + const enqueue = async (path: string) => { + const matches = matchRoutes(buildRoutes, normalizePrerenderMatchPath(path)); + if (!matches) { + return; + } + + const leafRoute = matches[matches.length - 1]?.route; + const routeId = leafRoute?.id; + const manifestRoute = routeId ? build.routes?.[routeId]?.module : null; + const isResourceRoute = + manifestRoute && !manifestRoute.default && !manifestRoute.ErrorBoundary; + + if (isResourceRoute) { + if (manifestRoute.loader && routeId) { + await prerenderData({ + handler: requestHandler, + prerenderPath: path, + onlyRoutes: [routeId], + clientBuildDir, + basename, + trailingSlashAwareDataRequests: + future.unstable_trailingSlashAwareDataRequests, + api, + }); + await prerenderResourceRoute({ + handler: requestHandler, + prerenderPath: path, + clientBuildDir, + basename, + api, + }); + } else { + api.logger.warn( + `⚠️ Skipping prerendering for resource route without a loader: ${routeId}` + ); + } + return; + } + + const hasLoaders = matches.some(match => { + const matchedRouteId = match.route.id; + if (!matchedRouteId) { + return false; + } + return build.assets?.routes?.[matchedRouteId]?.hasLoader; + }); + let data: string | undefined; + if (hasLoaders) { + data = await prerenderData({ + handler: requestHandler, + prerenderPath: path, + onlyRoutes: null, + clientBuildDir, + basename, + trailingSlashAwareDataRequests: + future.unstable_trailingSlashAwareDataRequests, + api, + }); + } + await prerenderRoute({ + handler: requestHandler, + prerenderPath: path, + clientBuildDir, + basename, + api, + requestInit: data + ? { + headers: { + 'X-React-Router-Prerender-Data': encodeURI(data), + }, + } + : undefined, + }); + }; + + for (const path of prerenderPaths) { + const task = enqueue(path); + pending.add(task); + task.finally(() => pending.delete(task)); + if (pending.size >= concurrency) { + await Promise.race(pending); + } + } + await Promise.all(pending); +}; + +export const runReactRouterPrerenderBuild = async ( + options: RunReactRouterPrerenderBuildOptions +): Promise => { + if (!options.hasWebEnvironment) { + return; + } + + const { + api, + buildDirectory, + serverBuildFile, + ssr, + isPrerenderEnabled, + prerenderPaths, + routes, + latestBrowserManifest, + latestBrowserManifestModuleExports, + clientStats, + pluginOptions, + appDirectory, + assetPrefix, + routeChunkOptions, + buildManifest, + resolvedConfigWithRoutes, + buildEnd, + basename, + } = options; + const { serverBuildDir, serverBuildPath } = await getServerBuildPath({ + buildDirectory, + serverBuildFile, + }); + const clientBuildDir = resolve(buildDirectory, 'client'); + + if (!existsSync(serverBuildPath)) { + console.warn( + `[${PLUGIN_NAME}] Server build not found at ${serverBuildPath}. ` + + 'Skipping prerendering.' + ); + return; + } + + await mkdir(clientBuildDir, { recursive: true }); + + if (!ssr || isPrerenderEnabled) { + process.env.IS_RR_BUILD_REQUEST = 'yes'; + const buildModule = await import(pathToFileURL(serverBuildPath).toString()); + const build = (await resolveServerBuildModule( + buildModule, + `Server build ${JSON.stringify(serverBuildPath)}` + )) as PrerenderServerBuild; + const requestHandler = createRequestHandler(build, 'production'); + + if (isPrerenderEnabled) { + if (!ssr) { + const generated = latestBrowserManifest + ? { + manifest: latestBrowserManifest, + moduleExportsByRouteId: latestBrowserManifestModuleExports, + } + : await generateReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + ); + assertValidSsrFalsePrerenderExports({ + routes, + manifestRoutes: generated.manifest.routes, + routeExports: generated.moduleExportsByRouteId, + prerenderPaths, + api, + }); + } + + validatePrerenderPathMatches(routes, prerenderPaths); + + if (prerenderPaths.length > 0) { + api.logger.info( + `Prerender (html): ${prerenderPaths.length} path(s)...` + ); + } + + await runPrerenderPaths({ + build, + requestHandler, + clientBuildDir, + options, + }); + } + + if (!ssr) { + await handleSpaMode({ + handler: requestHandler, + build, + clientBuildDir, + basename, + api, + }); + } + } + + if (!ssr) { + await fsExtra.remove(serverBuildDir); + api.logger.info( + `[${PLUGIN_NAME}] Removed server build (static deployment)` + ); + } + + if (buildEnd) { + await buildEnd({ + buildManifest, + reactRouterConfig: resolvedConfigWithRoutes, + viteConfig: api.getNormalizedConfig(), + }); + } +}; diff --git a/src/route-ast.ts b/src/route-ast.ts index 2793dcf..6d57b8c 100644 --- a/src/route-ast.ts +++ b/src/route-ast.ts @@ -2,8 +2,12 @@ import type { ParseResult } from 'yuku-parser'; export type AnyNode = Record; -export const getProgram = (ast: ParseResult | AnyNode): AnyNode => - (ast as ParseResult).program ?? ast; +export type ProgramNode = AnyNode & { + body: AnyNode[]; +}; + +export const getProgram = (ast: ParseResult | AnyNode): ProgramNode => + ((ast as ParseResult).program ?? ast) as ProgramNode; export const getPatternIdentifierNames = ( pattern: AnyNode | null | undefined, diff --git a/src/route-export-pruning.ts b/src/route-export-pruning.ts index 698d64a..d3e3762 100644 --- a/src/route-export-pruning.ts +++ b/src/route-export-pruning.ts @@ -5,93 +5,86 @@ import { getProgram, removeFromArray, type AnyNode, + type ProgramNode, } from './route-ast.js'; 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; - } + validateBindingTarget(id, new Set(exportsToRemove)); +} - if (id.type === 'ArrayPattern') { - for (const element of id.elements ?? []) { - if (!element) { - continue; - } +export function invalidDestructureError(name: string): Error { + return new Error(`Cannot remove destructured export "${name}"`); +} - if (element.type === 'AssignmentPattern') { - validateDestructuredExports(element, exportsToRemove); - continue; - } +const assertAllowedBindingName = ( + name: string, + exportsToRemove: ReadonlySet +): void => { + if (exportsToRemove.has(name)) { + throw invalidDestructureError(name); + } +}; - if ( - element.type === 'Identifier' && - exportsToRemove.includes(element.name) - ) { - throw invalidDestructureError(element.name); - } +const validateRestElement = ( + element: AnyNode, + exportsToRemove: ReadonlySet +): void => { + if (element.argument?.type === 'Identifier' && element.argument.name) { + assertAllowedBindingName(element.argument.name, exportsToRemove); + } +}; - if ( - element.type === 'RestElement' && - element.argument.type === 'Identifier' && - exportsToRemove.includes(element.argument.name) - ) { - throw invalidDestructureError(element.argument.name); - } +const validateObjectProperty = ( + property: AnyNode, + exportsToRemove: ReadonlySet +): void => { + if (property.type === 'RestElement') { + validateRestElement(property, exportsToRemove); + return; + } + if (property.type === 'Property') { + validateBindingTarget( + property.value as AnyNode | null | undefined, + exportsToRemove + ); + } +}; - if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { - validateDestructuredExports(element, exportsToRemove); - } - } +const validateBindingTarget = ( + node: AnyNode | null | undefined, + exportsToRemove: ReadonlySet +): void => { + if (!node) { + return; } - if (id.type === 'ObjectPattern') { - for (const property of id.properties ?? []) { - if (!property) { - continue; + switch (node.type) { + case 'Identifier': + if (node.name) { + assertAllowedBindingName(node.name, exportsToRemove); } - - if (property.type === 'Property') { - if ( - property.value.type === 'Identifier' && - exportsToRemove.includes(property.value.name) - ) { - throw invalidDestructureError(property.value.name); - } - - if ( - property.value.type === 'AssignmentPattern' || - property.value.type === 'ArrayPattern' || - property.value.type === 'ObjectPattern' - ) { - validateDestructuredExports(property.value, exportsToRemove); + return; + case 'AssignmentPattern': + validateBindingTarget(node.left, exportsToRemove); + return; + case 'ArrayPattern': + for (const element of node.elements ?? []) { + if (element?.type === 'RestElement') { + validateRestElement(element, exportsToRemove); + } else { + validateBindingTarget(element, exportsToRemove); } } - - if ( - property.type === 'RestElement' && - property.argument.type === 'Identifier' && - exportsToRemove.includes(property.argument.name) - ) { - throw invalidDestructureError(property.argument.name); + return; + case 'ObjectPattern': + for (const property of node.properties ?? []) { + validateObjectProperty(property, exportsToRemove); } - } } -} - -export function invalidDestructureError(name: string): Error { - return new Error(`Cannot remove destructured export "${name}"`); -} +}; const getDeclaredNames = (node: AnyNode): Set => { const names = new Set(); @@ -128,7 +121,9 @@ const isIdentifierDeclaration = (node: AnyNode, parent: AnyNode | null) => { return true; } if (parent.type === 'VariableDeclarator') { - return getPatternIdentifierNames(parent.id).has(node.name); + return Boolean( + node.name && getPatternIdentifierNames(parent.id).has(node.name) + ); } if ( (parent.type === 'ImportSpecifier' || @@ -142,11 +137,12 @@ const isIdentifierDeclaration = (node: AnyNode, parent: AnyNode | null) => { (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression' || parent.type === 'ArrowFunctionExpression') && - (parent.params ?? []).some((param: AnyNode) => - getPatternIdentifierNames(param).has(node.name) - ) + node.name ) { - return true; + const name = node.name; + return (parent.params ?? []).some((param: AnyNode) => + getPatternIdentifierNames(param).has(name) + ); } return false; }; @@ -204,43 +200,49 @@ const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); const collectReferencedNames = (node: AnyNode): Set => { const referenced = new Set(); - walk(node as any, { - Identifier(node: AnyNode, ctx: any) { - const parent = ctx.parent as AnyNode | null; - if (!isNonReferenceIdentifier(node, parent)) { - referenced.add(node.name); + walk(node as never, { + Identifier(node, ctx) { + const current = node as unknown as AnyNode; + const parent = (ctx as { parent?: unknown }).parent as AnyNode | null; + if (!isNonReferenceIdentifier(current, parent) && current.name) { + referenced.add(current.name); } }, - JSXIdentifier(node: AnyNode, ctx: any) { - const parent = ctx.parent as AnyNode | null; + JSXIdentifier(node, ctx) { + const current = node as unknown as AnyNode; + const parent = (ctx as { parent?: unknown }).parent as AnyNode | null; if (!parent) { return; } - if (parent.type === 'JSXMemberExpression' && parent.object === node) { - referenced.add(node.name); + if (parent.type === 'JSXMemberExpression' && parent.object === current) { + if (current.name) { + referenced.add(current.name); + } return; } - if (!isUppercaseName(node.name)) { + if (!current.name || !isUppercaseName(current.name)) { return; } if ( (parent.type === 'JSXOpeningElement' || parent.type === 'JSXClosingElement') && - parent.name === node + parent.name === current ) { - referenced.add(node.name); + referenced.add(current.name); return; } }, - ExportSpecifier(node: AnyNode, ctx: any) { - const declaration = ctx.parent as AnyNode | null; + ExportSpecifier(node, ctx) { + const current = node as unknown as AnyNode; + const declaration = (ctx as { parent?: unknown }) + .parent as AnyNode | null; if ( !declaration?.source && declaration?.exportKind !== 'type' && - node.local?.name && - node.exportKind !== 'type' + current.local?.name && + current.exportKind !== 'type' ) { - referenced.add(node.local.name); + referenced.add(current.local.name); } }, }); @@ -257,7 +259,7 @@ type TopLevelDeclarationGraph = { }; const createTopLevelDeclarationGraph = ( - program: AnyNode + program: ProgramNode ): TopLevelDeclarationGraph => { const declarationsByNode = new Map(); const declarationsByName = new Map>(); @@ -280,7 +282,7 @@ const createTopLevelDeclarationGraph = ( for (const statement of [...(program.body ?? [])]) { if (statement.type === 'VariableDeclaration') { - for (const declarator of statement.declarations) { + for (const declarator of statement.declarations ?? []) { registerDeclaration( declarator, declarator, @@ -301,7 +303,7 @@ const createTopLevelDeclarationGraph = ( }; const collectLiveTopLevelDeclarations = ( - program: AnyNode, + program: ProgramNode, graph: TopLevelDeclarationGraph ): Set => { const pendingNames: string[] = []; @@ -384,7 +386,7 @@ const declarationReferencesName = ( }; const removeNewlyDeadTopLevelDeclarations = ( - program: AnyNode, + program: ProgramNode, graph: TopLevelDeclarationGraph, previouslyLive: ReadonlySet, removedExportReferencedNames: ReadonlySet @@ -409,7 +411,7 @@ const removeNewlyDeadTopLevelDeclarations = ( program.body = program.body.filter((statement: AnyNode) => { if (statement.type === 'VariableDeclaration') { - statement.declarations = statement.declarations.filter( + statement.declarations = (statement.declarations ?? []).filter( (declarator: AnyNode) => !isRemovableDeadDeclaration(declarator) ); return statement.declarations.length > 0; @@ -419,7 +421,7 @@ const removeNewlyDeadTopLevelDeclarations = ( }; const hasRemovableExport = ( - program: AnyNode, + program: ProgramNode, exportsToRemove: ReadonlySet ): boolean => { const removesNamedExports = [...exportsToRemove].some( @@ -557,20 +559,23 @@ export const removeExports = ( const declaration = statement.declaration; if (declaration?.type === 'VariableDeclaration') { - declaration.declarations = declaration.declarations.filter( + declaration.declarations = (declaration.declarations ?? []).filter( (declarator: AnyNode) => { - if (declarator.id.type === 'Identifier') { - if (exportsToRemoveSet.has(declarator.id.name)) { + const id = declarator.id; + if (id?.type === 'Identifier') { + if (id.name && exportsToRemoveSet.has(id.name)) { exportsChanged = true; - removedExportLocalNames.add(declarator.id.name); - removedExportReferencedNames.add(declarator.id.name); + removedExportLocalNames.add(id.name); + removedExportReferencedNames.add(id.name); trackRemovedExportReferences(declarator); return false; } return true; } - validateDestructuredExports(declarator.id, exportsToRemove); + if (id) { + validateDestructuredExports(id, exportsToRemove); + } return true; } ); @@ -599,7 +604,7 @@ export const removeExports = ( ) { exportsChanged = true; const declaration = statement.declaration; - if (declaration?.type === 'Identifier') { + if (declaration?.type === 'Identifier' && declaration.name) { removedExportLocalNames.add(declaration.name); removedExportReferencedNames.add(declaration.name); } else if (declaration?.id?.name) { @@ -619,6 +624,7 @@ export const removeExports = ( if ( left?.type === 'MemberExpression' && left.object?.type === 'Identifier' && + left.object.name && removedExportLocalNames.has(left.object.name) ) { removeFromArray(program.body, statement); diff --git a/src/route-export-resolution.ts b/src/route-export-resolution.ts index 9cf3d63..8aa2bfd 100644 --- a/src/route-export-resolution.ts +++ b/src/route-export-resolution.ts @@ -1,6 +1,5 @@ import { readFileSync, statSync, type Stats } from 'node:fs'; import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; import { dirname, relative, resolve } from 'pathe'; import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js'; import { @@ -8,19 +7,25 @@ import { getRouteModuleAnalysis, } from './export-utils.js'; +const tryStat = (path: string): Stats | null => + statSync(path, { throwIfNoEntry: false }) ?? null; + +type PackageExportTarget = + | string + | PackageExportTarget[] + | { [condition: string]: PackageExportTarget | undefined } + | null; + type PackageJson = { - exports?: unknown; - module?: unknown; - main?: unknown; + exports?: PackageExportTarget; + main?: string; + module?: string; }; -type PackageImportResolution = - | { status: 'resolved'; path: string } - | { status: 'blocked-by-exports' } - | { status: 'not-found' }; - -const tryStat = (path: string): Stats | null => - statSync(path, { throwIfNoEntry: false }) ?? null; +const PACKAGE_IMPORT_CONDITIONS = new Set(['import', 'node']); +const PACKAGE_RESOLUTION_NOT_APPLICABLE = Symbol( + 'package resolution not applicable' +); const resolveIndexFile = (dirPath: string): string | null => { for (const ext of JS_EXTENSIONS) { @@ -55,38 +60,47 @@ const resolvePathWithExtensions = (basePath: string): string | null => { const parsePackageSpecifier = ( specifier: string -): { packageName: string; subpath: string } | null => { +): { packageName: string; packageSubpath: string } | null => { if ( specifier.startsWith('.') || specifier.startsWith('/') || - specifier.startsWith('node:') + specifier.startsWith('#') ) { return null; } + const parts = specifier.split('/'); const packageName = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; - if (!packageName || (specifier.startsWith('@') && parts.length < 2)) { - return null; - } - const rest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/'); + const packagePathParts = specifier.startsWith('@') + ? parts.slice(2) + : parts.slice(1); + return { packageName, - subpath: rest ? `./${rest}` : '.', + packageSubpath: + packagePathParts.length > 0 ? `./${packagePathParts.join('/')}` : '.', }; }; const findPackageDirectory = ( - packageName: string, - importerPath: string + importerPath: string, + packageName: string ): string | null => { let currentDirectory = dirname(importerPath); + while (true) { - const candidate = resolve(currentDirectory, 'node_modules', packageName); - if (tryStat(candidate)?.isDirectory()) { - return candidate; + const packageDirectory = resolve( + currentDirectory, + 'node_modules', + packageName + ); + const packageJsonPath = resolve(packageDirectory, 'package.json'); + if (tryStat(packageJsonPath)?.isFile()) { + return packageDirectory; } + const parentDirectory = dirname(currentDirectory); if (parentDirectory === currentDirectory) { return null; @@ -99,96 +113,110 @@ const readPackageJson = (packageDirectory: string): PackageJson | null => { try { return JSON.parse( readFileSync(resolve(packageDirectory, 'package.json'), 'utf8') - ); + ) as PackageJson; } catch { return null; } }; -const resolvePackageTarget = ( - packageDirectory: string, - target: unknown +const resolvePackageExportTarget = ( + target: PackageExportTarget | undefined ): string | null => { + if (!target) { + return null; + } + if (typeof target === 'string') { - return resolvePathWithExtensions(resolve(packageDirectory, target)); + return target; } + if (Array.isArray(target)) { - for (const item of target) { - const resolved = resolvePackageTarget(packageDirectory, item); - if (resolved) { - return resolved; + for (const nestedTarget of target) { + const resolvedTarget = resolvePackageExportTarget(nestedTarget); + if (resolvedTarget) { + return resolvedTarget; } } return null; } - if (target && typeof target === 'object') { - const conditions = target as Record; - for (const condition of ['import', 'default']) { - const resolved = resolvePackageTarget( - packageDirectory, - conditions[condition] - ); - if (resolved) { - return resolved; + + for (const [condition, nestedTarget] of Object.entries(target)) { + if (condition === 'default' || PACKAGE_IMPORT_CONDITIONS.has(condition)) { + const resolvedTarget = resolvePackageExportTarget(nestedTarget); + if (resolvedTarget) { + return resolvedTarget; } } } + return null; }; +const resolvePackageExports = ( + packageDirectory: string, + packageSubpath: string, + packageJson: PackageJson +): string | null => { + const exports = packageJson.exports; + if (!exports) { + const entry = packageJson.module ?? packageJson.main; + return entry + ? resolvePathWithExtensions(resolve(packageDirectory, entry)) + : null; + } + + const target = + typeof exports === 'object' && + !Array.isArray(exports) && + Object.keys(exports).some(key => key.startsWith('.')) + ? exports[packageSubpath] + : packageSubpath === '.' + ? exports + : undefined; + + const resolvedTarget = resolvePackageExportTarget(target); + if (!resolvedTarget || !resolvedTarget.startsWith('./')) { + return null; + } + + return resolvePathWithExtensions(resolve(packageDirectory, resolvedTarget)); +}; + const resolvePackageImport = ( specifier: string, importerPath: string -): PackageImportResolution => { - const parsed = parsePackageSpecifier(specifier); - if (!parsed) { - return { status: 'not-found' }; +): string | null | typeof PACKAGE_RESOLUTION_NOT_APPLICABLE => { + const parsedSpecifier = parsePackageSpecifier(specifier); + if (!parsedSpecifier) { + return PACKAGE_RESOLUTION_NOT_APPLICABLE; } + const packageDirectory = findPackageDirectory( - parsed.packageName, - importerPath + importerPath, + parsedSpecifier.packageName ); if (!packageDirectory) { - return { status: 'not-found' }; + return PACKAGE_RESOLUTION_NOT_APPLICABLE; } + const packageJson = readPackageJson(packageDirectory); if (!packageJson) { - return { status: 'not-found' }; - } - const exportsField = packageJson.exports; - if ('exports' in packageJson) { - const hasSubpathExports = - typeof exportsField === 'object' && - !Array.isArray(exportsField) && - exportsField !== null && - Object.keys(exportsField).some(key => key.startsWith('.')); - const target = - parsed.subpath === '.' && !hasSubpathExports - ? exportsField - : hasSubpathExports - ? (exportsField as Record)[parsed.subpath] - : undefined; - const resolved = resolvePackageTarget(packageDirectory, target); - if (resolved) { - return { status: 'resolved', path: resolved }; - } - return { status: 'blocked-by-exports' }; + return PACKAGE_RESOLUTION_NOT_APPLICABLE; } - if (parsed.subpath !== '.') { - const resolved = resolvePathWithExtensions( - resolve(packageDirectory, parsed.subpath) - ); - return resolved - ? { status: 'resolved', path: resolved } - : { status: 'not-found' }; + + if ( + packageJson.exports === undefined && + !packageJson.module && + !packageJson.main + ) { + return PACKAGE_RESOLUTION_NOT_APPLICABLE; } - const resolved = - resolvePackageTarget(packageDirectory, packageJson.module) ?? - resolvePackageTarget(packageDirectory, packageJson.main) ?? - resolveIndexFile(packageDirectory); - return resolved - ? { status: 'resolved', path: resolved } - : { status: 'not-found' }; + + return resolvePackageExports( + packageDirectory, + parsedSpecifier.packageSubpath, + packageJson + ); }; const resolveExportAllModule = ( @@ -205,17 +233,13 @@ const resolveExportAllModule = ( } } - const packageImport = resolvePackageImport(specifier, importerPath); - if (packageImport.status === 'resolved') { - return packageImport.path; - } - if (packageImport.status === 'blocked-by-exports') { - return null; + const importResolvedPath = resolvePackageImport(specifier, importerPath); + if (importResolvedPath !== PACKAGE_RESOLUTION_NOT_APPLICABLE) { + return importResolvedPath; } try { - const resolver = createRequire(pathToFileURL(importerPath).href); - return resolver.resolve(specifier); + return createRequire(importerPath).resolve(specifier); } catch { return null; } @@ -226,6 +250,26 @@ export type RouteExportResolver = ( importerPath: string ) => Promise | string | null; +export type RouteModuleResolveCallback = ( + error: Error | null, + resolved?: string | false +) => void; + +export type RouteModuleResolver = ( + context: string, + specifier: string, + callback: RouteModuleResolveCallback +) => void; + +export const createBundlerRouteExportResolver = + (resolveModule: RouteModuleResolver): RouteExportResolver => + (specifier, importerPath) => + new Promise(resolveResolvedPath => { + resolveModule(dirname(importerPath), specifier, (error, resolved) => { + resolveResolvedPath(error || !resolved ? null : resolved); + }); + }); + export const collectClientOnlyStubExportNames = async ( code: string, resourcePath: string, diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index b484368..05a4759 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -24,6 +24,7 @@ import { type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; +import { getProgram } from './route-ast.js'; export type RouteTransformResult = { code: string; @@ -165,7 +166,7 @@ const transformRouteModule = async ( const ast = parse(code, { sourceType: 'module' }); if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) { - const resolvedExportNames = collectProgramExportNames(ast.program); + const resolvedExportNames = collectProgramExportNames(getProgram(ast)); const isRootRoute = task.resourcePath === task.rootRoutePath; const relativePath = relative(process.cwd(), task.resourcePath); diff --git a/src/typegen.ts b/src/typegen.ts new file mode 100644 index 0000000..de10f5e --- /dev/null +++ b/src/typegen.ts @@ -0,0 +1,49 @@ +import type { RsbuildPluginAPI } from '@rsbuild/core'; +import type { ResultPromise } from 'execa'; + +export const registerReactRouterTypegen = (api: RsbuildPluginAPI): void => { + let typegenProcess: ResultPromise | undefined; + + api.onBeforeStartDevServer(async () => { + if (typegenProcess) { + return; + } + const { execa } = await import('execa'); + const process = execa( + 'npx', + ['--yes', 'react-router', 'typegen', '--watch'], + { + stdio: 'inherit', + detached: false, + cleanup: true, + } + ); + typegenProcess = process; + process + .catch(() => { + // Ignore errors when the process is killed on server shutdown. + }) + .finally(() => { + if (typegenProcess === process) { + typegenProcess = undefined; + } + }); + }); + + api.onCloseDevServer(async () => { + const process = typegenProcess; + typegenProcess = undefined; + if (!process) { + return; + } + process.kill('SIGTERM'); + await process.catch(() => undefined); + }); + + api.onBeforeBuild(async () => { + const { execa } = await import('execa'); + await execa('npx', ['--yes', 'react-router', 'typegen'], { + stdio: 'inherit', + }); + }); +}; diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts index 354e29f..5fe35b2 100644 --- a/tests/client-modules.test.ts +++ b/tests/client-modules.test.ts @@ -85,10 +85,20 @@ describe('client-only module transforms', () => { expect(transformCall).toBeDefined(); const handler = transformCall?.[1]; + const resolvedPath = join(packageDirectory, 'esm.js'); const result = await handler({ environment: { name: 'node' }, code: await readFile(resourcePath, 'utf8'), resourcePath, + resolve( + context: string, + specifier: string, + callback: (error: Error | null, resolved?: string) => void + ) { + expect(context).toBe(join(root, 'app')); + expect(specifier).toBe('conditional-client-lib'); + callback(null, resolvedPath); + }, }); expect(result.code).toContain('export const esmOnly = undefined;'); @@ -99,6 +109,56 @@ describe('client-only module transforms', () => { } }); + it('uses import conditions in the fallback export-all resolver', async () => { + const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-fallback-')); + const packageDirectory = join( + root, + 'node_modules', + 'fallback-conditional-client-lib' + ); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ + name: 'fallback-conditional-client-lib', + exports: { + '.': { + import: './esm.js', + require: './cjs.cjs', + }, + }, + type: 'module', + }) + ); + await writeFile( + join(packageDirectory, 'esm.js'), + 'export const esmOnly = true; export const shared = true;' + ); + await writeFile( + join(packageDirectory, 'cjs.cjs'), + 'exports.cjsOnly = true; exports.shared = true;' + ); + const resourcePath = join(root, 'app', 'example.client.ts'); + await mkdir(join(root, 'app'), { recursive: true }); + await writeFile( + resourcePath, + "export * from 'fallback-conditional-client-lib';" + ); + + try { + const exportNames = await collectClientOnlyStubExportNames( + await readFile(resourcePath, 'utf8'), + resourcePath + ); + + expect(exportNames).toContain('esmOnly'); + expect(exportNames).toContain('shared'); + expect(exportNames).not.toContain('cjsOnly'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('does not bypass package exports for private export-all subpaths', async () => { const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-private-')); const packageDirectory = join(root, 'node_modules', 'private-client-lib'); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index ca3a5d4..e7f8a0c 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -196,6 +196,75 @@ describe('removeExports', () => { ); }); + it('rejects nested destructured bindings for removed exports', () => { + const code = ` + const route = { data: { nested: { loader: async () => null } } }; + export const { data: { nested: { loader } } } = route; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).toThrowError( + 'Cannot remove destructured export "loader"' + ); + }); + + it('rejects rest identifiers for removed exports', () => { + const code = ` + const route = { action: async () => null, loader: async () => null }; + export const { action, ...loader } = route; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).toThrowError( + 'Cannot remove destructured export "loader"' + ); + }); + + it('checks aliased destructuring by local binding name', () => { + const code = ` + const route = { loader: async () => null }; + export const { loader: clientLoader } = route; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).not.toThrow(); + expect(() => removeExports(ast, ['clientLoader'])).toThrowError( + 'Cannot remove destructured export "clientLoader"' + ); + }); + + it('ignores array holes and default initializer references', () => { + const code = ` + const route = [undefined, async () => loader()]; + export const [, action = loader] = route; + function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).not.toThrow(); + expect(() => removeExports(ast, ['action'])).toThrowError( + 'Cannot remove destructured export "action"' + ); + }); + it('removes every declaration in a deep dead dependency chain', () => { const helperCount = 64; const helpers = Array.from({ length: helperCount }, (_, index) => { From 2617bb227412c36eebe45e48b1d1743696ec2db6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:49:24 +0000 Subject: [PATCH 48/64] chore: simplify route transform cleanup Remove redundant wrappers and duplicated test setup from the route transform follow-up work without changing behavior. --- src/build-output-transforms.ts | 12 ++--- src/dev-server.ts | 2 +- src/route-artifacts.ts | 6 +-- src/route-chunks.ts | 2 +- tests/client-modules.test.ts | 81 ++++++++++++++-------------------- tests/route-artifacts.test.ts | 4 +- 6 files changed, 42 insertions(+), 65 deletions(-) diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index 8adbf2d..bb788aa 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -176,19 +176,13 @@ export const registerBuildOutputTransforms = ({ 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({ + async () => + routeTransformExecutor.run({ kind: 'splitRouteExports', code: args.code, resourcePath: args.resourcePath, routeChunkConfig, - }); - } + }) ) ); } diff --git a/src/dev-server.ts b/src/dev-server.ts index daff753..4136976 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -42,7 +42,7 @@ export const createDevServerMiddleware = ( dependencies.loadBuild, 'development' ); - return createRequestListener(request => requestHandler(request)); + return createRequestListener(requestHandler); })(); return listenerPromise; }; diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 65bdc7c..2379596 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -116,7 +116,7 @@ export const createRouteChunkArtifact = async ({ const splitRouteModules = routeChunkConfig.splitRouteModules; if (!isBuild || !splitRouteModules) { return { - code: emptyRouteChunkSnippet('Split route modules disabled'), + code: emptyRouteChunkSnippet(), map: null, }; } @@ -127,7 +127,7 @@ export const createRouteChunkArtifact = async ({ } if (chunkName !== 'main' && !code.includes(chunkName)) { return { - code: emptyRouteChunkSnippet(`No ${chunkName} chunk`), + code: emptyRouteChunkSnippet(), map: null, }; } @@ -150,7 +150,7 @@ export const createRouteChunkArtifact = async ({ } return { - code: chunk ?? emptyRouteChunkSnippet(`No ${chunkName} chunk`), + code: chunk ?? emptyRouteChunkSnippet(), map: null, }; }; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 8f7dd86..2fa1cdd 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -59,7 +59,7 @@ const createRouteChunkExportMap = ( routeChunkExportNames.map(exportName => [exportName, getValue(exportName)]) ) as Record; -export const emptyRouteChunkSnippet = (_reason: string): string => 'export {};'; +export const emptyRouteChunkSnippet = (): string => 'export {};'; const routeChunkQueryStringPrefix = '?route-chunk='; diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts index 5fe35b2..1f952a2 100644 --- a/tests/client-modules.test.ts +++ b/tests/client-modules.test.ts @@ -8,6 +8,36 @@ import { pluginReactRouter } from '../src'; import { collectClientOnlyStubExportNames } from '../src/route-export-resolution'; describe('client-only module transforms', () => { + const createConditionalClientPackage = async ( + root: string, + packageName: string + ): Promise => { + const packageDirectory = join(root, 'node_modules', packageName); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ + name: packageName, + exports: { + '.': { + import: './esm.js', + require: './cjs.cjs', + }, + }, + type: 'module', + }) + ); + await writeFile( + join(packageDirectory, 'esm.js'), + 'export const esmOnly = true; export const shared = true;' + ); + await writeFile( + join(packageDirectory, 'cjs.cjs'), + 'exports.cjsOnly = true; exports.shared = true;' + ); + return join(packageDirectory, 'esm.js'); + }; + it('stubs exports for .client modules using export *', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -40,33 +70,10 @@ describe('client-only module transforms', () => { it('uses import conditions for bare export-all modules', async () => { const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-')); - const packageDirectory = join( + const resolvedPath = await createConditionalClientPackage( root, - 'node_modules', 'conditional-client-lib' ); - await mkdir(packageDirectory, { recursive: true }); - await writeFile( - join(packageDirectory, 'package.json'), - JSON.stringify({ - name: 'conditional-client-lib', - exports: { - '.': { - import: './esm.js', - require: './cjs.cjs', - }, - }, - type: 'module', - }) - ); - await writeFile( - join(packageDirectory, 'esm.js'), - 'export const esmOnly = true; export const shared = true;' - ); - await writeFile( - join(packageDirectory, 'cjs.cjs'), - 'exports.cjsOnly = true; exports.shared = true;' - ); const resourcePath = join(root, 'app', 'example.client.ts'); await mkdir(join(root, 'app'), { recursive: true }); await writeFile(resourcePath, "export * from 'conditional-client-lib';"); @@ -85,7 +92,6 @@ describe('client-only module transforms', () => { expect(transformCall).toBeDefined(); const handler = transformCall?.[1]; - const resolvedPath = join(packageDirectory, 'esm.js'); const result = await handler({ environment: { name: 'node' }, code: await readFile(resourcePath, 'utf8'), @@ -111,33 +117,10 @@ describe('client-only module transforms', () => { it('uses import conditions in the fallback export-all resolver', async () => { const root = await mkdtemp(join(tmpdir(), 'rr-client-modules-fallback-')); - const packageDirectory = join( + await createConditionalClientPackage( root, - 'node_modules', 'fallback-conditional-client-lib' ); - await mkdir(packageDirectory, { recursive: true }); - await writeFile( - join(packageDirectory, 'package.json'), - JSON.stringify({ - name: 'fallback-conditional-client-lib', - exports: { - '.': { - import: './esm.js', - require: './cjs.cjs', - }, - }, - type: 'module', - }) - ); - await writeFile( - join(packageDirectory, 'esm.js'), - 'export const esmOnly = true; export const shared = true;' - ); - await writeFile( - join(packageDirectory, 'cjs.cjs'), - 'exports.cjsOnly = true; exports.shared = true;' - ); const resourcePath = join(root, 'app', 'example.client.ts'); await mkdir(join(root, 'app'), { recursive: true }); await writeFile( diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index d9dbd0f..f32d094 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -146,7 +146,7 @@ describe('route artifact helpers', () => { } ) ).resolves.toEqual({ - code: emptyRouteChunkSnippet('Split route modules disabled'), + code: emptyRouteChunkSnippet(), map: null, }); }); @@ -194,7 +194,7 @@ describe('route artifact helpers', () => { isBuild: true, }) ).resolves.toEqual({ - code: emptyRouteChunkSnippet('No clientLoader chunk'), + code: emptyRouteChunkSnippet(), map: null, }); }); From 9c8325ec66afaa91f55dacb2c0dac04f84a3d4f2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:01:06 +0000 Subject: [PATCH 49/64] chore: simplify server build helpers Trim unused route artifact return data and dedupe server build candidate probing while preserving behavior. --- scripts/compare-benchmarks.mjs | 2 +- src/route-artifacts.ts | 9 +++------ src/server-utils.ts | 29 +++++++++++++---------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/scripts/compare-benchmarks.mjs b/scripts/compare-benchmarks.mjs index c7da2aa..ca686dd 100644 --- a/scripts/compare-benchmarks.mjs +++ b/scripts/compare-benchmarks.mjs @@ -18,7 +18,7 @@ const { values } = parseArgs({ if (!values.before || !values.after) { throw new Error( - 'Usage: node scripts/compare-benchmarks.mjs --before --after [--benchmark ] [--operations op,op]' + 'Usage: node scripts/compare-benchmarks.mjs --before --after [--benchmark ] [--operations op,op]' ); } diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 2379596..6fda786 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -52,7 +52,7 @@ export const buildRouteClientEntryCode = ({ chunkedExports: readonly string[]; isServer: boolean; resourcePath: string; -}): { code: string; reexports: string[] } => { +}): string => { const chunkedExportSet = chunkedExports.length > 0 ? new Set(chunkedExports) : undefined; const reexports = exportNames.filter(exp => { @@ -65,10 +65,7 @@ export const buildRouteClientEntryCode = ({ ); }); const target = `${resourcePath}?react-router-route`; - return { - code: `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`, - reexports, - }; + return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`; }; export const createRouteClientEntryArtifact = async ({ @@ -101,7 +98,7 @@ export const createRouteClientEntryArtifact = async ({ chunkedExports, isServer, resourcePath, - }).code, + }), }; }; diff --git a/src/server-utils.ts b/src/server-utils.ts index ecc37e8..c189b83 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -179,24 +179,21 @@ export async function resolveServerBuildModule( source: string ): Promise { const moduleValue = await buildModule; - const direct = await resolveServerBuildCandidate(moduleValue); - if (direct) { - return direct; - } - if (isRecord(moduleValue) && 'default' in moduleValue) { - const fromDefault = await resolveServerBuildCandidate( - await moduleValue.default - ); - if (fromDefault) { - return fromDefault; + const candidates = [() => moduleValue]; + if (isRecord(moduleValue)) { + if ('default' in moduleValue) { + candidates.push(() => moduleValue.default); + } + if ('module.exports' in moduleValue) { + candidates.push(() => moduleValue['module.exports']); } } - if (isRecord(moduleValue) && 'module.exports' in moduleValue) { - const fromModuleExports = await resolveServerBuildCandidate( - await moduleValue['module.exports'] - ); - if (fromModuleExports) { - return fromModuleExports; + + for (const getCandidate of candidates) { + const candidate = await getCandidate(); + const serverBuild = await resolveServerBuildCandidate(candidate); + if (serverBuild) { + return serverBuild; } } throw new Error( From 8a256d460eab25b44ce5c38282897fc0b8dc99d5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:46:16 +0000 Subject: [PATCH 50/64] fix: reload on lazy CSS ownership loss --- .../tests/e2e/lazy-compilation.test.ts | 180 ++++++++- src/dev-generation.ts | 193 +++++++++- src/dev-runtime-controller.ts | 6 + tests/dev-generation.test.ts | 354 +++++++++++++++++- tests/dev-runtime-controller.test.ts | 146 +++++++- 5 files changed, 846 insertions(+), 33 deletions(-) diff --git a/examples/default-template/tests/e2e/lazy-compilation.test.ts b/examples/default-template/tests/e2e/lazy-compilation.test.ts index b4dca75..4d3c8fc 100644 --- a/examples/default-template/tests/e2e/lazy-compilation.test.ts +++ b/examples/default-template/tests/e2e/lazy-compilation.test.ts @@ -1,6 +1,101 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; +import { readFileSync, 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 aboutRoutePath = join(appDirectory, 'routes/about.tsx'); +const aboutCssPath = join(appDirectory, 'routes/about.css'); +const originalAboutRoute = readFileSync(aboutRoutePath, 'utf8'); +const originalAboutCss = readFileSync(aboutCssPath, 'utf8'); +const aboutRouteWithCssImport = `import './about.css'; + +export default function About() { + return ( +
+

About CSS HMR Probe

+
+ CSS HMR probe +
+
+ ); +} +`; +const aboutRouteWithoutCssImport = aboutRouteWithCssImport.replace( + "import './about.css';\n\n", + '' +); +const aboutCssProbe = `.css-hmr-probe { + color: rgb(255, 0, 0); +} +`; + +const writeFileIfChanged = (path: string, contents: string) => { + if (readFileSync(path, 'utf8') !== contents) { + writeFileSync(path, contents); + } +}; + +const restoreAboutRoute = () => { + writeFileIfChanged(aboutRoutePath, originalAboutRoute); + writeFileIfChanged(aboutCssPath, originalAboutCss); +}; + +const readProbeColor = async (page: Page) => { + try { + return await page.evaluate(() => { + const probe = document.querySelector('[data-testid="css-hmr-probe"]'); + return probe ? getComputedStyle(probe).color : 'missing'; + }); + } catch (cause) { + if ( + cause instanceof Error && + cause.message.includes('Execution context was destroyed') + ) { + return 'navigating'; + } + throw cause; + } +}; + +const readProbeState = async (page: Page) => { + try { + return await page.evaluate(() => { + const probe = document.querySelector('[data-testid="css-hmr-probe"]'); + const manifest = (window as any).__reactRouterManifest; + return { + color: probe ? getComputedStyle(probe).color : 'missing', + links: Array.from(document.querySelectorAll('link[rel="stylesheet"]')) + .map(link => (link as HTMLLinkElement).href) + .sort(), + manifestCss: manifest?.routes?.['routes/about']?.css ?? null, + manifestModule: manifest?.routes?.['routes/about']?.module ?? null, + manifestVersion: manifest?.version ?? null, + }; + }); + } catch (cause) { + if ( + cause instanceof Error && + cause.message.includes('Execution context was destroyed') + ) { + return { color: 'navigating' }; + } + throw cause; + } +}; test.describe('lazy compilation', () => { + test.setTimeout(90000); + + test.beforeEach(() => { + restoreAboutRoute(); + }); + + test.afterEach(() => { + restoreAboutRoute(); + }); + test('hydrates with entries:true while manifest route modules stay synchronous', async ({ page, }) => { @@ -65,4 +160,87 @@ test.describe('lazy compilation', () => { expect(documentRequests).toEqual([]); expect(errors.join('\n')).not.toMatch(/hydration|Hydration|Component/); }); + + test('full reloads when active lazy route CSS import is removed and re-added', async ({ + page, + }) => { + writeFileSync(aboutRoutePath, aboutRouteWithCssImport); + writeFileSync(aboutCssPath, aboutCssProbe); + + const documentRequests: string[] = []; + const stylesheetRequests: string[] = []; + const stylesheetResponses: string[] = []; + page.on('request', request => { + if ( + request.isNavigationRequest() && + request.frame() === page.mainFrame() + ) { + documentRequests.push(request.url()); + } + if ( + request.resourceType() === 'stylesheet' || + new URL(request.url()).pathname.endsWith('.css') + ) { + stylesheetRequests.push(request.url()); + } + }); + page.on('response', response => { + const url = response.url(); + if ( + response.request().resourceType() === 'stylesheet' || + new URL(url).pathname.endsWith('.css') + ) { + stylesheetResponses.push(`${response.status()} ${url}`); + } + }); + + await page.goto('/about'); + await expect( + page.getByRole('heading', { name: 'About CSS HMR Probe' }) + ).toBeVisible(); + await expect + .poll(() => readProbeColor(page), { timeout: 60000 }) + .toBe('rgb(255, 0, 0)'); + + const documentRequestsBeforeRemoval = documentRequests.length; + writeFileSync(aboutRoutePath, aboutRouteWithoutCssImport); + + await expect + .poll( + async () => { + const reloads = + documentRequests.length - documentRequestsBeforeRemoval; + const color = await readProbeColor(page); + return reloads > 0 && color !== 'rgb(255, 0, 0)' + ? 'cleared' + : `reloads:${reloads};color:${color}`; + }, + { timeout: 60000 } + ) + .toBe('cleared'); + + const stylesheetRequestsBeforeReAdd = stylesheetRequests.length; + const documentRequestsBeforeReAdd = documentRequests.length; + writeFileSync(aboutRoutePath, aboutRouteWithCssImport); + + await expect + .poll( + async () => { + const state = await readProbeState(page); + const reloads = documentRequests.length - documentRequestsBeforeReAdd; + return state.color === 'rgb(255, 0, 0)' && + stylesheetRequests.length > stylesheetRequestsBeforeReAdd && + reloads > 0 + ? 'loaded' + : JSON.stringify({ + ...state, + reloads, + stylesheetRequestCount: stylesheetRequests.length, + stylesheetResponses: stylesheetResponses.slice(-5), + }); + }, + { timeout: 60000 } + ) + .toBe('loaded'); + }); }); diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 0a82200..c4d30b6 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -74,9 +74,109 @@ type CreateReactRouterDevRuntimeOptions = { server: RsbuildDevServer; buildPlan: ReactRouterDevBuildPlan; onEvaluationError: (error: Error) => void; + onCssAssetOwnershipChanged?: () => void; onWarning?: (message: string) => void; }; +const collectManifestCssAssetOwnership = ( + manifest: ReactRouterDevManifestSet[string] +): Set => { + const ownership = new Set(); + for (const asset of manifest.entry?.css ?? []) { + ownership.add(`entry\0${asset}`); + } + for (const [routeId, route] of Object.entries(manifest.routes ?? {})) { + for (const asset of route.css ?? []) { + ownership.add(`route\0${routeId}\0${asset}`); + } + } + return ownership; +}; + +const hasRemovedCssAssetOwnership = ( + previous: ReactRouterDevManifestSet, + next: ReactRouterDevManifestSet +): boolean => { + for (const [entryName, previousManifest] of Object.entries(previous)) { + const previousOwnership = collectManifestCssAssetOwnership(previousManifest); + if (previousOwnership.size === 0) { + continue; + } + const nextManifest = next[entryName]; + if (!nextManifest) { + return true; + } + const nextOwnership = collectManifestCssAssetOwnership(nextManifest); + for (const owner of previousOwnership) { + if (!nextOwnership.has(owner)) { + return true; + } + } + } + return false; +}; + +const hasAddedCssAssetOwnership = ( + previous: ReactRouterDevManifestSet, + next: ReactRouterDevManifestSet +): boolean => hasRemovedCssAssetOwnership(next, previous); + +const normalizeManifestForCssOwnershipCheck = ( + manifest: ReactRouterDevManifestSet[string] +) => ({ + entry: { + imports: manifest.entry?.imports ?? [], + module: manifest.entry?.module, + }, + routes: Object.fromEntries( + Object.entries(manifest.routes ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, route]) => [ + routeId, + { + caseSensitive: route.caseSensitive, + clientActionModule: route.clientActionModule, + clientLoaderModule: route.clientLoaderModule, + clientMiddlewareModule: route.clientMiddlewareModule, + errorBoundary: route.hasErrorBoundary, + hasAction: route.hasAction, + hasClientAction: route.hasClientAction, + hasClientLoader: route.hasClientLoader, + hasClientMiddleware: route.hasClientMiddleware, + hasDefaultExport: route.hasDefaultExport, + hasLoader: route.hasLoader, + hydrateFallbackModule: route.hydrateFallbackModule, + id: route.id, + imports: route.imports, + index: route.index, + module: route.module, + parentId: route.parentId, + path: route.path, + }, + ]) + ), +}); + +const hasOnlyCssAssetOwnershipChanges = ( + previous: ReactRouterDevManifestSet, + next: ReactRouterDevManifestSet +): boolean => { + const previousEntryNames = Object.keys(previous).sort(); + const nextEntryNames = Object.keys(next).sort(); + if (previousEntryNames.join('\0') !== nextEntryNames.join('\0')) { + return false; + } + return previousEntryNames.every(entryName => { + const previousManifest = normalizeManifestForCssOwnershipCheck( + previous[entryName] + ); + const nextManifest = normalizeManifestForCssOwnershipCheck( + next[entryName] + ); + return JSON.stringify(previousManifest) === JSON.stringify(nextManifest); + }); +}; + const createDeferred = (): Deferred => { let resolve!: (value: T) => void; let reject!: (error: Error) => void; @@ -94,9 +194,11 @@ export const createReactRouterDevRuntime = ({ server, buildPlan, onEvaluationError, + onCssAssetOwnershipChanged = () => undefined, onWarning = () => undefined, }: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => { let nextAttemptId = 1; + let reloadAfterCssRemoval = false; let state: RuntimeState = { kind: 'starting', attemptId: 0, @@ -107,6 +209,17 @@ export const createReactRouterDevRuntime = ({ ReactRouterDevManifestSet >(); + const notifyCssAssetOwnershipChanged = (): void => { + try { + onCssAssetOwnershipChanged(); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + onWarning( + `[rsbuild-plugin-react-router] Failed to notify the browser after CSS asset ownership changed: ${reason}` + ); + } + }; + const uniqueEntryNames = new Set(buildPlan.entryNames); if ( uniqueEntryNames.size !== buildPlan.entryNames.length || @@ -161,9 +274,12 @@ export const createReactRouterDevRuntime = ({ } }; - const commit = (attemptId: number, committed: CommittedGeneration): void => { + const commit = ( + attemptId: number, + committed: CommittedGeneration + ): boolean => { if (!isCurrentAttempt(attemptId)) { - return; + return false; } if (state.kind === 'starting') { const { readiness } = state; @@ -172,6 +288,7 @@ export const createReactRouterDevRuntime = ({ } else if (state.kind === 'ready') { state = { kind: 'ready', committed, pendingAttemptId: null }; } + return true; }; const discardUnsafeOneSidedResult = ( @@ -283,17 +400,6 @@ export const createReactRouterDevRuntime = ({ return; } - if (nodeChanged && identity.nodeWeb !== webIdentity) { - const message = - '[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.'; - if (!previous) { - return; - } - onWarning(message); - rejectAttempt(attemptId, new Error(message), false); - return; - } - const manifestsByEntryName = webChanged ? manifestsByCompilation.get(webCompilation) : previous?.web.manifestsByEntryName; @@ -307,16 +413,55 @@ export const createReactRouterDevRuntime = ({ ); return; } + const cssAssetsRemoved = + !!previous && + webChanged && + hasRemovedCssAssetOwnership( + previous.web.manifestsByEntryName, + manifestsByEntryName + ); + const cssAssetsAdded = + !!previous && + webChanged && + hasAddedCssAssetOwnership( + previous.web.manifestsByEntryName, + manifestsByEntryName + ); + const cssOnlyWebManifestChange = + (cssAssetsRemoved || cssAssetsAdded) && + hasOnlyCssAssetOwnershipChanges( + previous.web.manifestsByEntryName, + manifestsByEntryName + ); + const reusePreviousNodeBuild = !!previous && cssOnlyWebManifestChange; + + if ( + nodeChanged && + identity.nodeWeb !== webIdentity && + !reusePreviousNodeBuild + ) { + const message = + '[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.'; + if (!previous) { + return; + } + onWarning(message); + rejectAttempt(attemptId, new Error(message), false); + return; + } + + const shouldEvaluateNode = nodeChanged && !reusePreviousNodeBuild; if ( previous && - webChanged !== nodeChanged && + webChanged !== shouldEvaluateNode && + !cssOnlyWebManifestChange && discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes) ) { return; } try { - const buildsByEntryName = nodeChanged + const buildsByEntryName = shouldEvaluateNode ? await evaluateServerBuilds(server, buildPlan.entryNames) : previous!.buildsByEntryName; if (!isCurrentAttempt(attemptId)) { @@ -328,19 +473,31 @@ export const createReactRouterDevRuntime = ({ dependencies: snapshotDependencies(webCompilation), } : previous!.web; - commit(attemptId, { + const committed = commit(attemptId, { buildsByEntryName: pinServerBuildsToManifests( buildsByEntryName, buildPlan.entryNames, web.manifestsByEntryName ), webIdentity, - nodeIdentity, + nodeIdentity: shouldEvaluateNode + ? nodeIdentity + : previous!.nodeIdentity, web, - nodeDependencies: nodeChanged + nodeDependencies: shouldEvaluateNode ? snapshotDependencies(nodeCompilation) : previous!.nodeDependencies, }); + if (!committed) { + return; + } + if (cssAssetsRemoved) { + reloadAfterCssRemoval = !cssAssetsAdded; + notifyCssAssetOwnershipChanged(); + } else if (webChanged && reloadAfterCssRemoval) { + reloadAfterCssRemoval = false; + notifyCssAssetOwnershipChanged(); + } } catch (cause) { rejectAttempt( attemptId, diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 27cd284..cc86c8e 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -170,6 +170,12 @@ export const createReactRouterDevRuntimeController = ({ html: escapeHtml(error.message), }); }, + onCssAssetOwnershipChanged() { + if (sessions.getActiveBinding()?.runtime !== runtime) { + return; + } + server.sockWrite('full-reload', { path: '*' }); + }, onWarning: message => api.logger.warn(message), }); const binding = sessions.createBinding(server, runtime); diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 8d13a9c..500adfe 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -8,6 +8,7 @@ import { unregisterReactRouterDevRuntime, type DevGraphChanges, type DevGraphIdentity, + type ReactRouterDevManifest, type ReactRouterDevRuntime, } from '../src/dev-generation'; @@ -40,10 +41,18 @@ const graphIdentity = ( type TestServerBuild = ServerBuild & { marker: string }; -const createBuild = (marker: string): TestServerBuild => +const createBuild = ( + marker: string, + routeIds = ['routes/about', 'routes/home'] +): TestServerBuild => ({ entry: { module: { default: () => new Response() } }, - routes: {}, + routes: Object.fromEntries( + routeIds.map(routeId => [ + routeId, + { module: { default: () => null } }, + ]) + ), assets: { routes: {}, version: marker }, assetsBuildDirectory: '/app/build/client', basename: '/', @@ -56,6 +65,41 @@ const createBuild = (marker: string): TestServerBuild => ssr: true, }) as unknown as TestServerBuild; +const createRouteManifest = ( + id: string, + css: string[] +): ReactRouterDevManifest['routes'][string] => ({ + id, + module: `/${id}.js`, + hasAction: false, + hasLoader: false, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasDefaultExport: true, + hasErrorBoundary: false, + imports: [], + css, +}); + +const createDevManifest = ( + version: string, + css: { + entry?: string[]; + routes?: Record; + } = {} +): ReactRouterDevManifest => ({ + version, + url: '/manifest', + entry: { module: '/entry.js', imports: [], css: css.entry ?? [] }, + routes: Object.fromEntries( + Object.entries(css.routes ?? {}).map(([id, routeCss]) => [ + id, + createRouteManifest(id, routeCss), + ]) + ), +}); + const createCompilation = ( name: 'web' | 'node', dependencies: { @@ -94,18 +138,17 @@ const createGraphStats = ( const captureWeb = ( runtime: ReactRouterDevRuntime, compilation: Rspack.Compilation, - marker: string + marker: string, + css?: Parameters[1] ) => { runtime.captureWeb(compilation, { - 'static/js/app': { - routes: {}, - version: marker, - }, + 'static/js/app': createDevManifest(marker, css), }); }; const createHarness = ( - loadBundle: (entryName: string) => Promise | unknown + loadBundle: (entryName: string) => Promise | unknown, + options: { onCssAssetOwnershipChanged?: () => void } = {} ) => { const errors: Error[] = []; const warnings: string[] = []; @@ -122,6 +165,7 @@ const createHarness = ( entryNames: ['static/js/app'], }, onEvaluationError: error => errors.push(error), + onCssAssetOwnershipChanged: options.onCssAssetOwnershipChanged, onWarning: warning => warnings.push(warning), }); return { errors, loadBundle: loadBundleMock, runtime, server, warnings }; @@ -133,7 +177,7 @@ describe('React Router development runtime', () => { const { runtime } = createHarness(() => rawBuild); const web = createCompilation('web'); const node = createCompilation('node'); - const manifest = { routes: {}, version: 'web-1' }; + const manifest = createDevManifest('web-1'); runtime.beginAttempt(); runtime.captureWeb(web, { 'static/js/app': manifest }); @@ -150,6 +194,298 @@ describe('React Router development runtime', () => { expect(committed.assets).not.toBe(manifest); }); + it('detects retargeted route css ownership', async () => { + const onCssAssetOwnershipChanged = rstest.fn(); + const { runtime } = createHarness(() => createBuild('build'), { + onCssAssetOwnershipChanged, + }); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'about-css', { + routes: { 'routes/about': ['/assets/shared.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + + const nextWeb = createCompilation('web'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, nextWeb, 'home-css', { + routes: { 'routes/home': ['/assets/shared.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(nextWeb, nextNode), + noKnownChanges, + graphIdentity(nextWeb, nextNode) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + }); + + it('notifies after a committed web manifest removes route or entry css ownership', async () => { + const onCssAssetOwnershipChanged = rstest.fn(); + const { runtime } = createHarness(() => createBuild('build'), { + onCssAssetOwnershipChanged, + }); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'with-css', { + entry: ['/assets/entry.css'], + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + expect(onCssAssetOwnershipChanged).not.toHaveBeenCalled(); + + const removedRouteCssWeb = createCompilation('web'); + const secondNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, removedRouteCssWeb, 'without-route-css', { + entry: ['/assets/entry.css'], + }); + await runtime.finishAttempt( + createGraphStats(removedRouteCssWeb, secondNode), + noKnownChanges, + graphIdentity(removedRouteCssWeb, secondNode) + ); + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + + const removedEntryCssWeb = createCompilation('web'); + const thirdNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, removedEntryCssWeb, 'without-entry-css'); + await runtime.finishAttempt( + createGraphStats(removedEntryCssWeb, thirdNode), + noKnownChanges, + graphIdentity(removedEntryCssWeb, thirdNode) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledTimes(2); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'without-entry-css' }, + }); + }); + + it('publishes css-only removals when the route file overlaps node dependencies', async () => { + const routePath = '/app/routes/about.tsx'; + const onCssAssetOwnershipChanged = rstest.fn(); + const { runtime, warnings } = createHarness(() => createBuild('build'), { + onCssAssetOwnershipChanged, + }); + const firstWeb = createCompilation('web'); + const node = createCompilation('node', { files: [routePath] }); + + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'with-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, node), + noKnownChanges, + graphIdentity(firstWeb, node) + ); + + const removedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, removedCssWeb, 'without-css', { + routes: { 'routes/about': [] }, + }); + await runtime.finishAttempt( + createGraphStats(removedCssWeb, node), + { + web: { known: true, files: new Set([routePath]) }, + node: { known: false, files: new Set() }, + }, + graphIdentity(removedCssWeb, node) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + expect(warnings).toEqual([]); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'without-css' }, + }); + }); + + it('keeps normal hmr for css-only additions, stable css assets, and node-only compiles', async () => { + const routePath = '/app/routes/about.tsx'; + const onCssAssetOwnershipChanged = rstest.fn(); + const { runtime } = createHarness(() => createBuild('build'), { + onCssAssetOwnershipChanged, + }); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node', { files: [routePath] }); + + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'base'); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + + const cssOnlyChange: DevGraphChanges = { + web: { known: true, files: new Set([routePath]) }, + node: { known: false, files: new Set() }, + }; + + const addedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, addedCssWeb, 'added-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(addedCssWeb, firstNode), + cssOnlyChange, + graphIdentity(addedCssWeb, firstNode) + ); + + const stableCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, stableCssWeb, 'same-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(stableCssWeb, firstNode), + cssOnlyChange, + graphIdentity(stableCssWeb, firstNode) + ); + + const nodeOnly = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(stableCssWeb, nodeOnly), + noKnownChanges, + graphIdentity(stableCssWeb, nodeOnly) + ); + + expect(onCssAssetOwnershipChanged).not.toHaveBeenCalled(); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'same-css' }, + }); + }); + + it('notifies when css ownership is re-added after a removal', async () => { + const onCssAssetOwnershipChanged = rstest.fn(); + const { runtime } = createHarness(() => createBuild('build'), { + onCssAssetOwnershipChanged, + }); + const node = createCompilation('node'); + const cssOnlyChange: DevGraphChanges = { + web: { known: true, files: new Set(['/app/routes/about.tsx']) }, + node: { known: false, files: new Set() }, + }; + + const firstWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'with-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, node), + noKnownChanges, + graphIdentity(firstWeb, node) + ); + + const removedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, removedCssWeb, 'without-css', { + routes: { 'routes/about': [] }, + }); + await runtime.finishAttempt( + createGraphStats(removedCssWeb, node), + cssOnlyChange, + graphIdentity(removedCssWeb, node) + ); + + const readdedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, readdedCssWeb, 'readded-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(readdedCssWeb, node), + cssOnlyChange, + graphIdentity(readdedCssWeb, node) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledTimes(2); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'readded-css' }, + }); + }); + + it('publishes css-only web manifest changes when a node result comes from an older web cycle', async () => { + const onCssAssetOwnershipChanged = rstest.fn(); + const { loadBundle, runtime, warnings } = createHarness( + () => createBuild('build'), + { onCssAssetOwnershipChanged } + ); + const node = createCompilation('node'); + const webOnlyCssChange: DevGraphChanges = { + web: { known: true, files: new Set(['/app/routes/about.tsx']) }, + node: { known: false, files: new Set() }, + }; + const cssOnlyChange: DevGraphChanges = { + web: { known: true, files: new Set(['/app/routes/about.tsx']) }, + node: { known: true, files: new Set(['/app/routes/about.tsx']) }, + }; + + const firstWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'with-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, node), + noKnownChanges, + graphIdentity(firstWeb, node) + ); + + const removedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, removedCssWeb, 'without-css', { + routes: { 'routes/about': [] }, + }); + await runtime.finishAttempt( + createGraphStats(removedCssWeb, node), + webOnlyCssChange, + graphIdentity(removedCssWeb, node) + ); + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'without-css' }, + }); + + const readdedCssWeb = createCompilation('web'); + const staleNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, readdedCssWeb, 'readded-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(readdedCssWeb, staleNode), + cssOnlyChange, + graphIdentity(readdedCssWeb, staleNode, removedCssWeb) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledTimes(2); + expect(loadBundle).toHaveBeenCalledOnce(); + expect(warnings).toEqual([]); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'readded-css' }, + }); + }); + it('rejects initial waiters on evaluation failure and recovers on a new attempt', async () => { let shouldFail = true; const { runtime } = createHarness(() => { diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index 9d89b8d..c4f9ff8 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -9,6 +9,7 @@ import type { } from '@rsbuild/core'; import { describe, expect, it, rstest } from '@rstest/core'; import type { ServerBuild } from 'react-router'; +import type { ReactRouterDevManifest } from '../src/dev-generation'; import { createReactRouterDevRuntimeController } from '../src/dev-runtime-controller'; type FailedCallback = (error: Error) => void; @@ -20,10 +21,18 @@ const afterDoneByCompiler = new WeakMap< type TestServerBuild = ServerBuild & { marker: string }; -const createBuild = (marker: string): TestServerBuild => +const createBuild = ( + marker: string, + routeIds = ['routes/about', 'routes/home'] +): TestServerBuild => ({ entry: { module: { default: () => new Response() } }, - routes: {}, + routes: Object.fromEntries( + routeIds.map(routeId => [ + routeId, + { module: { default: () => null } }, + ]) + ), assets: { routes: {}, version: marker }, assetsBuildDirectory: '/app/build/client', basename: '/', @@ -36,12 +45,37 @@ const createBuild = (marker: string): TestServerBuild => ssr: true, }) as unknown as TestServerBuild; -const createManifest = (version: string) => ({ +const createRouteManifest = ( + id: string, + css: string[] +): ReactRouterDevManifest['routes'][string] => ({ + id, + module: `/${id}.js`, + hasAction: false, + hasLoader: false, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasDefaultExport: true, + hasErrorBoundary: false, + imports: [], + css, +}); + +const createManifest = ( + version: string, + css: { entry?: string[]; routes?: Record } = {} +) => ({ 'static/js/app': { version, url: '/manifest', - entry: { module: '/entry.js', imports: [], css: [] }, - routes: {}, + entry: { module: '/entry.js', imports: [], css: css.entry ?? [] }, + routes: Object.fromEntries( + Object.entries(css.routes ?? {}).map(([id, routeCss]) => [ + id, + createRouteManifest(id, routeCss), + ]) + ), }, }); @@ -429,6 +463,108 @@ describe('React Router development runtime controller', () => { expect(loadBundle).toHaveBeenCalledOnce(); }); + it('hard reloads when a safe web-only compile removes CSS assets', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + loadBundle.mockImplementation(() => createBuild('base')); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb( + baseWeb, + createManifest('web-base', { + entry: ['/assets/entry.css'], + routes: { 'routes/about': ['/assets/about.css'] }, + }) + ); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + expect(server.sockWrite).not.toHaveBeenCalled(); + + web.setChanges(['/app/routes/about.tsx']); + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb( + nextWeb, + createManifest('web-next', { + entry: ['/assets/entry.css'], + }) + ); + web.complete(nextWeb); + await callbacks.after({ stats: createGraphStats(nextWeb, baseNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'web-next' }, + }); + expect(server.sockWrite).toHaveBeenCalledWith('full-reload', { + path: '*', + }); + }); + + it('hard reloads when CSS ownership is restored after a removal', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + loadBundle.mockImplementation(() => createBuild('base')); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb( + baseWeb, + createManifest('web-base', { + routes: { 'routes/about': ['/assets/about.css'] }, + }) + ); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + web.setChanges(['/app/routes/about.tsx']); + callbacks.before(); + const removedCssWeb = web.compile(); + controller.captureWeb( + removedCssWeb, + createManifest('without-css', { + routes: { 'routes/about': [] }, + }) + ); + web.complete(removedCssWeb); + await callbacks.after({ stats: createGraphStats(removedCssWeb, baseNode) }); + + web.setChanges(['/app/routes/about.tsx']); + callbacks.before(); + const readdedCssWeb = web.compile(); + controller.captureWeb( + readdedCssWeb, + createManifest('readded-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + }) + ); + web.complete(readdedCssWeb); + await callbacks.after({ stats: createGraphStats(readdedCssWeb, baseNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'base', + assets: { version: 'readded-css' }, + }); + expect(server.sockWrite).toHaveBeenCalledTimes(2); + expect(server.sockWrite).toHaveBeenNthCalledWith(1, 'full-reload', { + path: '*', + }); + expect(server.sockWrite).toHaveBeenNthCalledWith(2, 'full-reload', { + path: '*', + }); + }); + it('publishes a safe node-only compile after the aggregate pre-hook', async () => { const { callbacks, controller, loadBundle, server } = createHarness(); let build = createBuild('base'); From b39b979586f1df512ec010e65a3791d7f272a6dc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:50:50 +0200 Subject: [PATCH 51/64] fix: preserve css readd reload state Keep the post-removal CSS reload state armed through unrelated web updates and only fire the follow-up reload when CSS ownership is added back. --- src/dev-generation.ts | 6 ++++-- tests/dev-generation.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/dev-generation.ts b/src/dev-generation.ts index c4d30b6..5cf1c51 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -494,9 +494,11 @@ export const createReactRouterDevRuntime = ({ if (cssAssetsRemoved) { reloadAfterCssRemoval = !cssAssetsAdded; notifyCssAssetOwnershipChanged(); - } else if (webChanged && reloadAfterCssRemoval) { + } else if (cssAssetsAdded) { + if (reloadAfterCssRemoval) { + notifyCssAssetOwnershipChanged(); + } reloadAfterCssRemoval = false; - notifyCssAssetOwnershipChanged(); } } catch (cause) { rejectAttempt( diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 500adfe..6a12938 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -406,6 +406,19 @@ describe('React Router development runtime', () => { cssOnlyChange, graphIdentity(removedCssWeb, node) ); + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + + const contentOnlyWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, contentOnlyWeb, 'without-css-content-edit', { + routes: { 'routes/about': [] }, + }); + await runtime.finishAttempt( + createGraphStats(contentOnlyWeb, node), + cssOnlyChange, + graphIdentity(contentOnlyWeb, node) + ); + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); const readdedCssWeb = createCompilation('web'); runtime.beginAttempt(); From 95f6db6af817ea4bbaebc1c618cca050dfd0b1cb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:20:33 +0000 Subject: [PATCH 52/64] chore: publish source files --- package.json | 3 ++- scripts/test-package-interop.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cecf3de..7fa90e6 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "main": "./dist/index.cjs", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "src" ], "publint": { "ignoreMissingDts": true diff --git a/scripts/test-package-interop.mjs b/scripts/test-package-interop.mjs index d68d01e..e47fe24 100644 --- a/scripts/test-package-interop.mjs +++ b/scripts/test-package-interop.mjs @@ -1,10 +1,14 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; const require = createRequire(import.meta.url); +const execFileAsync = promisify(execFile); const esm = await import('../dist/index.js'); const commonjs = require('../dist/index.cjs'); +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); const build = { entry: { module: { default: () => new Response() } }, routes: {}, @@ -67,6 +71,29 @@ async function verifyRegistration(writer, reader) { ); } +async function verifyPackIncludesOriginalSource() { + const { stdout } = await execFileAsync( + 'npm', + ['pack', '--dry-run', '--json'], + { + cwd: packageRoot, + } + ); + const [pack] = JSON.parse(stdout); + const files = new Set(pack.files.map(file => file.path)); + + assert( + files.has('src/index.ts'), + 'Expected npm package to include src/index.ts' + ); + assert( + files.has('src/templates/entry.client.tsx'), + 'Expected npm package to include source templates' + ); +} + +await verifyPackIncludesOriginalSource(); + process.chdir( fileURLToPath(new URL('../tests/fixtures/dev-runtime/', import.meta.url)) ); From e6a80cd0894dd1ee289ac0ea96452d2bd1e625bf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:35:33 +0000 Subject: [PATCH 53/64] fix: harden route transforms and manifest css --- config/package.json | 2 +- examples/client-only/package.json | 4 +- examples/cloudflare/package.json | 4 +- examples/custom-node-server/package.json | 4 +- examples/default-template/package.json | 4 +- examples/epic-stack/package.json | 4 +- .../federation/epic-stack-remote/package.json | 4 +- examples/federation/epic-stack/package.json | 4 +- examples/prerender/package.json | 4 +- examples/spa-mode/package.json | 4 +- package.json | 6 +- pnpm-lock.yaml | 602 +++++++++++------- src/build-output-transforms.ts | 58 +- src/manifest.ts | 65 +- tests/build-output-transforms.test.ts | 152 +++++ tests/modify-browser-manifest.test.ts | 62 +- 16 files changed, 703 insertions(+), 280 deletions(-) create mode 100644 tests/build-output-transforms.test.ts diff --git a/config/package.json b/config/package.json index 1bcf48a..8a53e8c 100644 --- a/config/package.json +++ b/config/package.json @@ -3,7 +3,7 @@ "version": "1.0.1", "private": true, "devDependencies": { - "@rsbuild/core": "2.0.15", + "@rsbuild/core": "2.1.0", "@rslib/core": "0.22.1", "@types/node": "^25.0.10", "typescript": "^5.9.3" diff --git a/examples/client-only/package.json b/examples/client-only/package.json index c3e5acb..948fa29 100644 --- a/examples/client-only/package.json +++ b/examples/client-only/package.json @@ -20,8 +20,8 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "^2.1.0", "@types/node": "^25.0.10", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", diff --git a/examples/cloudflare/package.json b/examples/cloudflare/package.json index 08d6de1..6877fa5 100644 --- a/examples/cloudflare/package.json +++ b/examples/cloudflare/package.json @@ -23,8 +23,8 @@ "@playwright/test": "^1.58.0", "@react-router/cloudflare": "^7.13.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "^2.1.0", "@tailwindcss/postcss": "^4.1.18", "@types/node": "^25.0.10", "@types/react": "^19.2.10", diff --git a/examples/custom-node-server/package.json b/examples/custom-node-server/package.json index 7cc9bbc..76ba920 100644 --- a/examples/custom-node-server/package.json +++ b/examples/custom-node-server/package.json @@ -30,8 +30,8 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "^2.1.0", "@rsdoctor/rspack-plugin": "^1.5.13", "@tailwindcss/postcss": "^4.1.18", "@types/express": "^5.0.6", diff --git a/examples/default-template/package.json b/examples/default-template/package.json index 7d91fcc..f66c857 100644 --- a/examples/default-template/package.json +++ b/examples/default-template/package.json @@ -23,9 +23,9 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", + "@rsbuild/core": "2.1.0", "@rsbuild/plugin-less": "^1.6.4", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/plugin-react": "^2.1.0", "@rsbuild/plugin-sass": "^1.5.3", "@tailwindcss/postcss": "^4.1.18", "@types/node": "^25.0.10", diff --git a/examples/epic-stack/package.json b/examples/epic-stack/package.json index ae98ab1..91a86db 100644 --- a/examples/epic-stack/package.json +++ b/examples/epic-stack/package.json @@ -66,8 +66,8 @@ "@react-router/node": "^7.13.0", "@react-router/remix-routes-option-adapter": "7.13.0", "@remix-run/server-runtime": "2.17.4", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "2.1.0", "@sentry/node": "10.37.0", "@sentry/profiling-node": "10.37.0", "@sentry/react": "10.37.0", diff --git a/examples/federation/epic-stack-remote/package.json b/examples/federation/epic-stack-remote/package.json index 62f587b..c4bb8d6 100644 --- a/examples/federation/epic-stack-remote/package.json +++ b/examples/federation/epic-stack-remote/package.json @@ -70,8 +70,8 @@ "@react-router/node": "^7.13.0", "@react-router/remix-routes-option-adapter": "7.13.0", "@remix-run/server-runtime": "2.17.4", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "2.1.0", "@sentry/node": "10.37.0", "@sentry/profiling-node": "10.37.0", "@sentry/react": "10.37.0", diff --git a/examples/federation/epic-stack/package.json b/examples/federation/epic-stack/package.json index e237b13..817c90b 100644 --- a/examples/federation/epic-stack/package.json +++ b/examples/federation/epic-stack/package.json @@ -70,8 +70,8 @@ "@react-router/node": "^7.13.0", "@react-router/remix-routes-option-adapter": "7.13.0", "@remix-run/server-runtime": "2.17.4", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "2.1.0", "@sentry/node": "10.37.0", "@sentry/profiling-node": "10.37.0", "@sentry/react": "10.37.0", diff --git a/examples/prerender/package.json b/examples/prerender/package.json index 458967f..4f5a09a 100644 --- a/examples/prerender/package.json +++ b/examples/prerender/package.json @@ -21,8 +21,8 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "^2.1.0", "@tailwindcss/postcss": "^4.1.18", "@types/node": "^25.0.10", "@types/react": "^19.2.10", diff --git a/examples/spa-mode/package.json b/examples/spa-mode/package.json index b2bc006..b061a79 100644 --- a/examples/spa-mode/package.json +++ b/examples/spa-mode/package.json @@ -21,8 +21,8 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@react-router/dev": "^7.13.0", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "^2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "^2.1.0", "@tailwindcss/postcss": "^4.1.18", "@types/node": "^25.0.10", "@types/react": "^19.2.10", diff --git a/package.json b/package.json index 7fa90e6..3530570 100644 --- a/package.json +++ b/package.json @@ -91,10 +91,10 @@ "@changesets/cli": "^2.29.8", "@react-router/dev": "^7.13.0", "@rsbuild/config": "workspace:*", - "@rsbuild/core": "2.0.15", - "@rsbuild/plugin-react": "2.0.1", + "@rsbuild/core": "2.1.0", + "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "^0.22.1", - "@rspack/core": "2.0.8", + "@rspack/core": "2.1.0", "@rstest/core": "^0.8.1", "@rstest/coverage-istanbul": "^0.2.0", "@swc/helpers": "^0.5.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4f30b9..5f9ebff 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.1.0(@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 @@ -61,17 +61,17 @@ importers: specifier: workspace:* version: link:config '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: 2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@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)(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) + specifier: 2.1.0 + version: 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) '@rstest/core': specifier: ^0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -130,8 +130,8 @@ importers: config: devDependencies: '@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) + specifier: 2.1.0 + version: 2.1.0(@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) @@ -173,11 +173,11 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@types/node': specifier: ^25.0.10 version: 25.0.10 @@ -231,11 +231,11 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -292,14 +292,14 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.9.4)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rsdoctor/rspack-plugin': specifier: ^1.5.13 - version: 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + version: 1.5.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -356,17 +356,17 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rsbuild/plugin-less': specifier: ^1.6.4 - version: 1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + version: 1.6.4(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rsbuild/plugin-sass': specifier: ^1.5.3 - version: 1.5.3(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) + version: 1.5.3(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -485,11 +485,11 @@ importers: specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: 2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@sentry/node': specifier: 10.37.0 version: 10.37.0 @@ -808,13 +808,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.7.44(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.5.1(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -867,11 +867,11 @@ importers: specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: 2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@sentry/node': specifier: 10.37.0 version: 10.37.0 @@ -1172,13 +1172,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.7.44(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + version: 2.5.1(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -1231,11 +1231,11 @@ importers: specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: 2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@sentry/node': specifier: 10.37.0 version: 10.37.0 @@ -1536,11 +1536,11 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -1615,11 +1615,11 @@ importers: specifier: ^7.13.0 version: 7.13.0(@react-router/serve@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))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@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) + specifier: 2.1.0 + version: 2.1.0(@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)) + specifier: ^2.1.0 + version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -2068,21 +2068,21 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@epic-web/cachified@5.6.1': resolution: {integrity: sha512-+VKwMhqM43l2s+gX28Telcf6bUJk1Zaj0Ix2i8K4R2QW8WgPE0q3THCnr0xZg5chw35/B4SkHS43an2fqKOFnQ==} @@ -4082,6 +4082,16 @@ packages: core-js: optional: true + '@rsbuild/core@2.1.0': + resolution: {integrity: sha512-BNOp22xLGA+L8zvZ12Qg/3zhFhWFZCvZ7OcQRoGLSTw1pB9q/XDLK+WGey9SFPOtsRD3CRPXheXApxwvneH6uA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + '@rsbuild/plugin-check-syntax@1.6.1': resolution: {integrity: sha512-26xtEYN0QjZYoyt0lWnvIztBWjEZJvcfw7MN4f5B4SpNggmnF7F7aNPrgkY3EccXVFx1VGQBhnCkBV//OoS07Q==} peerDependencies: @@ -4098,10 +4108,10 @@ packages: '@rsbuild/core': optional: true - '@rsbuild/plugin-react@2.0.1': - resolution: {integrity: sha512-n5m3VxEm6m3Dv1VkI0WnxsildySJ6M+QjGIzkZDy5UebRCIJ1Q/hlQVyhofBL6C+AcsF9fGjlHQkeiteXJSr3Q==} + '@rsbuild/plugin-react@2.1.0': + resolution: {integrity: sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA==} peerDependencies: - '@rsbuild/core': ^2.0.0-0 + '@rsbuild/core': ^2.0.0 peerDependenciesMeta: '@rsbuild/core': optional: true @@ -4161,8 +4171,8 @@ packages: typescript: optional: true - '@rspack/binding-darwin-arm64@1.7.4': - resolution: {integrity: sha512-d4FTW/TkqvU9R1PsaK2tbLG1uY0gAlxy3rEiQYrFRAOVTMOFkPasypmvhwD5iWrPIhkjIi79IkgrSzRJaP2ZwA==} + '@rspack/binding-darwin-arm64@1.7.12': + resolution: {integrity: sha512-rbFprJaJiqrmfy8SHth8EsoRS0wg4bXcucwj9NiMzpGFq14Opw8c04iQ6H9BECYzgmN0PKZ9rh41LdVvhdZe4A==} cpu: [arm64] os: [darwin] @@ -4171,8 +4181,13 @@ packages: cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@1.7.4': - resolution: {integrity: sha512-Oq65S5szs3+In9hVWfPksdL6EUu1+SFZK3oQINP3kMJ5zPzrdyiue+L5ClpTU/VMKVxfQTdCBsI6OVJNnaLBiA==} + '@rspack/binding-darwin-arm64@2.1.0': + resolution: {integrity: sha512-1DdnXLCl4/7BydtxvFyJbqOyvo3dgeKIdukr5BrM7UUA5rJnpin0qZIq/C0Y+ZwTx7ML4zdYaJeR+WOujRQH1Q==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@1.7.12': + resolution: {integrity: sha512-jnOp+/UXOJa9xqUb8KXH03sysoO2e4Ij6tw6MqDdmdj8n/A8PQENRPUbW9AwXpPtVDJPus9r4fi7b3+6e4B8Hg==} cpu: [x64] os: [darwin] @@ -4181,8 +4196,13 @@ packages: cpu: [x64] os: [darwin] - '@rspack/binding-linux-arm64-gnu@1.7.4': - resolution: {integrity: sha512-sTpfCraAtYZBhdw9Xx5a19OgJ/mBELTi61utZzrO3bV6BFEulvOdmnNjpgb0xv1KATtNI8YxECohUzekk1WsOA==} + '@rspack/binding-darwin-x64@2.1.0': + resolution: {integrity: sha512-PJB6n/BaupvfLaErsfvC7q9W07WozkPe2Xw7sQqX6fblK+4tooBp0ZdAtKi76L+U2fR8t8/nQb0Jokco0co7Fg==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@1.7.12': + resolution: {integrity: sha512-C8owWG+yvo7X0oVLIXetkoJhIFBP1LYNcAQqtgLmJnQLQDklGuP83dKC+zISGQWpjawHfZ1ER96vLgoTrxKZdw==} cpu: [arm64] os: [linux] @@ -4191,8 +4211,13 @@ packages: cpu: [arm64] os: [linux] - '@rspack/binding-linux-arm64-musl@1.7.4': - resolution: {integrity: sha512-sw8jZbUe13Ry0/tnUt1pSdwkaPtSzKuveq+b6/CUT26I3DKfJQoG0uJbjj2quMe4ks3jDmoGlxuRe4D/fWUoSg==} + '@rspack/binding-linux-arm64-gnu@2.1.0': + resolution: {integrity: sha512-TCmWIeI03ZZi8GjpIS2yl9JpaazsaA4F84zbX6a4kdZnFkrmFKRdvczZrquTNQvmggAEaJiPxkSrS8OC1LSAwA==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-arm64-musl@1.7.12': + resolution: {integrity: sha512-i51WWI64aRpsfSki6rN0aepPqXkVfS+vZM7+4bWDcmnhUmdMvhIPcYg0QRk3DtyJnu33jqNLM0WHY78k00NyfA==} cpu: [arm64] os: [linux] @@ -4201,8 +4226,23 @@ packages: cpu: [arm64] os: [linux] - '@rspack/binding-linux-x64-gnu@1.7.4': - resolution: {integrity: sha512-1W6LU0wR/TxB+8pogt0pn0WRwbQmKfu9839p/VBuSkNdWR4aljAhYO6RxsLQLCLrDAqEyrpeYWsWJBvAJ4T/pA==} + '@rspack/binding-linux-arm64-musl@2.1.0': + resolution: {integrity: sha512-HJzw5gG62qjj9fRQgj948naLucwE1Vg1bfcYHAxOr1/bVVIm4I4QvWGuqvd3XOu0MfLXPWvEyMAvJL+rtgamsw==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-riscv64-gnu@2.1.0': + resolution: {integrity: sha512-B3ENZHIBi5u1Apt6RJ62QSCabCijI5l86Sm2AEDYpQnqqBj3vIc+Br9HJHvNjK8PNWs1WfmD//UTUmQqZbYpKQ==} + cpu: [riscv64] + os: [linux] + + '@rspack/binding-linux-riscv64-musl@2.1.0': + resolution: {integrity: sha512-Qho1S8bW2BKRsJjl/f39GoyPRznF8ZarIgxZdVCIkn4k+3veggKWxqR1WWKoMj/LfykQd1uG3FF6n7zy5IfxWw==} + cpu: [riscv64] + os: [linux] + + '@rspack/binding-linux-x64-gnu@1.7.12': + resolution: {integrity: sha512-MSos0FuPEefqo9V92ULd5hggKG29EkSNg1zDcypy0OkpsKh5pfjVxTLYFXgTcVyFoUQQbdG8zFBzYbwmJ8V4ew==} cpu: [x64] os: [linux] @@ -4211,8 +4251,13 @@ packages: cpu: [x64] os: [linux] - '@rspack/binding-linux-x64-musl@1.7.4': - resolution: {integrity: sha512-rkmu8qLnm/q8J14ZQZ04SnPNzdRNgzAoKJCTbnhCzcuL5k5e20LUFfGuS6j7Io1/UdVMOjz/u7R6b9h/qA1Scw==} + '@rspack/binding-linux-x64-gnu@2.1.0': + resolution: {integrity: sha512-oE2CMALLdV3QNiA3YYDZ46tDGf+WRlqu/tQ+B79JYKVwt3sI0fpzvgwPNpx/gfRKUyA0phaeYS4kyOEnpltjpA==} + cpu: [x64] + os: [linux] + + '@rspack/binding-linux-x64-musl@1.7.12': + resolution: {integrity: sha512-JcAMVKXOnjfpC3coWjCFPWD3Yl8RBw6a+IXQQ8mfRlHaHMIiOv8IfZqx15XRxMUn49CtP7Z0Na8iiAg2aKrcfw==} cpu: [x64] os: [linux] @@ -4221,16 +4266,25 @@ packages: cpu: [x64] os: [linux] - '@rspack/binding-wasm32-wasi@1.7.4': - resolution: {integrity: sha512-6BQvLbDtUVkTN5o1QYLYKAYuXavC4ER5Vn/amJEoecbM9F25MNAv28inrXs7BQ4cHSU4WW/F4yZPGnA+jUZLyw==} + '@rspack/binding-linux-x64-musl@2.1.0': + resolution: {integrity: sha512-lxTFZgsfPPyyIt/DpOH5TK2u1ZROMB+gLp/LWvYBc8FSOtmR0Gl4L/AWmJdM2yqwPfy0hgSkVicf/7k80jHuVQ==} + cpu: [x64] + os: [linux] + + '@rspack/binding-wasm32-wasi@1.7.12': + resolution: {integrity: sha512-n+ZqP6ZMc0nhOgvadg5VhEs9ojtbES80AcWeFnmGkbzIszvGSO63GKNiRkXtjJ9KFuRzytbbmsCqkUVH+Tywxg==} cpu: [wasm32] '@rspack/binding-wasm32-wasi@2.0.8': resolution: {integrity: sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==} cpu: [wasm32] - '@rspack/binding-win32-arm64-msvc@1.7.4': - resolution: {integrity: sha512-kipggu7xVPhnAkAV7koSDVbBuuMDMA4hX60DNJKTS6fId3XNHcZqWKIsWGOt0yQ6KV7I3JRRBDotKLx6uYaRWw==} + '@rspack/binding-wasm32-wasi@2.1.0': + resolution: {integrity: sha512-ZsDDduXaEF1SpyGz2OFuEU9Tzm0pKtbtCYviymiNtQS+3lx6rXyv+FaK0oIWn+gWL+gVNamplxKnNNR2jZsp5w==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@1.7.12': + resolution: {integrity: sha512-8+h5fYDXYdmugbdfZ+D1y8IQ3rv2EhSfyGP7vBe+bjNyaMa4jWrpucmZbtxojUL1AzaeuHbvMdj9UO/gelk/+g==} cpu: [arm64] os: [win32] @@ -4239,8 +4293,13 @@ packages: cpu: [arm64] os: [win32] - '@rspack/binding-win32-ia32-msvc@1.7.4': - resolution: {integrity: sha512-9Zdozc13AUQHqagDDHxHml1FnZZWuSj/uP+SxtlTlQaiIE9GDH3n0cUio1GUq+cBKbcXeiE3dJMGJxhiFaUsxA==} + '@rspack/binding-win32-arm64-msvc@2.1.0': + resolution: {integrity: sha512-0uMWAZYgwdkk0ocE4X85w/0BNWT5GaKJTEZDVYxfSYcfVAxJvIsuM0VH/cjRjsQtEeE1rcY7JvJyd0rQ6j0DqA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@1.7.12': + resolution: {integrity: sha512-cDMGwTRSa2p9fNBVe1wTRkF2AEXZ9ARWW36QeC5CkLaI0Ezz8lvhF2+CSOPnhaQ1O1qtn0L0SF+lFnrY+I7xGQ==} cpu: [ia32] os: [win32] @@ -4249,8 +4308,13 @@ packages: cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@1.7.4': - resolution: {integrity: sha512-3a/jZTUrvU340IuRcxul+ccsDtdrMaGq/vi4HNcWalL0H2xeOeuieBAV8AZqaRjmxMu8OyRcpcSrkHtN1ol/eA==} + '@rspack/binding-win32-ia32-msvc@2.1.0': + resolution: {integrity: sha512-MRuIZwF6w1tGyZgoJZ5dnpLaD/oMx8zAYSYQfNS7l0f7qjxbnp42625wkeNB8kPqrmDfqaWUWLwiOaRqFPmumA==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@1.7.12': + resolution: {integrity: sha512-wIqFvlgFqrgUyj/6S/FJcvShnkZOmIeXTfqvheLY67MGq8qd8jb1YimQVKAIrmWB3yuJKUFACI3Ag1UBtEedEA==} cpu: [x64] os: [win32] @@ -4259,14 +4323,22 @@ packages: cpu: [x64] os: [win32] - '@rspack/binding@1.7.4': - resolution: {integrity: sha512-BOACDXd9aTrdJgqa88KGxnTGdUdVLAClTCLhSvdNvQZIcaVLOB1qtW0TvqjZ19MxuQB/Cba5u/ILc5DNXxuDhg==} + '@rspack/binding-win32-x64-msvc@2.1.0': + resolution: {integrity: sha512-Fme2Ifa647CtD7N6we9xvK+COzfzVJREtUayxdG+VArPdijURZyRQVUKKlYSBW+2qMg+G+kF1xo7gf7svG3sNA==} + cpu: [x64] + os: [win32] + + '@rspack/binding@1.7.12': + resolution: {integrity: sha512-f4HHuLbvuld8Ba4iB/4ibse5XrKxFrgmM3S4P2AOKnPlekAFlBjmltCuaTL/W2ggYvILaVY+YcFXrEH1rrKeQA==} '@rspack/binding@2.0.8': resolution: {integrity: sha512-3uZ+y8aQxq33ty2srMxg2Nu0XuBI6vVrG50rkDaXqwWqOohfgGUSfFuQK7EnSUNy4aFUQlCG6NHialQHJov0wg==} - '@rspack/core@1.7.4': - resolution: {integrity: sha512-6QNqcsRSy1WbAGvjA2DAEx4yyAzwrvT6vd24Kv4xdZHdvF6FmcUbr5J+mLJ1jSOXvpNhZ+RzN37JQ8fSmytEtw==} + '@rspack/binding@2.1.0': + resolution: {integrity: sha512-LsXFIOOYDutHk44SAOcVQa5iA7lhYwEbD+nZhgmCiGJvKKh0UIpBj6EAsBsB6omEK5GEXvjDeLFieKgbYW08QQ==} + + '@rspack/core@1.7.12': + resolution: {integrity: sha512-6CwFIHlhRmXfZoMj3v9MZ1SMTPBn+cHVXeMIeaGp5sufqinKsISbsqHu6ZMJu2wDSmZLdmQJX6zLxkhcAUlhkQ==} engines: {node: '>=18.12.0'} peerDependencies: '@swc/helpers': '>=0.5.1' @@ -4286,18 +4358,21 @@ packages: '@swc/helpers': optional: true - '@rspack/lite-tapable@1.1.0': - resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} - - '@rspack/plugin-react-refresh@2.0.0': - resolution: {integrity: sha512-Cf6CxBStNDJbiXMc/GmsvG1G8PRlUpa0MSfWsMTI+e8npzuTN/p8nwLs3shriBZOLciqgkSZpBtPTd10BLpj1g==} + '@rspack/core@2.1.0': + resolution: {integrity: sha512-dlZRzWQi90HzLYErGh0/xnEWAEMEAtDKXvNxERCEj5uIVIOVu9+uYwpNyAkKc9cK5sPhOz05kk9MIb1EaUJ5gg==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - '@rspack/core': ^2.0.0-0 - react-refresh: '>=0.10.0 <1.0.0' + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 peerDependenciesMeta: - '@rspack/core': + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': optional: true + '@rspack/lite-tapable@1.1.0': + resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} + '@rspack/plugin-react-refresh@2.0.2': resolution: {integrity: sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==} peerDependencies: @@ -4737,8 +4812,8 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -5538,8 +5613,8 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.38: - resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} hasBin: true @@ -5629,8 +5704,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -6168,8 +6243,8 @@ packages: electron-to-chromium@1.5.279: resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==} - electron-to-chromium@1.5.376: - resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + electron-to-chromium@1.5.379: + resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6206,8 +6281,8 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.24.0: - resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + enhanced-resolve@5.24.1: + resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -6452,8 +6527,8 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} express-rate-limit@8.2.1: @@ -7648,8 +7723,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.48: - resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} node-schedule@2.1.1: @@ -10254,9 +10329,9 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.8.1': + '@emnapi/core@1.11.1': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true @@ -10265,17 +10340,17 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true @@ -10655,7 +10730,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -10817,7 +10892,7 @@ snapshots: - node-fetch - utf-8-validate - '@module-federation/enhanced@2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': + '@module-federation/enhanced@2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/cli': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -10826,7 +10901,7 @@ snapshots: '@module-federation/inject-external-runtime-core-plugin': 2.5.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13))) '@module-federation/managers': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/manifest': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) - '@module-federation/rspack': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) + '@module-federation/rspack': 2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/webpack-bundler-runtime': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) @@ -10870,9 +10945,9 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': + '@module-federation/node@2.7.44(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + '@module-federation/enhanced': 2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/runtime': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) encoding: 0.1.13 @@ -10887,13 +10962,13 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rsbuild-plugin@2.5.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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': + '@module-federation/rsbuild-plugin@2.5.1(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) - '@module-federation/node': 2.7.44(@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))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + '@module-federation/enhanced': 2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + '@module-federation/node': 2.7.44(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) optionalDependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10903,7 +10978,7 @@ snapshots: - vue-tsc - webpack - '@module-federation/rspack@2.5.1(@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))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)': + '@module-federation/rspack@2.5.1(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/dts-plugin': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -10912,7 +10987,7 @@ snapshots: '@module-federation/manifest': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -10993,30 +11068,30 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.1 optional: true '@napi-rs/wasm-runtime@1.0.7': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true '@nasa-gcn/remix-seo@2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3))': @@ -12216,22 +12291,31 @@ snapshots: '@rsbuild/core@1.7.2': dependencies: - '@rspack/core': 1.7.4(@swc/helpers@0.5.23) + '@rspack/core': 1.7.12(@swc/helpers@0.5.23) '@rspack/lite-tapable': 1.1.0 '@swc/helpers': 0.5.23 core-js: 3.47.0 jiti: 2.6.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)': + '@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)': + dependencies: + '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + optionalDependencies: + core-js: 3.47.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)': dependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 optionalDependencies: core-js: 3.47.0 transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))': + '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))': dependencies: acorn: 8.15.0 browserslist-to-es-version: 1.4.1 @@ -12239,30 +12323,30 @@ snapshots: picocolors: 1.1.1 source-map: 0.7.6 optionalDependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - '@rsbuild/plugin-less@1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsbuild/plugin-less@1.6.4(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: deepmerge: 4.3.1 less: 4.6.6 - less-loader: 12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)) + less-loader: 12.3.3(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)) reduce-configs: 1.1.2 optionalDependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) transitivePeerDependencies: - '@rspack/core' - webpack - '@rsbuild/plugin-react@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))': + '@rsbuild/plugin-react@2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))': dependencies: - '@rspack/plugin-react-refresh': 2.0.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))(react-refresh@0.18.0) + '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.1.0(@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) react-refresh: 0.18.0 optionalDependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) transitivePeerDependencies: - '@rspack/core' - '@rsbuild/plugin-sass@1.5.3(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))': + '@rsbuild/plugin-sass@1.5.3(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))': dependencies: deepmerge: 4.3.1 loader-utils: 2.0.4 @@ -12270,18 +12354,18 @@ snapshots: reduce-configs: 1.1.2 sass-embedded: 1.100.0 optionalDependencies: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rsdoctor/client@1.5.13': {} - '@rsdoctor/core@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/core@1.5.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rspack/resolver': 0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rspack/resolver': 0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) browserslist-load-config: 1.0.3 es-toolkit: 1.47.1 filesize: 11.0.17 @@ -12298,10 +12382,10 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/graph@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/graph@1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) es-toolkit: 1.47.1 path-browserify: 1.0.1 source-map: 0.7.6 @@ -12309,15 +12393,15 @@ snapshots: - '@rspack/core' - webpack - '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/core': 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/core': 1.5.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) 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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -12327,12 +12411,12 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/sdk@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/sdk@1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@rsdoctor/client': 1.5.13 - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) launch-editor: 2.14.1 safer-buffer: 2.1.2 socket.io: 4.8.1 @@ -12344,20 +12428,20 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/types@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/types@1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@types/connect': 3.4.38 '@types/estree': 1.0.5 '@types/tapable': 2.3.0 source-map: 0.7.6 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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) webpack: 5.97.1(lightningcss@1.30.2) - '@rsdoctor/utils@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': + '@rsdoctor/utils@1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@babel/code-frame': 7.26.2 - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@types/estree': 1.0.5 acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) @@ -12377,7 +12461,7 @@ snapshots: '@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) + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0) 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) optionalDependencies: typescript: 5.9.3 @@ -12386,43 +12470,67 @@ snapshots: - '@typescript/native-preview' - core-js - '@rspack/binding-darwin-arm64@1.7.4': + '@rspack/binding-darwin-arm64@1.7.12': optional: true '@rspack/binding-darwin-arm64@2.0.8': optional: true - '@rspack/binding-darwin-x64@1.7.4': + '@rspack/binding-darwin-arm64@2.1.0': + optional: true + + '@rspack/binding-darwin-x64@1.7.12': optional: true '@rspack/binding-darwin-x64@2.0.8': optional: true - '@rspack/binding-linux-arm64-gnu@1.7.4': + '@rspack/binding-darwin-x64@2.1.0': + optional: true + + '@rspack/binding-linux-arm64-gnu@1.7.12': optional: true '@rspack/binding-linux-arm64-gnu@2.0.8': optional: true - '@rspack/binding-linux-arm64-musl@1.7.4': + '@rspack/binding-linux-arm64-gnu@2.1.0': + optional: true + + '@rspack/binding-linux-arm64-musl@1.7.12': optional: true '@rspack/binding-linux-arm64-musl@2.0.8': optional: true - '@rspack/binding-linux-x64-gnu@1.7.4': + '@rspack/binding-linux-arm64-musl@2.1.0': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.1.0': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.1.0': + optional: true + + '@rspack/binding-linux-x64-gnu@1.7.12': optional: true '@rspack/binding-linux-x64-gnu@2.0.8': optional: true - '@rspack/binding-linux-x64-musl@1.7.4': + '@rspack/binding-linux-x64-gnu@2.1.0': + optional: true + + '@rspack/binding-linux-x64-musl@1.7.12': optional: true '@rspack/binding-linux-x64-musl@2.0.8': optional: true - '@rspack/binding-wasm32-wasi@1.7.4': + '@rspack/binding-linux-x64-musl@2.1.0': + optional: true + + '@rspack/binding-wasm32-wasi@1.7.12': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true @@ -12434,36 +12542,52 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rspack/binding-win32-arm64-msvc@1.7.4': + '@rspack/binding-wasm32-wasi@2.1.0': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rspack/binding-win32-arm64-msvc@1.7.12': optional: true '@rspack/binding-win32-arm64-msvc@2.0.8': optional: true - '@rspack/binding-win32-ia32-msvc@1.7.4': + '@rspack/binding-win32-arm64-msvc@2.1.0': + optional: true + + '@rspack/binding-win32-ia32-msvc@1.7.12': optional: true '@rspack/binding-win32-ia32-msvc@2.0.8': optional: true - '@rspack/binding-win32-x64-msvc@1.7.4': + '@rspack/binding-win32-ia32-msvc@2.1.0': + optional: true + + '@rspack/binding-win32-x64-msvc@1.7.12': optional: true '@rspack/binding-win32-x64-msvc@2.0.8': optional: true - '@rspack/binding@1.7.4': + '@rspack/binding-win32-x64-msvc@2.1.0': + optional: true + + '@rspack/binding@1.7.12': optionalDependencies: - '@rspack/binding-darwin-arm64': 1.7.4 - '@rspack/binding-darwin-x64': 1.7.4 - '@rspack/binding-linux-arm64-gnu': 1.7.4 - '@rspack/binding-linux-arm64-musl': 1.7.4 - '@rspack/binding-linux-x64-gnu': 1.7.4 - '@rspack/binding-linux-x64-musl': 1.7.4 - '@rspack/binding-wasm32-wasi': 1.7.4 - '@rspack/binding-win32-arm64-msvc': 1.7.4 - '@rspack/binding-win32-ia32-msvc': 1.7.4 - '@rspack/binding-win32-x64-msvc': 1.7.4 + '@rspack/binding-darwin-arm64': 1.7.12 + '@rspack/binding-darwin-x64': 1.7.12 + '@rspack/binding-linux-arm64-gnu': 1.7.12 + '@rspack/binding-linux-arm64-musl': 1.7.12 + '@rspack/binding-linux-x64-gnu': 1.7.12 + '@rspack/binding-linux-x64-musl': 1.7.12 + '@rspack/binding-wasm32-wasi': 1.7.12 + '@rspack/binding-win32-arm64-msvc': 1.7.12 + '@rspack/binding-win32-ia32-msvc': 1.7.12 + '@rspack/binding-win32-x64-msvc': 1.7.12 '@rspack/binding@2.0.8': optionalDependencies: @@ -12478,34 +12602,50 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.0.8 '@rspack/binding-win32-x64-msvc': 2.0.8 - '@rspack/core@1.7.4(@swc/helpers@0.5.23)': + '@rspack/binding@2.1.0': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.1.0 + '@rspack/binding-darwin-x64': 2.1.0 + '@rspack/binding-linux-arm64-gnu': 2.1.0 + '@rspack/binding-linux-arm64-musl': 2.1.0 + '@rspack/binding-linux-riscv64-gnu': 2.1.0 + '@rspack/binding-linux-riscv64-musl': 2.1.0 + '@rspack/binding-linux-x64-gnu': 2.1.0 + '@rspack/binding-linux-x64-musl': 2.1.0 + '@rspack/binding-wasm32-wasi': 2.1.0 + '@rspack/binding-win32-arm64-msvc': 2.1.0 + '@rspack/binding-win32-ia32-msvc': 2.1.0 + '@rspack/binding-win32-x64-msvc': 2.1.0 + + '@rspack/core@1.7.12(@swc/helpers@0.5.23)': dependencies: '@module-federation/runtime-tools': 0.22.0 - '@rspack/binding': 1.7.4 + '@rspack/binding': 1.7.12 '@rspack/lite-tapable': 1.1.0 optionalDependencies: '@swc/helpers': 0.5.23 - '@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/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.0.8 optionalDependencies: '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@swc/helpers': 0.5.23 - '@rspack/lite-tapable@1.1.0': {} - - '@rspack/plugin-react-refresh@2.0.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))(react-refresh@0.18.0)': + '@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)': dependencies: - react-refresh: 0.18.0 + '@rspack/binding': 2.1.0 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) + '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) + '@swc/helpers': 0.5.23 + + '@rspack/lite-tapable@1.1.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)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.1.0(@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: - '@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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) '@rspack/resolver-binding-darwin-arm64@0.2.8': optional: true @@ -12525,9 +12665,9 @@ snapshots: '@rspack/resolver-binding-linux-x64-musl@0.2.8': optional: true - '@rspack/resolver-binding-wasm32-wasi@0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@rspack/resolver-binding-wasm32-wasi@0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -12542,7 +12682,7 @@ snapshots: '@rspack/resolver-binding-win32-x64-msvc@0.2.8': optional: true - '@rspack/resolver@0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@rspack/resolver@0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': optionalDependencies: '@rspack/resolver-binding-darwin-arm64': 0.2.8 '@rspack/resolver-binding-darwin-x64': 0.2.8 @@ -12550,7 +12690,7 @@ snapshots: '@rspack/resolver-binding-linux-arm64-musl': 0.2.8 '@rspack/resolver-binding-linux-x64-gnu': 0.2.8 '@rspack/resolver-binding-linux-x64-musl': 0.2.8 - '@rspack/resolver-binding-wasm32-wasi': 0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@rspack/resolver-binding-wasm32-wasi': 0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@rspack/resolver-binding-win32-arm64-msvc': 0.2.8 '@rspack/resolver-binding-win32-ia32-msvc': 0.2.8 '@rspack/resolver-binding-win32-x64-msvc': 0.2.8 @@ -13012,7 +13152,7 @@ snapshots: tslib: 2.8.1 optional: true - '@tybys/wasm-util@0.10.2': + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true @@ -13846,7 +13986,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.38: {} + baseline-browser-mapping@2.10.40: {} baseline-browser-mapping@2.9.18: {} @@ -13966,13 +14106,13 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - browserslist@4.28.2: + browserslist@4.28.4: dependencies: - baseline-browser-mapping: 2.10.38 + baseline-browser-mapping: 2.10.40 caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.376 - node-releases: 2.0.48 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + electron-to-chromium: 1.5.379 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) buffer-from@1.1.2: {} @@ -14459,7 +14599,7 @@ snapshots: electron-to-chromium@1.5.279: {} - electron-to-chromium@1.5.376: {} + electron-to-chromium@1.5.379: {} emoji-regex@8.0.0: {} @@ -14502,7 +14642,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - enhanced-resolve@5.24.0: + enhanced-resolve@5.24.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -14917,7 +15057,7 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - expect-type@1.3.0: + expect-type@1.4.0: optional: true express-rate-limit@8.2.1(express@5.2.1): @@ -15764,11 +15904,11 @@ snapshots: leac@0.6.0: {} - less-loader@12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)): + less-loader@12.3.3(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)): dependencies: less: 4.6.6 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/core': 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) webpack: 5.97.1(lightningcss@1.30.2) less@4.6.6: @@ -16104,7 +16244,7 @@ snapshots: node-releases@2.0.27: {} - node-releases@2.0.48: {} + node-releases@2.0.50: {} node-schedule@2.1.1: dependencies: @@ -16903,7 +17043,7 @@ snapshots: 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 - '@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/core': 2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0) optionalDependencies: typescript: 5.9.3 @@ -17797,9 +17937,9 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -17961,7 +18101,7 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 @@ -18025,9 +18165,9 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.17.0 - browserslist: 4.28.2 + browserslist: 4.28.4 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.0 + enhanced-resolve: 5.24.1 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -18065,9 +18205,9 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.17.0 - browserslist: 4.28.2 + browserslist: 4.28.4 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.0 + enhanced-resolve: 5.24.1 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index bb788aa..c06bae2 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -1,4 +1,4 @@ -import type { RsbuildPluginAPI } from '@rsbuild/core'; +import type { RsbuildPluginAPI, TransformHandler } from '@rsbuild/core'; import jsesc from 'jsesc'; import { relative } from 'pathe'; import { PLUGIN_NAME } from './constants.js'; @@ -62,6 +62,28 @@ export const registerBuildOutputTransforms = ({ isSpaMode, rootRoutePath, }: RegisterBuildOutputTransformsOptions): void => { + const transformRouteModule = async (args: Parameters[0]) => + performanceProfiler.record( + args.environment?.name, + 'route:module', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeModule', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + environmentName: args.environment.name, + sourceMaps: isSourceMapEnabled( + args.environment.config.output.sourceMap + ), + ssr, + isBuild, + isSpaMode, + rootRoutePath, + }) + ); + api.processAssets( { stage: 'additional', targets: ['node'] }, ({ sources, compilation }) => { @@ -233,27 +255,19 @@ export const registerBuildOutputTransforms = ({ api.transform( { resourceQuery: /\?react-router-route/, + order: 'post', }, - async args => - performanceProfiler.record( - args.environment?.name, - 'route:module', - args.resource, - async () => - routeTransformExecutor.run({ - kind: 'routeModule', - code: args.code, - resource: args.resource, - resourcePath: args.resourcePath, - environmentName: args.environment.name, - sourceMaps: isSourceMapEnabled( - args.environment.config.output.sourceMap - ), - ssr, - isBuild, - isSpaMode, - rootRoutePath, - }) - ) + transformRouteModule + ); + + api.transform( + { + test: path => routeByFilePath.has(path), + resourceQuery: { + not: /__react-router-build-client-route|react-router-route|route-chunk=/, + }, + order: 'post', + }, + transformRouteModule ); }; diff --git a/src/manifest.ts b/src/manifest.ts index 02e68d7..3a0cb42 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -99,14 +99,20 @@ export type ReactRouterManifestForDev = { export type ReactRouterManifestStats = { assetsByChunkName?: Record; + entrypointFilesByName?: Record; }; type ReactRouterManifestStatsChunk = { files?: Iterable; }; +type ReactRouterManifestStatsEntrypoint = { + getFiles?: () => Iterable; +}; + type ReactRouterManifestStatsCompilation = { namedChunks: Iterable<[string, ReactRouterManifestStatsChunk]>; + entrypoints?: Iterable<[string, ReactRouterManifestStatsEntrypoint]>; }; type ReactRouterManifestStatsNamedChunks = @@ -114,6 +120,14 @@ type ReactRouterManifestStatsNamedChunks = get?: (chunkName: string) => ReactRouterManifestStatsChunk | undefined; }; +type ReactRouterManifestStatsEntrypoints = NonNullable< + ReactRouterManifestStatsCompilation['entrypoints'] +> & { + get?: ( + entrypointName: string + ) => ReactRouterManifestStatsEntrypoint | undefined; +}; + const orderChunkFiles = (chunkName: string, files: string[]): string[] => { const ownChunkAsset = `${chunkName}.js`; const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset)); @@ -137,8 +151,12 @@ export const createReactRouterManifestStats = ( } const assetsByChunkName: Record = {}; + const entrypointFilesByName: Record = {}; const namedChunks = compilation.namedChunks as ReactRouterManifestStatsNamedChunks; + const entrypoints = compilation.entrypoints as + | ReactRouterManifestStatsEntrypoints + | undefined; if (chunkNames && typeof namedChunks.get === 'function') { for (const chunkName of chunkNames) { @@ -159,7 +177,32 @@ export const createReactRouterManifestStats = ( } } - return { assetsByChunkName }; + if (entrypoints) { + if (chunkNames && typeof entrypoints.get === 'function') { + for (const entrypointName of chunkNames) { + const entrypoint = entrypoints.get(entrypointName); + if (!entrypoint) { + continue; + } + entrypointFilesByName[entrypointName] = Array.from( + entrypoint.getFiles?.() ?? [] + ); + } + } else { + for (const [entrypointName, entrypoint] of entrypoints) { + if (chunkNames && !chunkNames.has(entrypointName)) { + continue; + } + entrypointFilesByName[entrypointName] = Array.from( + entrypoint.getFiles?.() ?? [] + ); + } + } + } + + return Object.keys(entrypointFilesByName).length > 0 + ? { assetsByChunkName, entrypointFilesByName } + : { assetsByChunkName }; }; export type RouteManifestModuleExports = Record; @@ -256,10 +299,24 @@ export async function generateReactRouterManifestForDev( if (!assets) { return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`]; } - if (!assets.some(asset => asset.endsWith('.js'))) { - return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, ...assets]; + const entrypointCssAssets = + clientStats?.entrypointFilesByName?.[chunkName]?.filter(asset => + asset.endsWith('.css') + ) ?? []; + const cssAssets = [ + ...assets.filter(asset => asset.endsWith('.css')), + ...entrypointCssAssets, + ].filter((asset, index, all) => all.indexOf(asset) === index); + const nonCssAssets = assets.filter(asset => !asset.endsWith('.css')); + + if (!nonCssAssets.some(asset => asset.endsWith('.js'))) { + return [ + `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, + ...nonCssAssets, + ...cssAssets, + ]; } - return assets; + return [...nonCssAssets, ...cssAssets]; }; const getModulePathForChunk = (chunkName: string): string | undefined => { diff --git a/tests/build-output-transforms.test.ts b/tests/build-output-transforms.test.ts new file mode 100644 index 0000000..b080e5b --- /dev/null +++ b/tests/build-output-transforms.test.ts @@ -0,0 +1,152 @@ +import type { TransformDescriptor, TransformHandler } from '@rsbuild/core'; +import { describe, expect, it, rstest } from '@rstest/core'; +import { resolve } from 'pathe'; +import { registerBuildOutputTransforms } from '../src/build-output-transforms'; + +type TransformRegistration = { + descriptor: TransformDescriptor; + handler: TransformHandler; +}; + +const createTransformHarness = () => { + const transforms: TransformRegistration[] = []; + + return { + api: { + processAssets: rstest.fn(), + transform(descriptor: TransformDescriptor, handler: TransformHandler) { + transforms.push({ descriptor, handler }); + }, + }, + transforms, + }; +}; + +const createBaseOptions = ( + transforms: ReturnType +) => { + const appDirectory = resolve('/project/app'); + const routePath = resolve(appDirectory, 'routes/page.tsx'); + + return { + api: transforms.api as never, + resolvedServerOutput: 'module' as const, + performanceProfiler: { + record: (_environment: string, _label: string, _resource: string, run) => + run(), + }, + getLatestServerManifest: () => null, + getLatestServerManifestByBundleId: () => undefined, + routes: { + page: { id: 'page', file: 'routes/page.tsx', path: 'page' }, + }, + pluginOptions: {}, + getClientStats: () => undefined, + appDirectory, + getAssetPrefix: () => '/', + routeChunkOptions: { isBuild: true }, + routeTransformExecutor: { + run: rstest.fn(async task => ({ code: `${task.kind}:${task.code}` })), + close: rstest.fn(async () => undefined), + }, + routeByFilePath: new Map([[routePath, { id: 'page' }]]), + routeChunkConfig: { + splitRouteModules: true, + appDirectory, + rootRouteFile: 'root.tsx', + }, + isBuild: true, + splitRouteModules: true, + ssr: true, + isSpaMode: false, + rootRoutePath: resolve(appDirectory, 'root.tsx'), + routePath, + }; +}; + +const createTransformArgs = ( + routePath: string, + resourceQuery = '', + code = 'export async function loader() {}' +) => + ({ + code, + resource: `${routePath}${resourceQuery}`, + resourcePath: routePath, + resourceQuery, + environment: { + name: 'web', + config: { output: { sourceMap: false } }, + }, + }) as never; + +describe('build output transforms', () => { + it('registers post-order route-module transforms for explicit and queryless route modules', async () => { + const harness = createTransformHarness(); + const options = createBaseOptions(harness); + + registerBuildOutputTransforms(options); + + const routeModuleTransforms = harness.transforms.filter( + transform => transform.descriptor.order === 'post' + ); + + expect(routeModuleTransforms).toHaveLength(2); + expect(routeModuleTransforms[0].descriptor).toMatchObject({ + resourceQuery: /\?react-router-route/, + order: 'post', + }); + expect(routeModuleTransforms[1].descriptor).toMatchObject({ + order: 'post', + }); + expect( + (routeModuleTransforms[1].descriptor.test as (path: string) => boolean)( + options.routePath + ) + ).toBe(true); + + await routeModuleTransforms[0].handler( + createTransformArgs(options.routePath, '?react-router-route') + ); + await routeModuleTransforms[1].handler( + createTransformArgs(options.routePath) + ); + + const run = options.routeTransformExecutor.run; + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'routeModule' }) + ); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('does not match queryless route-module transforms for internal route requests', () => { + const harness = createTransformHarness(); + const options = createBaseOptions(harness); + + registerBuildOutputTransforms(options); + + const querylessRouteModuleTransform = harness.transforms.find( + transform => + transform.descriptor.order === 'post' && + typeof transform.descriptor.test === 'function' + ); + + expect(querylessRouteModuleTransform).toBeDefined(); + const predicate = querylessRouteModuleTransform!.descriptor.test as ( + path: string + ) => boolean; + expect(predicate(options.routePath)).toBe(true); + expect(predicate(resolve(options.appDirectory, 'not-a-route.tsx'))).toBe( + false + ); + + const resourceQuery = querylessRouteModuleTransform!.descriptor + .resourceQuery as { not: RegExp }; + expect(resourceQuery.not.test('?__react-router-build-client-route')).toBe( + true + ); + expect(resourceQuery.not.test('?react-router-route')).toBe(true); + expect(resourceQuery.not.test('?route-chunk=clientLoader')).toBe(true); + expect(resourceQuery.not.test('')).toBe(false); + }); +}); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 0544dda..848d35d 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -92,9 +92,11 @@ const createProcessAssetsHarness = () => { const createCompilation = ( namedChunks: Array<[string, unknown]>, - assets: Record = {} + assets: Record = {}, + entrypoints: Array<[string, unknown]> = [] ) => ({ namedChunks: new Map(namedChunks), + entrypoints: new Map(entrypoints), assets, getAsset(name: string) { const asset = assets[name]; @@ -359,4 +361,62 @@ describe('modify browser manifest plugin', () => { rmSync(root, { recursive: true, force: true }); } }); + + it('adds transitive entrypoint CSS without adding transitive JavaScript preloads', async () => { + const { root, appDir } = createTempApp(); + const harness = createProcessAssetsHarness(); + const assets = createBrowserManifestAssets(); + let manifest: unknown; + + try { + registerModifyBrowserManifestAssets( + harness.api as never, + { root: rootRoute }, + {}, + appDir, + '/', + { isBuild: true }, + { + onManifest(nextManifest) { + manifest = nextManifest; + }, + } + ); + + await harness.run({ + assets, + compilation: createCompilation( + [ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['vendor', { files: new Set(['static/js/vendor.js']) }], + ], + assets, + [ + [ + 'entry.client', + { + getFiles: () => [ + 'static/js/entry.client.js', + 'static/js/vendor.js', + 'static/css/reset.css', + 'static/css/route.css', + ], + }, + ], + ] + ), + }); + + expect(manifest).toMatchObject({ + entry: { + imports: ['/static/js/entry.client.js'], + css: ['/static/css/reset.css', '/static/css/route.css'], + }, + }); + expect((manifest as { entry: { imports: string[] } }).entry.imports).not + .toContain('/static/js/vendor.js'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); From e18f076554aedebd13c217c925dce915d3024b0e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:20:37 +0000 Subject: [PATCH 54/64] Add large benchmark fixture --- src/dev-generation.ts | 91 +++++++++++++++++++++--------------- tests/dev-generation.test.ts | 65 ++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 40 deletions(-) diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 5cf1c51..b7be66c 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -98,7 +98,8 @@ const hasRemovedCssAssetOwnership = ( next: ReactRouterDevManifestSet ): boolean => { for (const [entryName, previousManifest] of Object.entries(previous)) { - const previousOwnership = collectManifestCssAssetOwnership(previousManifest); + const previousOwnership = + collectManifestCssAssetOwnership(previousManifest); if (previousOwnership.size === 0) { continue; } @@ -121,41 +122,59 @@ const hasAddedCssAssetOwnership = ( next: ReactRouterDevManifestSet ): boolean => hasRemovedCssAssetOwnership(next, previous); +const collectManifestCssAssets = ( + manifest: ReactRouterDevManifestSet[string] +): Set => { + const assets = new Set(manifest.entry?.css ?? []); + for (const route of Object.values(manifest.routes ?? {})) { + for (const asset of route.css ?? []) { + assets.add(asset); + } + } + return assets; +}; + const normalizeManifestForCssOwnershipCheck = ( manifest: ReactRouterDevManifestSet[string] -) => ({ - entry: { - imports: manifest.entry?.imports ?? [], - module: manifest.entry?.module, - }, - routes: Object.fromEntries( - Object.entries(manifest.routes ?? {}) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([routeId, route]) => [ - routeId, - { - caseSensitive: route.caseSensitive, - clientActionModule: route.clientActionModule, - clientLoaderModule: route.clientLoaderModule, - clientMiddlewareModule: route.clientMiddlewareModule, - errorBoundary: route.hasErrorBoundary, - hasAction: route.hasAction, - hasClientAction: route.hasClientAction, - hasClientLoader: route.hasClientLoader, - hasClientMiddleware: route.hasClientMiddleware, - hasDefaultExport: route.hasDefaultExport, - hasLoader: route.hasLoader, - hydrateFallbackModule: route.hydrateFallbackModule, - id: route.id, - imports: route.imports, - index: route.index, - module: route.module, - parentId: route.parentId, - path: route.path, - }, - ]) - ), -}); +) => { + const cssAssets = collectManifestCssAssets(manifest); + const nonCssImports = (imports: string[] = []) => + imports.filter(importPath => !cssAssets.has(importPath)); + + return { + entry: { + imports: nonCssImports(manifest.entry?.imports), + module: manifest.entry?.module, + }, + routes: Object.fromEntries( + Object.entries(manifest.routes ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, route]) => [ + routeId, + { + caseSensitive: route.caseSensitive, + clientActionModule: route.clientActionModule, + clientLoaderModule: route.clientLoaderModule, + clientMiddlewareModule: route.clientMiddlewareModule, + errorBoundary: route.hasErrorBoundary, + hasAction: route.hasAction, + hasClientAction: route.hasClientAction, + hasClientLoader: route.hasClientLoader, + hasClientMiddleware: route.hasClientMiddleware, + hasDefaultExport: route.hasDefaultExport, + hasLoader: route.hasLoader, + hydrateFallbackModule: route.hydrateFallbackModule, + id: route.id, + imports: nonCssImports(route.imports), + index: route.index, + module: route.module, + parentId: route.parentId, + path: route.path, + }, + ]) + ), + }; +}; const hasOnlyCssAssetOwnershipChanges = ( previous: ReactRouterDevManifestSet, @@ -170,9 +189,7 @@ const hasOnlyCssAssetOwnershipChanges = ( const previousManifest = normalizeManifestForCssOwnershipCheck( previous[entryName] ); - const nextManifest = normalizeManifestForCssOwnershipCheck( - next[entryName] - ); + const nextManifest = normalizeManifestForCssOwnershipCheck(next[entryName]); return JSON.stringify(previousManifest) === JSON.stringify(nextManifest); }); }; diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 6a12938..9af9446 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -67,7 +67,8 @@ const createBuild = ( const createRouteManifest = ( id: string, - css: string[] + css: string[], + imports: string[] = [] ): ReactRouterDevManifest['routes'][string] => ({ id, module: `/${id}.js`, @@ -78,7 +79,7 @@ const createRouteManifest = ( hasClientMiddleware: false, hasDefaultExport: true, hasErrorBoundary: false, - imports: [], + imports, css, }); @@ -87,6 +88,7 @@ const createDevManifest = ( css: { entry?: string[]; routes?: Record; + routeImports?: Record; } = {} ): ReactRouterDevManifest => ({ version, @@ -95,7 +97,7 @@ const createDevManifest = ( routes: Object.fromEntries( Object.entries(css.routes ?? {}).map(([id, routeCss]) => [ id, - createRouteManifest(id, routeCss), + createRouteManifest(id, routeCss, css.routeImports?.[id]), ]) ), }); @@ -499,6 +501,63 @@ describe('React Router development runtime', () => { }); }); + it('publishes re-added css when route imports change with css ownership', async () => { + const onCssAssetOwnershipChanged = rstest.fn(); + const { loadBundle, runtime, warnings } = createHarness( + () => createBuild('build'), + { onCssAssetOwnershipChanged } + ); + const node = createCompilation('node'); + const cssOnlyChange: DevGraphChanges = { + web: { known: true, files: new Set(['/app/routes/about.tsx']) }, + node: { known: true, files: new Set(['/app/routes/about.tsx']) }, + }; + + const firstWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, firstWeb, 'with-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + routeImports: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, node), + noKnownChanges, + graphIdentity(firstWeb, node) + ); + + const removedCssWeb = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, removedCssWeb, 'without-css', { + routes: { 'routes/about': [] }, + }); + await runtime.finishAttempt( + createGraphStats(removedCssWeb, node), + cssOnlyChange, + graphIdentity(removedCssWeb, node, firstWeb) + ); + expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); + + const readdedCssWeb = createCompilation('web'); + const staleNode = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, readdedCssWeb, 'readded-css', { + routes: { 'routes/about': ['/assets/about.css'] }, + routeImports: { 'routes/about': ['/assets/about.css'] }, + }); + await runtime.finishAttempt( + createGraphStats(readdedCssWeb, staleNode), + cssOnlyChange, + graphIdentity(readdedCssWeb, staleNode, removedCssWeb) + ); + + expect(onCssAssetOwnershipChanged).toHaveBeenCalledTimes(2); + expect(loadBundle).toHaveBeenCalledOnce(); + expect(warnings).toEqual([]); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'readded-css' }, + }); + }); + it('rejects initial waiters on evaluation failure and recovers on a new attempt', async () => { let shouldFail = true; const { runtime } = createHarness(() => { From a3eef98cc44b7d1c04ddcb56dd85df848a1bfb2b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:21:09 +0000 Subject: [PATCH 55/64] fix react router parity gaps --- README.md | 10 ++--- .../default-template/react-router.config.ts | 4 +- src/index.ts | 34 +++++++++++++- src/react-router-config.ts | 15 ++++++- src/route-chunks.ts | 12 ----- tests/features.test.ts | 22 ++++++++-- tests/react-router-config.test.ts | 14 ++++++ tests/route-chunks.test.ts | 44 ++++++++++++++++++- tests/setup.ts | 4 +- 9 files changed, 127 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 79854ac..b66cda4 100644 --- a/README.md +++ b/README.md @@ -191,12 +191,10 @@ export default { basename: '/my-app', /** - * React Router future flags (optional). - * Example: split client route modules into separate chunks. + * Split client route module exports into separate chunks. + * @default true */ - future: { - v8_splitRouteModules: true, - }, + splitRouteModules: true, } satisfies Config; ``` @@ -749,7 +747,7 @@ React Router "Framework Mode" wraps Data Mode using a Vite plugin. This Rsbuild plugin aims to match the important behaviors without depending on Vite: - Typegen + Route Module API types (`./+types/*`) -- Route module splitting (`future.v8_splitRouteModules`) +- Route module splitting (`splitRouteModules`) - SPA mode (`ssr: false`), SSR mode, and static prerendering (`prerender`) Some Vite-specific integrations (for example Vite's environment API + critical diff --git a/examples/default-template/react-router.config.ts b/examples/default-template/react-router.config.ts index 9e79e74..1a463d0 100644 --- a/examples/default-template/react-router.config.ts +++ b/examples/default-template/react-router.config.ts @@ -4,7 +4,5 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, - future: { - v8_splitRouteModules: true, - }, + splitRouteModules: true, } satisfies Config; diff --git a/src/index.ts b/src/index.ts index e96aa2a..746f9ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,6 +122,10 @@ const ensureFederationAsyncStartup = ( } }; +const cssUrlAssetExtensions = + /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/; +const urlAssetResourceQuery = /(?:\?|&)url(?:&|$)/; + export const pluginReactRouter = ( options: PluginOptions = {} ): RsbuildPlugin => ({ @@ -228,6 +232,7 @@ export const pluginReactRouter = ( prerender: prerenderConfig, serverBuildFile, serverModuleFormat, + splitRouteModules, buildEnd, } = resolvedConfig; @@ -401,7 +406,6 @@ 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; @@ -558,7 +562,15 @@ export const pluginReactRouter = ( }; if (isBuild && splitRouteModules && route.id !== 'root') { - const source = readFileSync(routeFilePath, 'utf8'); + let source: string; + try { + source = readFileSync(routeFilePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return acc; + } + throw error; + } for (const exportName of routeChunkExportNames) { if (!source.includes(exportName)) { continue; @@ -820,6 +832,15 @@ export const pluginReactRouter = ( tools: { rspack: { name: 'web', + module: { + rules: [ + { + resourceQuery: urlAssetResourceQuery, + exclude: cssUrlAssetExtensions, + type: 'asset/resource', + }, + ], + }, ...(options.federation ? { output: { @@ -862,6 +883,15 @@ export const pluginReactRouter = ( tools: { rspack: { target: options.federation ? 'async-node' : 'node', + module: { + rules: [ + { + resourceQuery: urlAssetResourceQuery, + exclude: cssUrlAssetExtensions, + type: 'asset/resource', + }, + ], + }, externals: nodeExternals, dependencies: ['web'], externalsType: resolvedServerOutput, diff --git a/src/react-router-config.ts b/src/react-router-config.ts index 238bb31..b4ceb06 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -13,8 +13,14 @@ export type BuildEndHook = { }): void | Promise; }['bivarianceHack']; -export type Config = Omit & { +type SplitRouteModulesConfig = boolean | 'enforce'; + +export type Config = Omit< + ReactRouterConfig, + 'buildEnd' | 'splitRouteModules' +> & { buildEnd?: BuildEndHook; + splitRouteModules?: SplitRouteModulesConfig; }; type FutureConfig = { @@ -49,6 +55,7 @@ export type ResolvedReactRouterConfig = Readonly<{ serverBuildFile: NonNullable; serverBundles?: Config['serverBundles']; serverModuleFormat: NonNullable; + splitRouteModules: SplitRouteModulesConfig; ssr: NonNullable; allowedActionOrigins: string[] | false; unstable_routeConfig: RouteConfigEntry[]; @@ -60,6 +67,7 @@ const DEFAULT_CONFIG = { buildDirectory: 'build', serverBuildFile: 'index.js', serverModuleFormat: 'esm', + splitRouteModules: true, ssr: true, future: { unstable_optimizeDeps: false, @@ -152,11 +160,16 @@ export const resolveReactRouterConfig = async ( ...DEFAULT_CONFIG.future, ...(userAndPresetConfigs.future ?? {}), }; + const splitRouteModules = + userAndPresetConfigs.splitRouteModules ?? + userAndPresetConfigs.future?.v8_splitRouteModules ?? + DEFAULT_CONFIG.splitRouteModules; let resolved: ResolvedReactRouterConfig = { ...DEFAULT_CONFIG, ...userAndPresetConfigs, future: resolvedFuture, + splitRouteModules, allowedActionOrigins: userAndPresetConfigs.allowedActionOrigins ?? DEFAULT_CONFIG.allowedActionOrigins, diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 2fa1cdd..ce0be88 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -208,13 +208,6 @@ const getExportDependencies = ( const exportDependencies = new Map(); const topLevelStatementCache = new Map(); const variableDeclaratorCache = new Map(); - const sharedTopLevelSideEffects = (module.ast as AnyNode).body.filter( - (statement: AnyNode) => - (statement.type === 'ImportDeclaration' && - statement.specifiers.length === 0) || - statement.type === 'ExpressionStatement' - ); - const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => { const cached = topLevelStatementCache.get(node); if (cached) { @@ -326,11 +319,6 @@ const getExportDependencies = ( scanNode(statement); } - for (const statement of sharedTopLevelSideEffects) { - dependencies.topLevelStatements.add(statement); - dependencies.topLevelNonModuleStatements.add(statement); - } - exportDependencies.set(exportName, dependencies); }; diff --git a/tests/features.test.ts b/tests/features.test.ts index 9f122c0..cd0744a 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -160,8 +160,7 @@ describe('pluginReactRouter', () => { expect(routeTransform).toBeDefined(); }); - it('should register build and dot file transforms', async () => { - process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + it('should register build, dot file, and route chunk transforms by default', async () => { const readFileSync = rstest .spyOn(fs, 'readFileSync') .mockReturnValue('export default function Route() { return null; }'); @@ -174,7 +173,6 @@ describe('pluginReactRouter', () => { const plugin = pluginReactRouter(); await plugin.setup(rsbuild as any); } finally { - delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; readFileSync.mockRestore(); } @@ -230,6 +228,24 @@ describe('pluginReactRouter', () => { }); describe('asset handling', () => { + it('should treat non-CSS ?url imports as emitted assets in web and node builds', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + const getRules = (name: 'web' | 'node') => + config.environments?.[name]?.tools?.rspack?.module?.rules ?? []; + const hasUrlAssetRule = (rule: any) => + rule.resourceQuery?.toString().includes('url') && + rule.exclude?.test('app/styles.css') && + rule.type === 'asset/resource'; + + expect(getRules('web').some(hasUrlAssetRule)).toBe(true); + expect(getRules('node').some(hasUrlAssetRule)).toBe(true); + }); + it('should emit package.json for node environment', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index b7ead78..084b4f8 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -51,4 +51,18 @@ describe('resolveReactRouterConfig', () => { expect(defaultResult.hasConfiguredServerModuleFormat).toBe(false); expect(configuredResult.hasConfiguredServerModuleFormat).toBe(true); }); + + it('defaults route module splitting on and respects the stable top-level option', async () => { + const defaultResult = await resolveReactRouterConfig({}); + const disabledResult = await resolveReactRouterConfig({ + splitRouteModules: false, + } as any); + const enforcedResult = await resolveReactRouterConfig({ + splitRouteModules: 'enforce', + } as any); + + expect(defaultResult.resolved.splitRouteModules).toBe(true); + expect(disabledResult.resolved.splitRouteModules).toBe(false); + expect(enforcedResult.resolved.splitRouteModules).toBe('enforce'); + }); }); diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index da7f237..4b53e43 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -271,7 +271,7 @@ describe('route chunks', () => { ]); }); - it('does not split client exports away from top-level side effects', async () => { + it('keeps top-level side effects in the main chunk while splitting independent client exports', async () => { const code = ` import './polyfill'; initialize(); @@ -281,7 +281,19 @@ describe('route chunks', () => { const result = await detect(code); - expectNoRouteChunks(result, ['clientAction', 'default']); + expectOnlyChunkedExport(result, 'clientAction'); + }); + + it('keeps side-effect imports in the main chunk while splitting independent client exports', async () => { + const code = ` + import './polyfill'; + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientAction'); }); }); @@ -360,6 +372,34 @@ describe('route chunks', () => { await expectExports(chunk, ['clientLoader'], ['default']); }); + it('keeps side-effect imports in the main chunk and omits them from individual client chunks', async () => { + const code = ` + import './polyfill'; + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const mainChunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + code + ); + const clientActionChunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientAction', + code + ); + + expect(mainChunk).toContain("import './polyfill'"); + await expectExports(mainChunk, ['default'], ['clientAction']); + expect(clientActionChunk).not.toContain('polyfill'); + await expectExports(clientActionChunk, ['clientAction'], ['default']); + }); + it('returns null for the main chunk when only client exports exist', async () => { const code = ` export const clientAction = async () => {}; diff --git a/tests/setup.ts b/tests/setup.ts index cc1f73f..463ed4a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -31,9 +31,7 @@ rstest.mock('jiti', () => ({ } if (process.env.RR_TEST_SPLIT_ROUTE_MODULES === 'true') { return Promise.resolve({ - future: { - v8_splitRouteModules: true, - }, + splitRouteModules: true, }); } return Promise.resolve({}); From aa6974db780f8327141071bbf726a6e4c6a0e4df Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:10:14 +0000 Subject: [PATCH 56/64] chore: trigger benchmark ci From 470c3c82a863a98b10a46baf7544aea065d556e5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:38:41 +0000 Subject: [PATCH 57/64] fix: stabilize route artifact reexport order --- src/route-artifacts.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 6fda786..58dd4de 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -55,15 +55,17 @@ export const buildRouteClientEntryCode = ({ }): string => { const chunkedExportSet = chunkedExports.length > 0 ? new Set(chunkedExports) : undefined; - 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 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)) + ); + }) + .sort(); const target = `${resourcePath}?react-router-route`; return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`; }; From b0a7155fc0ce27f918df1a2ce6ff30a2421931b5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:28:13 +0000 Subject: [PATCH 58/64] fix config parity gaps --- src/config-dependencies.ts | 82 +++++++++++++++++++++++++++ src/index.ts | 11 +++- src/modify-browser-manifest.ts | 4 +- src/prerender.ts | 14 +++-- src/react-router-config.ts | 10 +++- tests/config-dependencies.test.ts | 41 ++++++++++++++ tests/index.test.ts | 65 +++++++++++++++++++++ tests/modify-browser-manifest.test.ts | 2 +- tests/prerender.test.ts | 18 +++++- tests/react-router-config.test.ts | 10 ++++ 10 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 src/config-dependencies.ts create mode 100644 tests/config-dependencies.test.ts diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts new file mode 100644 index 0000000..878dc3b --- /dev/null +++ b/src/config-dependencies.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'pathe'; +import { init, parse } from 'es-module-lexer'; +import { JS_EXTENSIONS } from './constants.js'; + +const requireDependencyRE = + /\brequire\s*\(\s*(['"])(?\.{1,2}\/[^'"]+)\1\s*\)/g; + +const resolveDependencyFile = ( + importerPath: string, + specifier: string +): string | undefined => { + if (!specifier.startsWith('.')) { + return undefined; + } + + const absolutePath = resolve(dirname(importerPath), specifier); + const candidates = [absolutePath]; + for (const extension of JS_EXTENSIONS) { + candidates.push(`${absolutePath}${extension}`); + } + for (const extension of JS_EXTENSIONS) { + candidates.push(resolve(absolutePath, `index${extension}`)); + } + + return candidates.find(candidate => { + try { + return existsSync(candidate) && statSync(candidate).isFile(); + } catch { + return false; + } + }); +}; + +const collectDependencySpecifiers = async ( + filePath: string +): Promise => { + let source: string; + try { + source = readFileSync(filePath, 'utf8'); + } catch { + return []; + } + await init; + const [imports] = parse(source); + const specifiers = imports + .map(importSpecifier => importSpecifier.n) + .filter((specifier): specifier is string => Boolean(specifier)); + + for (const match of source.matchAll(requireDependencyRE)) { + const specifier = match.groups?.specifier; + if (specifier) { + specifiers.push(specifier); + } + } + + return specifiers; +}; + +export const collectConfigDependencyWatchPaths = async ( + configPath: string +): Promise => { + const dependencies: string[] = []; + const visited = new Set([configPath]); + const queue = [configPath]; + + while (queue.length > 0) { + const currentPath = queue.shift()!; + for (const specifier of await collectDependencySpecifiers(currentPath)) { + const dependencyPath = resolveDependencyFile(currentPath, specifier); + if (!dependencyPath || visited.has(dependencyPath)) { + continue; + } + + visited.add(dependencyPath); + dependencies.push(dependencyPath); + queue.push(dependencyPath); + } + } + + return dependencies; +}; diff --git a/src/index.ts b/src/index.ts index 746f9ab..dbdf268 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,7 @@ import { import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; import { registerReactRouterTypegen } from './typegen.js'; +import { collectConfigDependencyWatchPaths } from './config-dependencies.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; @@ -187,8 +188,13 @@ export const pluginReactRouter = ( // Read the react-router.config file first (supports .ts, .js, .mjs, etc.) const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); + const configDependencyWatchPaths = configExists + ? await collectConfigDependencyWatchPaths(configPath) + : []; const configWatchPaths = configExists - ? configPath + ? configDependencyWatchPaths.length > 0 + ? [configPath, ...configDependencyWatchPaths] + : configPath : JS_EXTENSIONS.map(extension => resolve(`react-router.config${extension}`) ); @@ -233,6 +239,7 @@ export const pluginReactRouter = ( serverBuildFile, serverModuleFormat, splitRouteModules, + subResourceIntegrity, buildEnd, } = resolvedConfig; @@ -961,7 +968,7 @@ export const pluginReactRouter = ( () => assetPrefix, routeChunkOptions, { - future, + subResourceIntegrity, manifestChunkNames, onManifest: (manifest, sri, moduleExportsByRouteId, context) => stageLatestManifests( diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 11311ac..69066c3 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -13,6 +13,7 @@ import jsesc from 'jsesc'; type ModifyBrowserManifestOptions = { future?: { unstable_subResourceIntegrity?: boolean }; + subResourceIntegrity?: boolean; manifestChunkNames?: ReadonlySet; onManifest?: ( manifest: Awaited>, @@ -80,7 +81,8 @@ export function registerModifyBrowserManifestAssets( ); const finalizeSri = routeChunkOptions?.isBuild && - options?.future?.unstable_subResourceIntegrity; + (options?.subResourceIntegrity ?? + options?.future?.unstable_subResourceIntegrity); const generatedManifests = finalizeSri ? new WeakMap() : undefined; diff --git a/src/prerender.ts b/src/prerender.ts index db17003..f2cb0c5 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -15,6 +15,7 @@ type PrerenderPathsConfig = type PrerenderConfigObject = { paths?: PrerenderPathsConfig; + concurrency?: number; unstable_concurrency?: number; } | null; @@ -287,9 +288,11 @@ export const getPrerenderConcurrency = ( if ( typeof prerender === 'object' && prerender !== null && - 'unstable_concurrency' in prerender + ('concurrency' in prerender || 'unstable_concurrency' in prerender) ) { - const value = (prerender as PrerenderConfigObject)?.unstable_concurrency; + const value = + (prerender as PrerenderConfigObject)?.concurrency ?? + (prerender as PrerenderConfigObject)?.unstable_concurrency; if (typeof value === 'number' && Number.isInteger(value) && value > 0) { return value; } @@ -332,15 +335,16 @@ export const validatePrerenderConfig = ( const concurrency = typeof prerender === 'object' && prerender !== null && - 'unstable_concurrency' in prerender - ? (prerender as PrerenderConfigObject)?.unstable_concurrency + ('concurrency' in prerender || 'unstable_concurrency' in prerender) + ? ((prerender as PrerenderConfigObject)?.concurrency ?? + (prerender as PrerenderConfigObject)?.unstable_concurrency) : undefined; if ( concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency <= 0) ) { - return 'The `prerender.unstable_concurrency` config must be a positive integer if specified.'; + return 'The `prerender.concurrency` config must be a positive integer if specified.'; } return null; diff --git a/src/react-router-config.ts b/src/react-router-config.ts index b4ceb06..8f2ff76 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -17,10 +17,11 @@ type SplitRouteModulesConfig = boolean | 'enforce'; export type Config = Omit< ReactRouterConfig, - 'buildEnd' | 'splitRouteModules' + 'buildEnd' | 'splitRouteModules' | 'subResourceIntegrity' > & { buildEnd?: BuildEndHook; splitRouteModules?: SplitRouteModulesConfig; + subResourceIntegrity?: boolean; }; type FutureConfig = { @@ -56,6 +57,7 @@ export type ResolvedReactRouterConfig = Readonly<{ serverBundles?: Config['serverBundles']; serverModuleFormat: NonNullable; splitRouteModules: SplitRouteModulesConfig; + subResourceIntegrity: boolean; ssr: NonNullable; allowedActionOrigins: string[] | false; unstable_routeConfig: RouteConfigEntry[]; @@ -68,6 +70,7 @@ const DEFAULT_CONFIG = { serverBuildFile: 'index.js', serverModuleFormat: 'esm', splitRouteModules: true, + subResourceIntegrity: false, ssr: true, future: { unstable_optimizeDeps: false, @@ -164,12 +167,17 @@ export const resolveReactRouterConfig = async ( userAndPresetConfigs.splitRouteModules ?? userAndPresetConfigs.future?.v8_splitRouteModules ?? DEFAULT_CONFIG.splitRouteModules; + const subResourceIntegrity = + userAndPresetConfigs.subResourceIntegrity ?? + userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? + DEFAULT_CONFIG.subResourceIntegrity; let resolved: ResolvedReactRouterConfig = { ...DEFAULT_CONFIG, ...userAndPresetConfigs, future: resolvedFuture, splitRouteModules, + subResourceIntegrity, allowedActionOrigins: userAndPresetConfigs.allowedActionOrigins ?? DEFAULT_CONFIG.allowedActionOrigins, diff --git a/tests/config-dependencies.test.ts b/tests/config-dependencies.test.ts new file mode 100644 index 0000000..3eaea48 --- /dev/null +++ b/tests/config-dependencies.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { collectConfigDependencyWatchPaths } from '../src/config-dependencies'; + +describe('collectConfigDependencyWatchPaths', () => { + it('recursively collects relative config imports and requires', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-config-deps-')); + + try { + const configPath = join(root, 'react-router.config.ts'); + const serverBundlesPath = join(root, 'config/server-bundles.ts'); + const sharedPath = join(root, 'config/shared.js'); + + mkdirSync(join(root, 'config')); + writeFileSync( + configPath, + [ + "import { serverBundles } from './config/server-bundles';", + "const shared = require('./config/shared.js');", + 'export default { serverBundles, basename: shared.basename };', + ].join('\n') + ); + writeFileSync( + serverBundlesPath, + [ + "export { bundleId } from './shared.js';", + "export const serverBundles = async () => 'main';", + ].join('\n') + ); + writeFileSync(sharedPath, "export const basename = '/app';"); + + await expect(collectConfigDependencyWatchPaths(configPath)).resolves.toEqual( + [serverBundlesPath, sharedPath] + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index d7d383b..feeed24 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -103,6 +103,71 @@ describe('pluginReactRouter', () => { ); }); + it('reloads the dev server when imported config dependencies change', async () => { + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockImplementation(path => { + const filePath = String(path); + if (filePath.endsWith('react-router.config.ts')) { + return "import './config/server-bundles'; export default {};"; + } + if (filePath.endsWith('config/server-bundles.ts')) { + return 'export const value = 1;'; + } + return ''; + }); + const statSync = rstest.spyOn(fs, 'statSync').mockImplementation(path => { + const filePath = String(path); + if ( + filePath.endsWith('react-router.config.ts') || + filePath.endsWith('config/server-bundles.ts') + ) { + return { isFile: () => true } as fs.Stats; + } + throw new Error(`Missing test file: ${filePath}`); + }); + const existsSyncMock = fs.existsSync as unknown as { + mockImplementation: (implementation: (path: unknown) => boolean) => void; + mockReturnValue: (value: boolean) => void; + }; + existsSyncMock.mockImplementation(path => { + const filePath = String(path); + if (filePath.includes('react-router.config')) { + return filePath.endsWith('react-router.config.ts'); + } + if (filePath.includes('config/server-bundles')) { + return filePath.endsWith('config/server-bundles.ts'); + } + return ( + filePath.endsWith('app/routes.ts') || + filePath.endsWith('app/root.tsx') + ); + }); + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + const configWatch = config.dev.watchFiles.find( + (watchFile: { paths: unknown }) => Array.isArray(watchFile.paths) + ); + expect(configWatch).toMatchObject({ + paths: expect.arrayContaining([ + expect.stringMatching(/config\/server-bundles\.ts$/), + ]), + type: 'reload-server', + }); + } finally { + readFileSync.mockRestore(); + statSync.mockRestore(); + existsSyncMock.mockReturnValue(true); + } + }); + it('watches all supported config filenames when the config does not exist yet', async () => { const existsSyncMock = fs.existsSync as unknown as { mockImplementation: (implementation: (path: unknown) => boolean) => void; diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 848d35d..f7295b0 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -229,7 +229,7 @@ describe('modify browser manifest plugin', () => { '/', { isBuild: true }, { - future: { unstable_subResourceIntegrity: true }, + subResourceIntegrity: true, onManifest(_manifest, sri) { reportedSri = sri; }, diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 2e827c6..7d6c617 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -6,6 +6,7 @@ import { getSsrFalsePrerenderExportErrors, normalizePrerenderMatchPath, resolvePrerenderPaths, + validatePrerenderConfig, withBuildRequest, } from '../src/prerender'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -92,14 +93,25 @@ describe('prerender helpers', () => { }); it('supports prerender concurrency config', () => { - expect( - getPrerenderConcurrency({ paths: ['/'], unstable_concurrency: 3 }) - ).toBe(3); + expect(getPrerenderConcurrency({ paths: ['/'], concurrency: 3 } as any)).toBe( + 3 + ); expect(getPrerenderConcurrency({ paths: ['/'] }, 24)).toBe(1); expect(getPrerenderConcurrency({ paths: ['/'] }, 3)).toBe(1); expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(1); }); + it('validates stable prerender concurrency config', () => { + expect( + validatePrerenderConfig({ paths: ['/'], concurrency: 2 } as any) + ).toBeNull(); + expect( + validatePrerenderConfig({ paths: ['/'], concurrency: 0 } as any) + ).toBe( + 'The `prerender.concurrency` config must be a positive integer if specified.' + ); + }); + it('creates React Router match routes from a route manifest', () => { expect( createPrerenderRoutes({ diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index 084b4f8..6bed465 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -65,4 +65,14 @@ describe('resolveReactRouterConfig', () => { expect(disabledResult.resolved.splitRouteModules).toBe(false); expect(enforcedResult.resolved.splitRouteModules).toBe('enforce'); }); + + it('resolves stable subresource integrity from top-level config', async () => { + const defaultResult = await resolveReactRouterConfig({}); + const enabledResult = await resolveReactRouterConfig({ + subResourceIntegrity: true, + } as any); + + expect(defaultResult.resolved.subResourceIntegrity).toBe(false); + expect(enabledResult.resolved.subResourceIntegrity).toBe(true); + }); }); From 501f80b2702a63e12d080980621cc900c26b8331 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:56:00 +0000 Subject: [PATCH 59/64] chore: clean source map warning tests --- src/warnings/warn-on-client-source-maps.ts | 6 ++-- tests/warn-on-client-source-maps.test.ts | 33 +++++++++++++--------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/warnings/warn-on-client-source-maps.ts b/src/warnings/warn-on-client-source-maps.ts index 9696bf6..25c818a 100644 --- a/src/warnings/warn-on-client-source-maps.ts +++ b/src/warnings/warn-on-client-source-maps.ts @@ -2,6 +2,7 @@ import type { NormalizedConfig } from '@rsbuild/core'; type Warn = (message: string) => void; type ToolsRspackConfig = NonNullable['rspack']; +type SourceMapConfigObject = { js?: unknown }; function isProdBuild(mode?: string): boolean { // Prefer Rsbuild's normalized `mode` (explicit) and fall back to NODE_ENV. @@ -16,7 +17,7 @@ export function isSourceMapEnabled(value: unknown): boolean { if (value === false || value == null) return false; if (typeof value === 'string') return true; if (typeof value === 'object') { - const js = (value as any).js; + const js = (value as SourceMapConfigObject).js; // Any truthy devtool string/object means source maps are on for JS. return Boolean(js); } @@ -30,8 +31,7 @@ function isDevtoolSourceMap(value: unknown): boolean { return value.includes('source-map'); } // Unknown object shape - treat as enabled to be safe. - if (typeof value === 'object') return true; - return false; + return typeof value === 'object'; } export function getClientSourceMapSetting( diff --git a/tests/warn-on-client-source-maps.test.ts b/tests/warn-on-client-source-maps.test.ts index d9a83a6..40b600b 100644 --- a/tests/warn-on-client-source-maps.test.ts +++ b/tests/warn-on-client-source-maps.test.ts @@ -1,18 +1,23 @@ import { describe, expect, it, rstest } from '@rstest/core'; +import type { NormalizedConfig } from '@rsbuild/core'; import { isSourceMapEnabled, warnOnClientSourceMaps, } from '../src/warnings/warn-on-client-source-maps'; +const normalizedConfig = ( + config: Record +): NormalizedConfig => config as NormalizedConfig; + describe('warnOnClientSourceMaps', () => { it('does not warn in non-production mode', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'development', output: { sourceMap: { js: 'source-map', css: false } }, environments: {}, - } as any, + }), warn ); expect(warn).not.toHaveBeenCalled(); @@ -21,11 +26,11 @@ describe('warnOnClientSourceMaps', () => { it('warns when web environment source maps are enabled in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: false }, environments: { web: { output: { sourceMap: { js: 'source-map' } } } }, - } as any, + }), warn ); expect(warn).toHaveBeenCalledTimes(1); @@ -37,11 +42,11 @@ describe('warnOnClientSourceMaps', () => { it('warns when output.sourceMap is true in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: true }, environments: {}, - } as any, + }), warn ); expect(warn).toHaveBeenCalledTimes(1); @@ -50,11 +55,11 @@ describe('warnOnClientSourceMaps', () => { it('warns when output.sourceMap is a string in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: 'source-map' }, environments: {}, - } as any, + }), warn ); expect(warn).toHaveBeenCalledTimes(1); @@ -68,11 +73,11 @@ describe('warnOnClientSourceMaps', () => { it('does not warn when source maps are disabled in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: false }, environments: { web: { output: { sourceMap: false } } }, - } as any, + }), warn ); expect(warn).not.toHaveBeenCalled(); @@ -80,12 +85,12 @@ describe('warnOnClientSourceMaps', () => { it('warns when rspack devtool enables source maps in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: false }, tools: { rspack: { devtool: 'source-map' } }, environments: {}, - } as any, + }), warn ); expect(warn).toHaveBeenCalledTimes(1); @@ -94,13 +99,13 @@ describe('warnOnClientSourceMaps', () => { it('warns when web environment devtool enables source maps in production', () => { const warn = rstest.fn(); warnOnClientSourceMaps( - { + normalizedConfig({ mode: 'production', output: { sourceMap: false }, environments: { web: { tools: { rspack: { devtool: 'inline-source-map' } } }, }, - } as any, + }), warn ); expect(warn).toHaveBeenCalledTimes(1); From 62a50611c99b1f9fba39587c3236670832251a7c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:57:29 +0000 Subject: [PATCH 60/64] chore: simplify config dependency watch cleanup --- src/config-dependencies.ts | 4 ++-- src/index.ts | 23 +++++++++++++---------- tests/index.test.ts | 16 ++++------------ tests/react-router-config.test.ts | 2 +- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts index 878dc3b..c65f484 100644 --- a/src/config-dependencies.ts +++ b/src/config-dependencies.ts @@ -64,8 +64,8 @@ export const collectConfigDependencyWatchPaths = async ( const visited = new Set([configPath]); const queue = [configPath]; - while (queue.length > 0) { - const currentPath = queue.shift()!; + for (let index = 0; index < queue.length; index += 1) { + const currentPath = queue[index]; for (const specifier of await collectDependencySpecifiers(currentPath)) { const dependencyPath = resolveDependencyFile(currentPath, specifier); if (!dependencyPath || visited.has(dependencyPath)) { diff --git a/src/index.ts b/src/index.ts index dbdf268..40c2e75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -188,16 +188,19 @@ export const pluginReactRouter = ( // Read the react-router.config file first (supports .ts, .js, .mjs, etc.) const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); - const configDependencyWatchPaths = configExists - ? await collectConfigDependencyWatchPaths(configPath) - : []; - const configWatchPaths = configExists - ? configDependencyWatchPaths.length > 0 - ? [configPath, ...configDependencyWatchPaths] - : configPath - : JS_EXTENSIONS.map(extension => - resolve(`react-router.config${extension}`) - ); + let configWatchPaths: string | string[]; + if (configExists) { + const dependencyWatchPaths = + await collectConfigDependencyWatchPaths(configPath); + configWatchPaths = + dependencyWatchPaths.length > 0 + ? [configPath, ...dependencyWatchPaths] + : configPath; + } else { + configWatchPaths = JS_EXTENSIONS.map(extension => + resolve(`react-router.config${extension}`) + ); + } let reactRouterUserConfig: Config = {}; if (!configExists) { console.warn( diff --git a/tests/index.test.ts b/tests/index.test.ts index feeed24..462e1e4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -126,11 +126,7 @@ describe('pluginReactRouter', () => { } throw new Error(`Missing test file: ${filePath}`); }); - const existsSyncMock = fs.existsSync as unknown as { - mockImplementation: (implementation: (path: unknown) => boolean) => void; - mockReturnValue: (value: boolean) => void; - }; - existsSyncMock.mockImplementation(path => { + const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation(path => { const filePath = String(path); if (filePath.includes('react-router.config')) { return filePath.endsWith('react-router.config.ts'); @@ -164,16 +160,12 @@ describe('pluginReactRouter', () => { } finally { readFileSync.mockRestore(); statSync.mockRestore(); - existsSyncMock.mockReturnValue(true); + existsSync.mockReturnValue(true); } }); it('watches all supported config filenames when the config does not exist yet', async () => { - const existsSyncMock = fs.existsSync as unknown as { - mockImplementation: (implementation: (path: unknown) => boolean) => void; - mockReturnValue: (value: boolean) => void; - }; - existsSyncMock.mockImplementation( + const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation( path => !String(path).includes('react-router.config') ); @@ -200,7 +192,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }); } finally { - existsSyncMock.mockReturnValue(true); + existsSync.mockReturnValue(true); } }); diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index 6bed465..951dbb7 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -70,7 +70,7 @@ describe('resolveReactRouterConfig', () => { const defaultResult = await resolveReactRouterConfig({}); const enabledResult = await resolveReactRouterConfig({ subResourceIntegrity: true, - } as any); + }); expect(defaultResult.resolved.subResourceIntegrity).toBe(false); expect(enabledResult.resolved.subResourceIntegrity).toBe(true); From a79f16f8899c90735012ffc568c43d2e3efe045e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:30:07 +0000 Subject: [PATCH 61/64] fix asset query parity --- src/index.ts | 3 ++- tests/features.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 40c2e75..cc56ff3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,7 +125,8 @@ const ensureFederationAsyncStartup = ( const cssUrlAssetExtensions = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/; -const urlAssetResourceQuery = /(?:\?|&)url(?:&|$)/; +const urlAssetResourceQuery = + /^(?=.*(?:\?|&)url(?:&|$))(?!.*(?:\?|&)(?:raw|inline)(?:&|$))/; export const pluginReactRouter = ( options: PluginOptions = {} diff --git a/tests/features.test.ts b/tests/features.test.ts index cd0744a..dbb0193 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -240,6 +240,14 @@ describe('pluginReactRouter', () => { const hasUrlAssetRule = (rule: any) => rule.resourceQuery?.toString().includes('url') && rule.exclude?.test('app/styles.css') && + rule.resourceQuery?.test('?url') && + rule.resourceQuery?.test('?foo=bar&url') && + !rule.resourceQuery?.test('?raw') && + !rule.resourceQuery?.test('?inline') && + !rule.resourceQuery?.test('?url&raw') && + !rule.resourceQuery?.test('?raw&url') && + !rule.resourceQuery?.test('?url&inline') && + !rule.resourceQuery?.test('?inline&url') && rule.type === 'asset/resource'; expect(getRules('web').some(hasUrlAssetRule)).toBe(true); From 8b08bd7f16013f33b1600b846475760604af9ba0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:57:29 +0000 Subject: [PATCH 62/64] chore: simplify asset query parity test --- tests/features.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/features.test.ts b/tests/features.test.ts index dbb0193..85f7d48 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -237,17 +237,20 @@ describe('pluginReactRouter', () => { const config = await rsbuild.unwrapConfig(); const getRules = (name: 'web' | 'node') => config.environments?.[name]?.tools?.rspack?.module?.rules ?? []; + const includedQueries = ['?url', '?foo=bar&url']; + const excludedQueries = [ + '?raw', + '?inline', + '?url&raw', + '?raw&url', + '?url&inline', + '?inline&url', + ]; const hasUrlAssetRule = (rule: any) => rule.resourceQuery?.toString().includes('url') && rule.exclude?.test('app/styles.css') && - rule.resourceQuery?.test('?url') && - rule.resourceQuery?.test('?foo=bar&url') && - !rule.resourceQuery?.test('?raw') && - !rule.resourceQuery?.test('?inline') && - !rule.resourceQuery?.test('?url&raw') && - !rule.resourceQuery?.test('?raw&url') && - !rule.resourceQuery?.test('?url&inline') && - !rule.resourceQuery?.test('?inline&url') && + includedQueries.every(query => rule.resourceQuery?.test(query)) && + excludedQueries.every(query => !rule.resourceQuery?.test(query)) && rule.type === 'asset/resource'; expect(getRules('web').some(hasUrlAssetRule)).toBe(true); From dc5025f6dafdd5439a942b0f5d2b7a404f423c9b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:32:29 +0000 Subject: [PATCH 63/64] fix route metadata dev reloads --- src/dev-generation.ts | 70 ++++++++++++++++++++++++++++ src/dev-runtime-controller.ts | 6 +++ tests/dev-generation.test.ts | 60 ++++++++++++++++++++++-- tests/dev-runtime-controller.test.ts | 51 +++++++++++++++++++- 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/src/dev-generation.ts b/src/dev-generation.ts index b7be66c..35d94b7 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -75,6 +75,7 @@ type CreateReactRouterDevRuntimeOptions = { buildPlan: ReactRouterDevBuildPlan; onEvaluationError: (error: Error) => void; onCssAssetOwnershipChanged?: () => void; + onRouteManifestChanged?: () => void; onWarning?: (message: string) => void; }; @@ -194,6 +195,53 @@ const hasOnlyCssAssetOwnershipChanges = ( }); }; +const normalizeManifestForRouteMetadataCheck = ( + manifest: ReactRouterDevManifestSet[string] +) => + Object.fromEntries( + Object.entries(manifest.routes ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, route]) => [ + routeId, + { + caseSensitive: route.caseSensitive, + clientActionModule: route.clientActionModule, + clientLoaderModule: route.clientLoaderModule, + clientMiddlewareModule: route.clientMiddlewareModule, + errorBoundary: route.hasErrorBoundary, + hasAction: route.hasAction, + hasClientAction: route.hasClientAction, + hasClientLoader: route.hasClientLoader, + hasClientMiddleware: route.hasClientMiddleware, + hasDefaultExport: route.hasDefaultExport, + hasLoader: route.hasLoader, + hydrateFallbackModule: route.hydrateFallbackModule, + id: route.id, + index: route.index, + parentId: route.parentId, + path: route.path, + }, + ]) + ); + +const hasRouteManifestMetadataChanges = ( + previous: ReactRouterDevManifestSet, + next: ReactRouterDevManifestSet +): boolean => { + const previousEntryNames = Object.keys(previous).sort(); + const nextEntryNames = Object.keys(next).sort(); + if (previousEntryNames.join('\0') !== nextEntryNames.join('\0')) { + return true; + } + return previousEntryNames.some(entryName => { + const previousRoutes = normalizeManifestForRouteMetadataCheck( + previous[entryName] + ); + const nextRoutes = normalizeManifestForRouteMetadataCheck(next[entryName]); + return JSON.stringify(previousRoutes) !== JSON.stringify(nextRoutes); + }); +}; + const createDeferred = (): Deferred => { let resolve!: (value: T) => void; let reject!: (error: Error) => void; @@ -212,6 +260,7 @@ export const createReactRouterDevRuntime = ({ buildPlan, onEvaluationError, onCssAssetOwnershipChanged = () => undefined, + onRouteManifestChanged = () => undefined, onWarning = () => undefined, }: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => { let nextAttemptId = 1; @@ -237,6 +286,17 @@ export const createReactRouterDevRuntime = ({ } }; + const notifyRouteManifestChanged = (): void => { + try { + onRouteManifestChanged(); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + onWarning( + `[rsbuild-plugin-react-router] Failed to notify the browser after route manifest metadata changed: ${reason}` + ); + } + }; + const uniqueEntryNames = new Set(buildPlan.entryNames); if ( uniqueEntryNames.size !== buildPlan.entryNames.length || @@ -450,6 +510,13 @@ export const createReactRouterDevRuntime = ({ previous.web.manifestsByEntryName, manifestsByEntryName ); + const routeManifestMetadataChanged = + !!previous && + webChanged && + hasRouteManifestMetadataChanges( + previous.web.manifestsByEntryName, + manifestsByEntryName + ); const reusePreviousNodeBuild = !!previous && cssOnlyWebManifestChange; if ( @@ -517,6 +584,9 @@ export const createReactRouterDevRuntime = ({ } reloadAfterCssRemoval = false; } + if (routeManifestMetadataChanged) { + notifyRouteManifestChanged(); + } } catch (cause) { rejectAttempt( attemptId, diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index cc86c8e..37701b8 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -176,6 +176,12 @@ export const createReactRouterDevRuntimeController = ({ } server.sockWrite('full-reload', { path: '*' }); }, + onRouteManifestChanged() { + if (sessions.getActiveBinding()?.runtime !== runtime) { + return; + } + server.sockWrite('full-reload', { path: '*' }); + }, onWarning: message => api.logger.warn(message), }); const binding = sessions.createBinding(server, runtime); diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 9af9446..845823b 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -68,7 +68,8 @@ const createBuild = ( const createRouteManifest = ( id: string, css: string[], - imports: string[] = [] + imports: string[] = [], + overrides: Partial = {} ): ReactRouterDevManifest['routes'][string] => ({ id, module: `/${id}.js`, @@ -81,6 +82,7 @@ const createRouteManifest = ( hasErrorBoundary: false, imports, css, + ...overrides, }); const createDevManifest = ( @@ -97,7 +99,7 @@ const createDevManifest = ( routes: Object.fromEntries( Object.entries(css.routes ?? {}).map(([id, routeCss]) => [ id, - createRouteManifest(id, routeCss, css.routeImports?.[id]), + createRouteManifest(id, routeCss, css.routeImports?.[id]), ]) ), }); @@ -150,7 +152,10 @@ const captureWeb = ( const createHarness = ( loadBundle: (entryName: string) => Promise | unknown, - options: { onCssAssetOwnershipChanged?: () => void } = {} + options: { + onCssAssetOwnershipChanged?: () => void; + onRouteManifestChanged?: () => void; + } = {} ) => { const errors: Error[] = []; const warnings: string[] = []; @@ -168,6 +173,7 @@ const createHarness = ( }, onEvaluationError: error => errors.push(error), onCssAssetOwnershipChanged: options.onCssAssetOwnershipChanged, + onRouteManifestChanged: options.onRouteManifestChanged, onWarning: warning => warnings.push(warning), }); return { errors, loadBundle: loadBundleMock, runtime, server, warnings }; @@ -376,6 +382,54 @@ describe('React Router development runtime', () => { }); }); + it('notifies when route export metadata changes', async () => { + const onRouteManifestChanged = rstest.fn(); + const { runtime } = createHarness(() => createBuild('build'), { + onRouteManifestChanged, + }); + const firstWeb = createCompilation('web'); + const firstNode = createCompilation('node'); + + runtime.beginAttempt(); + runtime.captureWeb(firstWeb, { + 'static/js/app': { + ...createDevManifest('base'), + routes: { + 'routes/about': createRouteManifest('routes/about', [], [], { + hasClientLoader: false, + }), + }, + }, + }); + await runtime.finishAttempt( + createGraphStats(firstWeb, firstNode), + noKnownChanges, + graphIdentity(firstWeb, firstNode) + ); + + const nextWeb = createCompilation('web'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + runtime.captureWeb(nextWeb, { + 'static/js/app': { + ...createDevManifest('next'), + routes: { + 'routes/about': createRouteManifest('routes/about', [], [], { + hasClientLoader: true, + clientLoaderModule: '/routes/about.clientLoader.js', + }), + }, + }, + }); + await runtime.finishAttempt( + createGraphStats(nextWeb, nextNode), + noKnownChanges, + graphIdentity(nextWeb, nextNode) + ); + + expect(onRouteManifestChanged).toHaveBeenCalledOnce(); + }); + it('notifies when css ownership is re-added after a removal', async () => { const onCssAssetOwnershipChanged = rstest.fn(); const { runtime } = createHarness(() => createBuild('build'), { diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index c4f9ff8..8a403fa 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -47,7 +47,8 @@ const createBuild = ( const createRouteManifest = ( id: string, - css: string[] + css: string[], + overrides: Partial = {} ): ReactRouterDevManifest['routes'][string] => ({ id, module: `/${id}.js`, @@ -60,6 +61,7 @@ const createRouteManifest = ( hasErrorBoundary: false, imports: [], css, + ...overrides, }); const createManifest = ( @@ -565,6 +567,53 @@ describe('React Router development runtime controller', () => { }); }); + it('hard reloads when route export metadata changes', async () => { + const { callbacks, controller, loadBundle, server } = createHarness(); + loadBundle.mockImplementation(() => createBuild('base')); + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, { + 'static/js/app': { + ...createManifest('web-base')['static/js/app'], + routes: { + 'routes/about': createRouteManifest('routes/about', [], { + hasClientLoader: false, + }), + }, + }, + }); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb(nextWeb, { + 'static/js/app': { + ...createManifest('web-next')['static/js/app'], + routes: { + 'routes/about': createRouteManifest('routes/about', [], { + hasClientLoader: true, + clientLoaderModule: '/routes/about.clientLoader.js', + }), + }, + }, + }); + web.complete(nextWeb); + const nextNode = node.compile(); + await callbacks.after({ stats: createGraphStats(nextWeb, nextNode) }); + + expect(server.sockWrite).toHaveBeenCalledWith('full-reload', { + path: '*', + }); + }); + it('publishes a safe node-only compile after the aggregate pre-hook', async () => { const { callbacks, controller, loadBundle, server } = createHarness(); let build = createBuild('base'); From 6ab91d02672dce84125b1bc226e0afad1d3bea69 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:57:29 +0000 Subject: [PATCH 64/64] chore: tidy route reload parity test --- tests/dev-generation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 845823b..d709099 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -99,7 +99,7 @@ const createDevManifest = ( routes: Object.fromEntries( Object.entries(css.routes ?? {}).map(([id, routeCss]) => [ id, - createRouteManifest(id, routeCss, css.routeImports?.[id]), + createRouteManifest(id, routeCss, css.routeImports?.[id]), ]) ), });