rsbuild-plugin-react-router
Version:
React Router plugin for Rsbuild
421 lines (386 loc) • 13 kB
text/typescript
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);
}
};