Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/config-dependencies.ts
Original file line number Diff line number Diff line change
@@ -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*(['"])(?<specifier>\.{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<string[]> => {
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<string[]> => {
const dependencies: string[] = [];
const visited = new Set<string>([configPath]);
const queue = [configPath];

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)) {
continue;
}

visited.add(dependencyPath);
dependencies.push(dependencyPath);
queue.push(dependencyPath);
}
}

return dependencies;
};
19 changes: 14 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -187,11 +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 configWatchPaths = configExists
? 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(
Expand Down
2 changes: 1 addition & 1 deletion src/modify-browser-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { combineURLs } from './plugin-utils.js';
import jsesc from 'jsesc';

type ModifyBrowserManifestOptions = {
subResourceIntegrity?: boolean;
future?: { unstable_subResourceIntegrity?: boolean };
subResourceIntegrity?: boolean;
manifestChunkNames?: ReadonlySet<string>;
onManifest?: (
manifest: Awaited<ReturnType<typeof getReactRouterManifestForDev>>,
Expand Down
41 changes: 41 additions & 0 deletions tests/config-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
69 changes: 63 additions & 6 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,69 @@ 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 existsSync = rstest.spyOn(fs, 'existsSync').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();
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')
);

Expand All @@ -135,7 +192,7 @@ describe('pluginReactRouter', () => {
type: 'reload-server',
});
} finally {
existsSyncMock.mockReturnValue(true);
existsSync.mockReturnValue(true);
}
});

Expand Down
2 changes: 1 addition & 1 deletion tests/modify-browser-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ describe('modify browser manifest plugin', () => {
'/',
{ isBuild: true },
{
future: { unstable_subResourceIntegrity: true },
subResourceIntegrity: true,
onManifest(_manifest, sri) {
reportedSri = sri;
},
Expand Down
11 changes: 11 additions & 0 deletions tests/prerender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ describe('prerender helpers', () => {
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({
Expand Down
Loading