feat(react): add react-router plugin (#29965)

This PR introduces the React Router plugin in Nx. 
The new functionality adds a react-router plugin entry into `nx.json`,
projects that are React-Router V7 via `react-router.config.(m|c)?[jt]s`
will have their targets inferred.


### Changes
Update the React plugin to have a react-router (RR V7) plugin export.
The RR V7 will only infer targets if we have a
`react-router.config.(m|c)?[jt]s` and also a `vite.config.(m|c)?[jt]s`.

Under the hood the RR V7 CLI uses vite for compilation. 
That being said, apps are not limited to only use vite for RR V7. Should
you choose to use it the compilation will not be done via RR V7 CLI.
This commit is contained in:
Nicholas Cunningham 2025-03-14 11:08:21 -06:00 committed by GitHub
parent a72ffcbe70
commit f40873ffbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 807 additions and 9 deletions

View File

@ -207,6 +207,7 @@ const npmPackageToPluginMap: Record<string, `@nx/${string}`> = {
'react-native': '@nx/react-native',
'@remix-run/dev': '@nx/remix',
'@rsbuild/core': '@nx/rsbuild',
'@react-router/dev': '@nx/react',
};
export async function detectPlugins(

View File

@ -0,0 +1,4 @@
export {
createNodesV2,
ReactRouterPluginOptions,
} from './src/plugins/router-plugin';

View File

@ -1,6 +1,8 @@
import {
addDependenciesToPackageJson,
createProjectGraphAsync,
formatFiles,
readNxJson,
removeDependenciesFromPackageJson,
runTasksInSerial,
type GeneratorCallback,
@ -9,6 +11,8 @@ import {
import { nxVersion } from '../../utils/versions';
import { InitSchema } from './schema';
import { getReactDependenciesVersionsToInstall } from '../../utils/version-utils';
import { addPlugin } from '@nx/devkit/src/utils/add-plugin';
import { createNodesV2 } from '../../plugins/router-plugin';
export async function reactInitGenerator(tree: Tree, schema: InitSchema) {
const tasks: GeneratorCallback[] = [];
@ -32,6 +36,36 @@ export async function reactInitGenerator(tree: Tree, schema: InitSchema) {
);
}
const nxJson = readNxJson(tree);
schema.addPlugin ??=
process.env.NX_ADD_PLUGINS !== 'false' &&
nxJson.useInferencePlugins !== false;
if (schema.addPlugin) {
await addPlugin(
tree,
await createProjectGraphAsync(),
'@nx/react/router-plugin',
createNodesV2,
{
buildTargetName: ['build', 'react-router:build', 'react-router-build'],
devTargetName: ['dev', 'react-router:dev', 'react-router-dev'],
startTargetName: ['start', 'react-router-serve', 'react-router-start'],
watchDepsTargetName: [
'watch-deps',
'react-router:watch-deps',
'react-router-watch-deps',
],
buildDepsTargetName: [
'build-deps',
'react-router:build-deps',
'react-router-build-deps',
],
},
schema.updatePackageScripts
);
}
if (!schema.skipFormat) {
await formatFiles(tree);
}

View File

@ -2,4 +2,6 @@ export interface InitSchema {
skipFormat?: boolean;
skipPackageJson?: boolean;
keepExistingVersions?: boolean;
updatePackageScripts?: boolean;
addPlugin?: boolean;
}

View File

@ -0,0 +1,187 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`@nx/react/react-router-plugin React Router should create nodes by default 1`] = `
[
[
"acme/react-router.config.js",
{
"projects": {
"acme": {
"metadata": {},
"projectType": "application",
"root": "acme",
"targets": {
"build": {
"cache": true,
"command": "react-router build",
"dependsOn": [
"^build",
],
"inputs": [
"production",
"^production",
{
"externalDependencies": [
"@react-router/dev",
],
},
],
"options": {
"cwd": "acme",
},
"outputs": [
"{workspaceRoot}/acme/build/client",
"{workspaceRoot}/acme/build/server",
],
},
"build-deps": {
"dependsOn": [
"^build",
],
},
"dev": {
"command": "react-router dev",
"options": {
"cwd": "acme",
},
},
"start": {
"command": "react-router-serve build/server/index.js",
"dependsOn": [
"build",
],
"options": {
"cwd": "acme",
},
},
"typecheck": {
"cache": true,
"command": "tsc --noEmit",
"inputs": [
"production",
"^production",
{
"externalDependencies": [
"typescript",
],
},
],
"metadata": {
"description": "Runs type-checking for the project.",
"help": {
"command": "npx tsc --help",
"example": {
"options": {
"noEmit": true,
},
},
},
"technologies": [
"typescript",
],
},
"options": {
"cwd": "acme",
},
},
"watch-deps": {
"command": "npx nx watch --projects acme --includeDependentProjects -- npx nx build-deps acme",
"dependsOn": [
"build-deps",
],
},
},
},
},
},
],
]
`;
exports[`@nx/react/react-router-plugin React Router should create nodes without start target if ssr is false 1`] = `
[
[
"acme/react-router.config.js",
{
"projects": {
"acme": {
"metadata": {},
"projectType": "library",
"root": "acme",
"targets": {
"build": {
"cache": true,
"command": "react-router build",
"dependsOn": [
"^build",
],
"inputs": [
"production",
"^production",
{
"externalDependencies": [
"@react-router/dev",
],
},
],
"options": {
"cwd": "acme",
},
"outputs": [
"{workspaceRoot}/acme/build/client",
],
},
"build-deps": {
"dependsOn": [
"^build",
],
},
"dev": {
"command": "react-router dev",
"options": {
"cwd": "acme",
},
},
"typecheck": {
"cache": true,
"command": "tsc --noEmit",
"inputs": [
"production",
"^production",
{
"externalDependencies": [
"typescript",
],
},
],
"metadata": {
"description": "Runs type-checking for the project.",
"help": {
"command": "npx tsc --help",
"example": {
"options": {
"noEmit": true,
},
},
},
"technologies": [
"typescript",
],
},
"options": {
"cwd": "acme",
},
},
"watch-deps": {
"command": "npx nx watch --projects acme --includeDependentProjects -- npx nx build-deps acme",
"dependsOn": [
"build-deps",
],
},
},
},
},
},
],
]
`;

View File

@ -0,0 +1,95 @@
import { type CreateNodesContext } from '@nx/devkit';
import { createNodesV2 } from './router-plugin';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { join } from 'path';
jest.mock('nx/src/utils/cache-directory', () => ({
...jest.requireActual('nx/src/utils/cache-directory'),
workspaceDataDirectory: 'tmp/project-graph-cache',
}));
jest.mock('@nx/js/src/utils/typescript/ts-solution-setup', () => ({
...jest.requireActual('@nx/js/src/utils/typescript/ts-solution-setup'),
isUsingTsSolutionSetup: jest.fn(),
}));
describe('@nx/react/react-router-plugin', () => {
let createNodesFunction = createNodesV2[1];
let context: CreateNodesContext;
let tempFs: TempFs;
let cwd: string;
beforeEach(() => {
(isUsingTsSolutionSetup as jest.Mock).mockReturnValue(false);
});
describe('React Router', () => {
beforeEach(async () => {
tempFs = new TempFs('test');
cwd = process.cwd();
process.chdir(tempFs.tempDir);
context = {
nxJsonConfiguration: {
namedInputs: {
default: ['{projectRoot}/**/*'],
production: ['!{projectRoot}/**/*.spec.ts'],
},
},
workspaceRoot: tempFs.tempDir,
configFiles: [],
};
await tempFs.createFiles({
'acme/react-router.config.js': 'module.exports = {}',
'acme/vite.config.js': '',
'acme/project.json': JSON.stringify({ name: 'acme' }),
});
});
afterEach(() => {
jest.resetModules();
tempFs.cleanup();
process.chdir(cwd);
});
it('should create nodes by default', async () => {
mockConfig('acme/react-router.config.js', {}, context);
const nodes = await createNodesFunction(
['acme/react-router.config.js'],
{
buildTargetName: 'build',
devTargetName: 'dev',
startTargetName: 'start',
},
context
);
expect(nodes).toMatchSnapshot();
});
it('should create nodes without start target if ssr is false', async () => {
mockConfig('acme/react-router.config.js', { ssr: false }, context);
const nodes = await createNodesFunction(
['acme/react-router.config.js'],
{
buildTargetName: 'build',
devTargetName: 'dev',
startTargetName: 'start',
},
context
);
expect(nodes).toMatchSnapshot();
});
});
function mockConfig(path: string, config, context: CreateNodesContext) {
jest.mock(join(context.workspaceRoot, path), () => config, {
virtual: true,
});
}
});

View File

@ -0,0 +1,385 @@
import {
type CreateNodesV2,
type CreateNodesContext,
detectPackageManager,
readJsonFile,
type TargetConfiguration,
writeJsonFile,
createNodesFromFiles,
getPackageManagerCommand,
joinPathFragments,
type ProjectConfiguration,
type CreateNodesContextV2,
} from '@nx/devkit';
import { dirname, join } from 'path';
import { existsSync, readdirSync } from 'fs';
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { calculateHashesForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { getLockFileName } from '@nx/js';
import { hashObject } from 'nx/src/devkit-internals';
import { addBuildAndWatchDepsTargets } from '@nx/js/src/plugins/typescript/util';
import { isUsingTsSolutionSetup as _isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import {
clearRequireCache,
loadConfigFile,
} from '@nx/devkit/src/utils/config-utils';
export interface ReactRouterPluginOptions {
buildTargetName?: string;
devTargetName?: string;
startTargetName?: string;
typecheckTargetName?: string;
buildDepsTargetName?: string;
watchDepsTargetName?: string;
}
type ReactRouterTargets = Pick<
ProjectConfiguration,
'targets' | 'metadata' | 'projectType'
>;
const pmCommand = getPackageManagerCommand();
const reactRouterConfigBlob = '**/react-router.config.{ts,js,cjs,cts,mjs,mts}';
function readTargetsCache(
cachePath: string
): Record<string, ReactRouterTargets> {
return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' && existsSync(cachePath)
? readJsonFile(cachePath)
: {};
}
function writeTargetsToCache(
cachePath: string,
results: Record<string, ReactRouterTargets>
) {
writeJsonFile(cachePath, results);
}
export const createNodesV2: CreateNodesV2<ReactRouterPluginOptions> = [
reactRouterConfigBlob,
async (configFiles, options, context) => {
const optionsHash = hashObject(options);
const normalizedOptions = normalizeOptions(options);
const cachePath = join(
workspaceDataDirectory,
`react-router-${optionsHash}.hash`
);
const targetsCache = readTargetsCache(cachePath);
const isUsingTsSolutionSetup = _isUsingTsSolutionSetup();
const { roots: projectRoots, configFiles: validConfigFiles } =
configFiles.reduce(
(acc, configFile) => {
const potentialRoot = dirname(configFile);
if (checkIfConfigFileShouldBeProject(potentialRoot, context)) {
acc.roots.push(potentialRoot);
acc.configFiles.push(configFile);
}
return acc;
},
{
roots: [],
configFiles: [],
} as {
roots: string[];
configFiles: string[];
}
);
const lockfile = getLockFileName(
detectPackageManager(context.workspaceRoot)
);
const hashes = await calculateHashesForCreateNodes(
projectRoots,
{ ...normalizedOptions, isUsingTsSolutionSetup },
context,
projectRoots.map((_) => [lockfile])
);
try {
return await createNodesFromFiles(
async (configFile, _, context, idx) => {
const projectRoot = dirname(configFile);
const siblingFiles = readdirSync(
joinPathFragments(context.workspaceRoot, projectRoot)
);
const hash = hashes[idx] + configFile;
const { projectType, metadata, targets } = (targetsCache[hash] ??=
await buildReactRouterTargets(
configFile,
projectRoot,
normalizedOptions,
context,
siblingFiles,
isUsingTsSolutionSetup
));
const project: ProjectConfiguration = {
root: projectRoot,
targets,
metadata,
};
if (project.targets[normalizedOptions.buildTargetName]) {
project.projectType = projectType;
}
return {
projects: {
[projectRoot]: project,
},
};
},
validConfigFiles,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
}
},
];
async function buildReactRouterTargets(
configFilePath: string,
projectRoot: string,
options: ReactRouterPluginOptions,
context: CreateNodesContext,
siblingFiles: string[],
isUsingTsSolutionSetup: boolean
): Promise<ReactRouterTargets> {
const namedInputs = getNamedInputs(projectRoot, context);
const configPath = join(context.workspaceRoot, configFilePath);
if (require.cache[configPath]) clearRequireCache();
const reactRouterConfig = await loadConfigFile(configPath);
const isLibMode =
reactRouterConfig?.ssr !== undefined && reactRouterConfig.ssr === false;
const { buildDirectory, serverBuildPath } = await getBuildPaths(
reactRouterConfig,
isLibMode
);
const targets: Record<string, TargetConfiguration> = {};
targets[options.buildTargetName] = await getBuildTargetConfig(
options.buildTargetName,
projectRoot,
buildDirectory,
serverBuildPath,
namedInputs,
isUsingTsSolutionSetup
);
targets[options.devTargetName] = await devTarget(
projectRoot,
isUsingTsSolutionSetup
);
if (serverBuildPath) {
targets[options.startTargetName] = await startTarget(
projectRoot,
serverBuildPath,
options.buildTargetName,
isUsingTsSolutionSetup
);
}
targets[options.typecheckTargetName] = await typecheckTarget(
projectRoot,
options.typecheckTargetName,
namedInputs,
siblingFiles,
isUsingTsSolutionSetup
);
addBuildAndWatchDepsTargets(
context.workspaceRoot,
projectRoot,
targets,
options,
pmCommand
);
const metadata = {};
return {
targets,
metadata,
projectType: isLibMode ? 'library' : 'application',
};
}
async function getBuildTargetConfig(
buildTargetName: string,
projectRoot: string,
buildDirectory: string,
serverBuildDirectory: string,
namedInputs: { [inputName: string]: any[] },
isUsingTsSolutionSetup: boolean
) {
const basePath =
projectRoot === '.'
? `{workspaceRoot}`
: joinPathFragments(`{workspaceRoot}`, projectRoot);
const outputs = [
joinPathFragments(basePath, buildDirectory),
...(serverBuildDirectory
? [joinPathFragments(basePath, serverBuildDirectory)]
: []),
];
const buildTarget: TargetConfiguration = {
cache: true,
dependsOn: [`^${buildTargetName}`],
inputs: [
...('production' in namedInputs
? ['production', '^production']
: ['default', '^default']),
{ externalDependencies: ['@react-router/dev'] },
],
outputs,
command: 'react-router build',
options: { cwd: projectRoot },
};
if (isUsingTsSolutionSetup) {
buildTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return buildTarget;
}
async function getBuildPaths(reactRouterConfig, isLibMode: boolean) {
return {
buildDirectory: reactRouterConfig?.buildDirectory ?? 'build/client',
...(isLibMode
? undefined
: {
serverBuildPath: reactRouterConfig?.buildDirectory
? join(dirname(reactRouterConfig.buildDirectory), `server`)
: 'build/server',
}),
};
}
async function devTarget(projectRoot: string, isUsingTsSolutionSetup: boolean) {
const devTarget: TargetConfiguration = {
command: 'react-router dev',
options: { cwd: projectRoot },
};
if (isUsingTsSolutionSetup) {
devTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return devTarget;
}
async function startTarget(
projectRoot: string,
serverBuildPath: string,
buildTargetName: string,
isUsingTsSolutionSetup: boolean
) {
const serverPath =
serverBuildPath === 'build/server'
? `${serverBuildPath}/index.js`
: serverBuildPath;
const startTarget: TargetConfiguration = {
dependsOn: [buildTargetName],
command: `react-router-serve ${serverPath}`,
options: { cwd: projectRoot },
};
if (isUsingTsSolutionSetup) {
startTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return startTarget;
}
async function typecheckTarget(
projectRoot: string,
typecheckTargetName: string,
namedInputs: { [inputName: string]: any[] },
siblingFiles: string[],
isUsingTsSolutionSetup: boolean
) {
const hasTsConfigAppJson = siblingFiles.includes('tsconfig.app.json');
const typecheckTarget: TargetConfiguration = {
cache: true,
inputs: [
...('production' in namedInputs
? ['production', '^production']
: ['default', '^default']),
{ externalDependencies: ['typescript'] },
],
command: isUsingTsSolutionSetup
? `tsc --build --emitDeclarationOnly`
: `tsc${hasTsConfigAppJson ? ` -p tsconfig.app.json` : ``} --noEmit`,
options: {
cwd: projectRoot,
},
metadata: {
description: `Runs type-checking for the project.`,
technologies: ['typescript'],
help: {
command: isUsingTsSolutionSetup
? `${pmCommand.exec} tsc --build --help`
: `${pmCommand.exec} tsc${
hasTsConfigAppJson ? ` -p tsconfig.app.json` : ``
} --help`,
example: isUsingTsSolutionSetup
? { args: ['--force'] }
: { options: { noEmit: true } },
},
},
};
if (isUsingTsSolutionSetup) {
typecheckTarget.dependsOn = [`^${typecheckTargetName}`];
typecheckTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return typecheckTarget;
}
function normalizeOptions(options: ReactRouterPluginOptions) {
options ??= {};
options.buildTargetName ??= 'build';
options.devTargetName ??= 'dev';
options.startTargetName ??= 'start';
options.typecheckTargetName ??= 'typecheck';
return options;
}
function checkIfConfigFileShouldBeProject(
projectRoot: string,
context: CreateNodesContext | CreateNodesContextV2
): boolean {
// Do not create a project if package.json and project.json isn't there.
const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot));
return hasRequiredConfigs(siblingFiles);
}
function hasRequiredConfigs(files: string[]): boolean {
const lowerFiles = files.map((file) => file.toLowerCase());
// Check if vite.config.{ext} is present
const hasViteConfig = lowerFiles.some((file) => {
const parts = file.split('.');
return parts[0] === 'vite' && parts[1] === 'config' && parts.length > 2;
});
if (!hasViteConfig) return false;
const hasProjectOrPackageJson =
lowerFiles.includes('project.json') || lowerFiles.includes('package.json');
return hasProjectOrPackageJson;
}

View File

@ -32,6 +32,7 @@ export const emotionBabelPlugin = '11.11.0';
export const styledJsxVersion = '5.1.2';
export const reactRouterDomVersion = '6.29.0';
export const reactRouterVersion = '7.1.5';
export const testingLibraryReactVersion = '16.1.0';
export const testingLibraryDomVersion = '10.4.0';

View File

@ -69,6 +69,39 @@ describe('@nx/vite/plugin', () => {
expect(nodes).toMatchSnapshot();
});
it('should not create nodes when react-router.config is present', async () => {
tempFs.createFileSync('react-router.config.ts', '');
const nodes = await createNodesFunction(
['vite.config.ts'],
{
buildTargetName: 'build',
serveTargetName: 'serve',
previewTargetName: 'preview',
testTargetName: 'test',
serveStaticTargetName: 'serve-static',
},
context
);
expect(nodes).toMatchInlineSnapshot(`
[
[
"vite.config.ts",
{
"projects": {
".": {
"metadata": {},
"root": ".",
"targets": {},
},
},
},
],
]
`);
});
it('should create nodes when rollupOptions contains input', async () => {
// Don't need index.html if we're setting inputs
tempFs.removeFileSync('index.html');
@ -252,6 +285,39 @@ describe('@nx/vite/plugin', () => {
expect(nodes).toMatchSnapshot();
});
it('should not create nodes when react-router.config is present', async () => {
tempFs.createFileSync('my-app/react-router.config.ts', '');
const nodes = await createNodesFunction(
['my-app/vite.config.ts'],
{
buildTargetName: 'build',
serveTargetName: 'serve',
previewTargetName: 'preview',
testTargetName: 'test',
serveStaticTargetName: 'serve-static',
},
context
);
expect(nodes).toMatchInlineSnapshot(`
[
[
"my-app/vite.config.ts",
{
"projects": {
"my-app": {
"metadata": {},
"root": "my-app",
"targets": {},
},
},
},
],
]
`);
});
});
describe('Library mode', () => {

View File

@ -122,6 +122,15 @@ export const createNodesV2: CreateNodesV2<VitePluginOptions> = [
minimatch(p, 'tsconfig*{.json,.*.json}')
) ?? [];
const hasReactRouterConfig = siblingFiles.some((configFile) => {
const parts = configFile.split('.');
return (
parts[0] === 'react-router' &&
parts[1] === 'config' &&
parts.length > 2
);
});
// results from vitest.config.js will be different from results of vite.config.js
// but the hash will be the same because it is based on the files under the project root.
// Adding the config file path to the hash ensures that the final hash value is different
@ -133,6 +142,7 @@ export const createNodesV2: CreateNodesV2<VitePluginOptions> = [
projectRoot,
normalizedOptions,
tsConfigFiles,
hasReactRouterConfig,
isUsingTsSolutionSetup,
context
));
@ -185,6 +195,13 @@ export const createNodes: CreateNodes<VitePluginOptions> = [
siblingFiles.filter((p) => minimatch(p, 'tsconfig*{.json,.*.json}')) ??
[];
const hasReactRouterConfig = siblingFiles.some((configFile) => {
const parts = configFile.split('.');
return (
parts[0] === 'react-router' && parts[1] === 'config' && parts.length > 2
);
});
const normalizedOptions = normalizeOptions(options);
const isUsingTsSolutionSetup = _isUsingTsSolutionSetup();
@ -194,6 +211,7 @@ export const createNodes: CreateNodes<VitePluginOptions> = [
projectRoot,
normalizedOptions,
tsConfigFiles,
hasReactRouterConfig,
isUsingTsSolutionSetup,
context
);
@ -222,6 +240,7 @@ async function buildViteTargets(
projectRoot: string,
options: VitePluginOptions,
tsConfigFiles: string[],
hasReactRouterConfig: boolean,
isUsingTsSolutionSetup: boolean,
context: CreateNodesContext
): Promise<ViteTargets> {
@ -253,6 +272,19 @@ async function buildViteTargets(
const targets: Record<string, TargetConfiguration> = {};
// if file is vitest.config or vite.config has definition for test, create target for test
if (configFilePath.includes('vitest.config') || hasTest) {
targets[options.testTargetName] = await testTarget(
namedInputs,
testOutputs,
projectRoot
);
}
if (hasReactRouterConfig) {
// If we have a react-router config, we can skip the rest of the targets
return { targets, metadata: {}, projectType: 'application' };
}
// If file is not vitest.config and buildable, create targets for build, serve, preview and serve-static
const hasRemixPlugin =
viteBuildConfig.plugins &&
@ -335,15 +367,6 @@ async function buildViteTargets(
}
}
// if file is vitest.config or vite.config has definition for test, create target for test
if (configFilePath.includes('vitest.config') || hasTest) {
targets[options.testTargetName] = await testTarget(
namedInputs,
testOutputs,
projectRoot
);
}
addBuildAndWatchDepsTargets(
context.workspaceRoot,
projectRoot,