UNPKG

rsbuild-plugin-react-router

Version:
421 lines (386 loc) 13 kB
import { basename as pathBasename, relative } from 'pathe'; import { generate, parse } from './yuku.js'; import { SERVER_ONLY_ROUTE_EXPORTS, SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.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, } from './route-artifacts.js'; import { detectRouteChunksIfEnabled, getRouteChunkModuleId, type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; import { getProgram, type AnyNode, } from './route-ast.js'; export type RouteTransformResult = { code: string; map?: ReturnType<typeof generate>['map']; }; type BaseRouteTransformTask = { code: string; resourcePath: string; }; export type RouteClientEntryTransformTask = BaseRouteTransformTask & { kind: 'routeClientEntry'; environmentName?: string; isBuild: boolean; routeChunkConfig: RouteChunkConfig; routeId?: string; devHmr?: boolean; }; 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'; resolveExportAllModule?: RouteExportResolver; }; export type RouteModuleTransformTask = BaseRouteTransformTask & { kind: 'routeModule'; resource: string; environmentName: string; sourceMaps: boolean; ssr: boolean; isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; devHmr?: boolean; }; 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<RouteTransformResult> => { const { exportNames, hasRouteChunks, chunkedExports } = await detectRouteChunksIfEnabled( getRouteChunkCache(options), task.routeChunkConfig, task.resourcePath, task.code ); if (!hasRouteChunks) { return { code: task.code, map: null }; } const chunkedExportSet = new Set<string>(chunkedExports); const mainChunkReexports = exportNames .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 createClientOnlyStub = async ( task: ClientOnlyStubTransformTask ): Promise<RouteTransformResult> => { const exportNames = await collectClientOnlyStubExportNames( task.code, task.resourcePath, task.resolveExportAllModule ); return { code: Array.from(exportNames) .map(name => name === 'default' ? 'export default undefined;' : `export const ${name} = undefined;` ) .join('\n'), map: null, }; }; const isComponentishName = (name: string): boolean => /^[A-Z]/.test(name); /** * Whether an expression that appears as the first argument of a wrapping call * resolves to a component, mirroring react-refresh/babel's * `findInnerComponents` recursion for the node kinds it accepts as arguments. */ const argumentResolvesToComponent = (node: AnyNode | undefined): boolean => { switch (node?.type) { case 'FunctionExpression': return true; case 'ArrowFunctionExpression': // Babel bails on a curried arrow (an arrow whose body is another arrow): // that is a component factory, not a component. return node.body?.type !== 'ArrowFunctionExpression'; case 'Identifier': return !!node.name && isComponentishName(node.name); case 'CallExpression': return callResolvesToComponent(node); default: return false; } }; /** * Whether a `CallExpression` resolves to a component: it must have at least one * argument, a callee that is not `Import`/`require*`/`import*`, and a first * argument that itself resolves to a component. */ const callResolvesToComponent = (node: AnyNode): boolean => { const args = node.arguments ?? []; if (args.length === 0) { return false; } const callee = node.callee; if (!callee || callee.type === 'Import') { return false; } if (callee.type === 'Identifier') { const calleeName = callee.name ?? ''; if (calleeName.startsWith('require') || calleeName.startsWith('import')) { return false; } } else if (callee.type !== 'MemberExpression') { return false; } return argumentResolvesToComponent(args[0]); }; /** * Whether a `VariableDeclarator` initializer resolves to a component, matching * react-refresh/babel's accepted init kinds: a non-curried arrow, a function * expression, a tagged template, or a qualifying call expression. */ const initResolvesToComponent = (init: AnyNode): boolean => { switch (init.type) { case 'FunctionExpression': case 'TaggedTemplateExpression': return true; case 'ArrowFunctionExpression': return init.body?.type !== 'ArrowFunctionExpression'; case 'CallExpression': return callResolvesToComponent(init); default: return false; } }; const collectDeclaredComponentNames = ( declaration: AnyNode, names: Set<string> ): void => { if ( declaration.type === 'FunctionDeclaration' && declaration.id?.name && isComponentishName(declaration.id.name) ) { names.add(declaration.id.name); return; } if (declaration.type !== 'VariableDeclaration') { return; } const declarators = declaration.declarations ?? []; // Babel's react-refresh visitor only registers a `VariableDeclaration` with // exactly one declarator; multi-declarator declarations are skipped whole. if (declarators.length !== 1) { return; } const [declarator] = declarators; if ( declarator?.id?.type === 'Identifier' && declarator.id.name && isComponentishName(declarator.id.name) && declarator.init && initResolvesToComponent(declarator.init) ) { names.add(declarator.id.name); } }; /** * Names of top-level components that still need a React Fast Refresh * registration, following react-refresh/babel's name-based detection. * * SWC's refresh transform registers components in JSX/TSX sources, but * compiled route modules whose JSX was already lowered by an earlier loader * (e.g. MDX routes) reach it without JSX syntax and end up unregistered. An * unregistered component has no refresh family, so hot updates remount its * subtree instead of updating it in place. */ const collectUnregisteredComponentNames = (program: { body?: AnyNode[]; }): string[] => { const declared = new Set<string>(); const registered = new Set<string>(); for (const statement of program.body ?? []) { if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { collectDeclaredComponentNames(statement.declaration, declared); continue; } if ( statement.type === 'ExpressionStatement' && statement.expression?.type === 'CallExpression' && statement.expression.callee?.type === 'Identifier' && statement.expression.callee.name === '$RefreshReg$' ) { const nameArgument = statement.expression.arguments?.[1]; if (typeof nameArgument?.value === 'string') { registered.add(nameArgument.value); } continue; } collectDeclaredComponentNames(statement, declared); } return [...declared].filter(name => !registered.has(name)); }; const buildComponentRefreshRegistrations = (names: string[]): string => { const registrations = names .map( name => // react-refresh's runtime `register()` tags plain functions *and* // non-null exotic objects (memo/forwardRef `$$typeof` wrappers), // ignoring everything else safely -- so mirror that guard here. The // `typeof` short-circuit keeps this safe even for undeclared names. ` if (typeof ${name} === 'function' || (typeof ${name} === 'object' && ${name} !== null)) $RefreshReg$(${name}, ${JSON.stringify(name)});` ) .join('\n'); return `\nif (typeof $RefreshReg$ === 'function') {\n${registrations}\n}\n`; }; const transformRouteModule = async ( task: RouteModuleTransformTask ): Promise<RouteTransformResult> => { 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 = collectProgramExportNames(getProgram(ast)); 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 removedServerOnlyExports = task.environmentName === 'web' ? removeExports( ast, SERVER_ONLY_ROUTE_EXPORTS, SERVER_ONLY_ROUTE_EXPORTS_SET ) : false; transformRoute(ast); if (removedServerOnlyExports) { removeUnusedImports(ast); } const result = generate(ast, { // 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, }); if (task.devHmr && task.environmentName === 'web' && !task.isBuild) { const unregisteredComponents = collectUnregisteredComponentNames( getProgram(ast) ); if (unregisteredComponents.length > 0) { result.code += buildComponentRefreshRegistrations(unregisteredComponents); } } return result; }; export const executeRouteTransformTask = async ( task: RouteTransformTask, options?: RouteTransformTaskOptions ): Promise<RouteTransformResult> => { 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, routeId: task.routeId, devHmr: task.devHmr, }); 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); } };