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: {