From 2b7bc14754b85c2349b3b59c9394c14e9fa60884 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:26:06 +0200 Subject: [PATCH 1/7] fix: restart dev server for route entry changes --- src/index.ts | 38 +++++++++++++++++++++++++++++++++++++- tests/index.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index fc1fdd1..3859025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,12 @@ import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; +import { + rspack, + type RsbuildConfig, + type RsbuildPlugin, + type Rspack, +} from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; @@ -88,6 +93,26 @@ type ModuleFederationPluginLike = { options?: { experiments?: { asyncStartup?: boolean } }; }; +type WatchFilesConfig = NonNullable< + NonNullable['watchFiles'] +>; +type WatchFileConfig = + | Exclude + | Extract[number]; + +const mergeWatchFiles = ( + existing: WatchFilesConfig | undefined, + additions: WatchFileConfig[] +): WatchFilesConfig => { + if (!existing) { + return additions as WatchFilesConfig; + } + return [ + ...(Array.isArray(existing) ? existing : [existing]), + ...additions, + ] as WatchFilesConfig; +}; + const ensureFederationAsyncStartup = ( rspackConfig: Rspack.Configuration | undefined ): void => { @@ -411,6 +436,16 @@ export const pluginReactRouter = ( isBuild, cache: routeChunkCache, }; + const routeWatchFiles: WatchFileConfig[] = [ + { + paths: routesPath, + type: 'reload-server', + }, + { + paths: resolve(appDirectory, 'routes/**/*'), + type: 'reload-server', + }, + ]; type ReactRouterManifest = Awaited< ReturnType @@ -1149,6 +1184,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: diff --git a/tests/index.test.ts b/tests/index.test.ts index e3e9aba..b904302 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -18,6 +18,39 @@ 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(/app\/routes\/\*\*\/\*$/), + type: 'reload-server', + }, + ]) + ); + }); + it('should respect server output format', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, From 56cd97870cfa750a989975a41b97e0f902c2177c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:34:53 +0200 Subject: [PATCH 2/7] test: cover adding routes during dev --- .../tests/e2e/dev-route-watch.test.ts | 78 +++++++++++++++++++ src/index.ts | 2 +- tests/index.test.ts | 2 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 examples/default-template/tests/e2e/dev-route-watch.test.ts 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..58e3195 --- /dev/null +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -0,0 +1,78 @@ +import { expect, test } 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 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 addedRouteConfigEntry = ` route('dev-added-route', 'routes/dev-added-route.tsx'),`; + +const cleanupAddedRoute = () => { + if (existsSync(addedRoutePath)) { + rmSync(addedRoutePath, { force: true }); + } + + const routesConfig = readFileSync(routesConfigPath, 'utf8'); + if (routesConfig.includes(addedRouteConfigEntry)) { + writeFileSync( + routesConfigPath, + routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '') + ); + } +}; + +test.describe('dev route watch', () => { + test.setTimeout(90000); + + test.beforeEach(cleanupAddedRoute); + test.afterEach(cleanupAddedRoute); + + test('serves a route added after the dev server starts', 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 expect + .poll( + async () => { + try { + const response = await page.request.get(addedRouteUrl, { + timeout: 2000, + }); + if (!response.ok()) { + return `status:${response.status()}`; + } + const body = await response.text(); + return body.includes(addedRouteText) ? 'ready' : 'missing-text'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + { timeout: 60000 } + ) + .toBe('ready'); + + await page.goto(addedRouteUrl); + await expect(page.locator('h1')).toHaveText(addedRouteText); + }); +}); diff --git a/src/index.ts b/src/index.ts index 3859025..7746885 100644 --- a/src/index.ts +++ b/src/index.ts @@ -442,7 +442,7 @@ export const pluginReactRouter = ( type: 'reload-server', }, { - paths: resolve(appDirectory, 'routes/**/*'), + paths: resolve(appDirectory, 'routes'), type: 'reload-server', }, ]; diff --git a/tests/index.test.ts b/tests/index.test.ts index b904302..ebcdc49 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -44,7 +44,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }, { - paths: expect.stringMatching(/app\/routes\/\*\*\/\*$/), + paths: expect.stringMatching(/app\/routes$/), type: 'reload-server', }, ]) From 8a3f7536f94a72ca25ae37af7609ed0e07523fcf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:38:01 +0200 Subject: [PATCH 3/7] fix: restart only when route file set changes --- src/index.ts | 188 +++++++++++++++++++++++++++++++++++++++++++- tests/index.test.ts | 2 +- tests/setup.ts | 1 + 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7746885..bd9573f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { + existsSync, + readFileSync, + statSync, + watch, + type FSWatcher, +} from 'node:fs'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; @@ -113,6 +119,157 @@ const mergeWatchFiles = ( ] as WatchFilesConfig; }; +type RouteDirectoryState = { + directories: Set; + files: Set; +}; + +const isRouteModuleFile = (filePath: string): boolean => + JS_EXTENSIONS.some(extension => filePath.endsWith(extension)); + +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 ( + routesDirectory: string +): Promise => { + const state: RouteDirectoryState = { + directories: new Set(), + files: new Set(), + }; + + const walkDirectory = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + state.directories.add(directory); + await Promise.all( + entries.map(async entry => { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walkDirectory(entryPath); + return; + } + if (entry.isFile() && isRouteModuleFile(entryPath)) { + state.files.add(entryPath); + } + }) + ); + }; + + await walkDirectory(routesDirectory); + return state; +}; + +const createRouteFileSetWatcher = async ({ + routesDirectory, + restartMarkerPath, + onError, +}: { + routesDirectory: string; + restartMarkerPath: string; + onError: (error: unknown) => void; +}): Promise<() => void> => { + let state = await readRouteDirectoryState(routesDirectory); + let closed = false; + let rescanTimer: ReturnType | undefined; + 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, (eventType: string) => { + if (eventType === 'rename') { + scheduleRescan(); + } + }); + watcher.on('error', onError); + directoryWatchers.set(directory, watcher); + } catch (error) { + onError(error); + } + } + }; + + const syncDirectoryWatchers = (nextDirectories: Set): void => { + closeRemovedDirectoryWatchers(nextDirectories); + watchNewDirectories(nextDirectories); + }; + + const rescan = async (): Promise => { + if (closed) { + return; + } + try { + const nextState = await readRouteDirectoryState(routesDirectory); + syncDirectoryWatchers(nextState.directories); + if (!areSetsEqual(state.files, nextState.files)) { + state = nextState; + await touchRestartMarker(); + return; + } + state = nextState; + } catch (error) { + onError(error); + } + }; + + 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(); + }; +}; + const ensureFederationAsyncStartup = ( rspackConfig: Rspack.Configuration | undefined ): void => { @@ -436,16 +593,41 @@ export const pluginReactRouter = ( isBuild, cache: routeChunkCache, }; + const routesDirectory = resolve(appDirectory, 'routes'); + const routeRestartMarkerPath = resolve( + buildDirectory, + '.react-router-route-watch' + ); const routeWatchFiles: WatchFileConfig[] = [ { paths: routesPath, type: 'reload-server', }, { - paths: resolve(appDirectory, 'routes'), + paths: routeRestartMarkerPath, type: 'reload-server', }, ]; + let closeRouteFileSetWatcher: (() => void) | undefined; + + api.onBeforeStartDevServer(async () => { + await mkdir(dirname(routeRestartMarkerPath), { recursive: true }); + await writeFile(routeRestartMarkerPath, String(Date.now())); + closeRouteFileSetWatcher = await createRouteFileSetWatcher({ + routesDirectory, + restartMarkerPath: routeRestartMarkerPath, + onError: error => { + api.logger.warn( + `[${PLUGIN_NAME}] Failed to watch route file set changes: ${error}` + ); + }, + }); + }); + + api.onCloseDevServer(() => { + closeRouteFileSetWatcher?.(); + closeRouteFileSetWatcher = undefined; + }); type ReactRouterManifest = Awaited< ReturnType diff --git a/tests/index.test.ts b/tests/index.test.ts index ebcdc49..74d5657 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -44,7 +44,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }, { - paths: expect.stringMatching(/app\/routes$/), + paths: expect.stringMatching(/build\/\.react-router-route-watch$/), type: 'reload-server', }, ]) diff --git a/tests/setup.ts b/tests/setup.ts index f4cde81..f24ecf8 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -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), From d08b040fe0eaea9329054f5d6c53dd462e1d6bf8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:28:11 +0200 Subject: [PATCH 4/7] fix: stabilize dev route file watch restarts --- .../tests/e2e/dev-route-watch.test.ts | 110 +++++++++++++----- src/index.ts | 25 ++-- tests/index.test.ts | 2 +- tests/route-watch.test.ts | 42 +++++++ 4 files changed, 142 insertions(+), 37 deletions(-) create mode 100644 tests/route-watch.test.ts diff --git a/examples/default-template/tests/e2e/dev-route-watch.test.ts b/examples/default-template/tests/e2e/dev-route-watch.test.ts index 58e3195..c32f03a 100644 --- a/examples/default-template/tests/e2e/dev-route-watch.test.ts +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -1,37 +1,96 @@ -import { expect, test } from '@playwright/test'; +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, + '../../.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 cleanupAddedRoute = () => { - if (existsSync(addedRoutePath)) { - rmSync(addedRoutePath, { force: true }); - } - +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 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(cleanupAddedRoute); - test.afterEach(cleanupAddedRoute); + 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', async ({ page }) => { + 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'); @@ -52,27 +111,22 @@ test.describe('dev route watch', () => { ) ); - await expect - .poll( - async () => { - try { - const response = await page.request.get(addedRouteUrl, { - timeout: 2000, - }); - if (!response.ok()) { - return `status:${response.status()}`; - } - const body = await response.text(); - return body.includes(addedRouteText) ? 'ready' : 'missing-text'; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - }, - { timeout: 60000 } - ) - .toBe('ready'); + 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); + expect(readRestartMarker()).toBe(restartMarkerBefore); }); }); diff --git a/src/index.ts b/src/index.ts index bd9573f..9583505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { watch, type FSWatcher, } from 'node:fs'; -import { mkdir, readdir, writeFile } from 'node:fs/promises'; +import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; @@ -270,6 +270,17 @@ const createRouteFileSetWatcher = async ({ }; }; +export const ensureRestartMarker = async ( + restartMarkerPath: string +): Promise => { + await mkdir(dirname(restartMarkerPath), { recursive: true }); + try { + await access(restartMarkerPath); + } catch { + await writeFile(restartMarkerPath, String(Date.now())); + } +}; + const ensureFederationAsyncStartup = ( rspackConfig: Rspack.Configuration | undefined ): void => { @@ -380,7 +391,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')); @@ -594,10 +607,7 @@ export const pluginReactRouter = ( cache: routeChunkCache, }; const routesDirectory = resolve(appDirectory, 'routes'); - const routeRestartMarkerPath = resolve( - buildDirectory, - '.react-router-route-watch' - ); + const routeRestartMarkerPath = resolve('.react-router', 'route-watch'); const routeWatchFiles: WatchFileConfig[] = [ { paths: routesPath, @@ -611,8 +621,7 @@ export const pluginReactRouter = ( let closeRouteFileSetWatcher: (() => void) | undefined; api.onBeforeStartDevServer(async () => { - await mkdir(dirname(routeRestartMarkerPath), { recursive: true }); - await writeFile(routeRestartMarkerPath, String(Date.now())); + await ensureRestartMarker(routeRestartMarkerPath); closeRouteFileSetWatcher = await createRouteFileSetWatcher({ routesDirectory, restartMarkerPath: routeRestartMarkerPath, diff --git a/tests/index.test.ts b/tests/index.test.ts index 74d5657..95b4c0f 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -44,7 +44,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }, { - paths: expect.stringMatching(/build\/\.react-router-route-watch$/), + paths: expect.stringMatching(/\.react-router\/route-watch$/), type: 'reload-server', }, ]) diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts new file mode 100644 index 0000000..8de83ea --- /dev/null +++ b/tests/route-watch.test.ts @@ -0,0 +1,42 @@ +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 { ensureRestartMarker } from '../src/index'; + +describe('route watch restart marker', () => { + 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 ensureRestartMarker(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 ensureRestartMarker(markerPath); + + expect(readFileSync(markerPath, 'utf8')).toBe('existing'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 00fc7bbda782ef83ee237fe6d5cc81093de625d1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:29:59 +0200 Subject: [PATCH 5/7] fix: compare route config during dev watch --- src/index.ts | 113 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9583505..7f83f0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,12 +121,9 @@ const mergeWatchFiles = ( type RouteDirectoryState = { directories: Set; - files: Set; + routeFiles: Set; }; -const isRouteModuleFile = (filePath: string): boolean => - JS_EXTENSIONS.some(extension => filePath.endsWith(extension)); - const areSetsEqual = (left: Set, right: Set): boolean => { if (left.size !== right.size) { return false; @@ -139,13 +136,14 @@ const areSetsEqual = (left: Set, right: Set): boolean => { return true; }; -const readRouteDirectoryState = async ( - routesDirectory: string -): Promise => { - const state: RouteDirectoryState = { - directories: new Set(), - files: new Set(), - }; +const readRouteDirectoryState = async ({ + watchDirectory, + getRouteFiles, +}: { + watchDirectory: string; + getRouteFiles: () => Promise>; +}): Promise => { + const directories = new Set(); const walkDirectory = async (directory: string): Promise => { let entries; @@ -155,37 +153,42 @@ const readRouteDirectoryState = async ( return; } - state.directories.add(directory); + directories.add(directory); await Promise.all( entries.map(async entry => { const entryPath = resolve(directory, entry.name); if (entry.isDirectory()) { await walkDirectory(entryPath); - return; - } - if (entry.isFile() && isRouteModuleFile(entryPath)) { - state.files.add(entryPath); } }) ); }; - await walkDirectory(routesDirectory); - return state; + await walkDirectory(watchDirectory); + return { + directories, + routeFiles: await getRouteFiles(), + }; }; const createRouteFileSetWatcher = async ({ - routesDirectory, + watchDirectory, + getRouteFiles, restartMarkerPath, onError, }: { - routesDirectory: string; + watchDirectory: string; + getRouteFiles: () => Promise>; restartMarkerPath: string; onError: (error: unknown) => void; }): Promise<() => void> => { - let state = await readRouteDirectoryState(routesDirectory); + let state = await readRouteDirectoryState({ + watchDirectory, + getRouteFiles, + }); let closed = false; let rescanTimer: ReturnType | undefined; + let rescanQueue = Promise.resolve(); const directoryWatchers = new Map(); const touchRestartMarker = async (): Promise => { @@ -210,10 +213,8 @@ const createRouteFileSetWatcher = async ({ continue; } try { - const watcher = watch(directory, (eventType: string) => { - if (eventType === 'rename') { - scheduleRescan(); - } + const watcher = watch(directory, () => { + scheduleRescan(); }); watcher.on('error', onError); directoryWatchers.set(directory, watcher); @@ -228,14 +229,17 @@ const createRouteFileSetWatcher = async ({ watchNewDirectories(nextDirectories); }; - const rescan = async (): Promise => { + const runRescan = async (): Promise => { if (closed) { return; } try { - const nextState = await readRouteDirectoryState(routesDirectory); + const nextState = await readRouteDirectoryState({ + watchDirectory, + getRouteFiles, + }); syncDirectoryWatchers(nextState.directories); - if (!areSetsEqual(state.files, nextState.files)) { + if (!areSetsEqual(state.routeFiles, nextState.routeFiles)) { state = nextState; await touchRestartMarker(); return; @@ -246,6 +250,11 @@ const createRouteFileSetWatcher = async ({ } }; + const rescan = (): Promise => { + rescanQueue = rescanQueue.then(runRescan, runRescan); + return rescanQueue; + }; + const scheduleRescan = (): void => { if (rescanTimer) { clearTimeout(rescanTimer); @@ -521,21 +530,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') @@ -567,6 +579,18 @@ 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 getWatchedRouteFiles = async (): Promise> => { + const latestRouteConfig = await loadRouteConfig(); + const latestRoutes = { + root: { path: '', id: 'root', file: rootRouteFile }, + ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), + }; + return new Set( + Object.values(latestRoutes).map(route => + resolve(appDirectory, route.file) + ) + ); + }; const routes = { root: { path: '', id: 'root', file: rootRouteFile }, @@ -606,7 +630,7 @@ export const pluginReactRouter = ( isBuild, cache: routeChunkCache, }; - const routesDirectory = resolve(appDirectory, 'routes'); + const watchDirectory = resolve(appDirectory); const routeRestartMarkerPath = resolve('.react-router', 'route-watch'); const routeWatchFiles: WatchFileConfig[] = [ { @@ -623,7 +647,8 @@ export const pluginReactRouter = ( api.onBeforeStartDevServer(async () => { await ensureRestartMarker(routeRestartMarkerPath); closeRouteFileSetWatcher = await createRouteFileSetWatcher({ - routesDirectory, + watchDirectory, + getRouteFiles: getWatchedRouteFiles, restartMarkerPath: routeRestartMarkerPath, onError: error => { api.logger.warn( From 42887daf7ed758139bef9f8dc253fb8305d87814 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:43:27 +0200 Subject: [PATCH 6/7] fix: watch route topology changes --- .../tests/e2e/dev-route-watch.test.ts | 21 ++++++- src/index.ts | 57 ++++++++----------- src/route-watch.ts | 38 +++++++++++++ tests/route-watch.test.ts | 55 +++++++++++++++++- 4 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 src/route-watch.ts diff --git a/examples/default-template/tests/e2e/dev-route-watch.test.ts b/examples/default-template/tests/e2e/dev-route-watch.test.ts index c32f03a..de1de84 100644 --- a/examples/default-template/tests/e2e/dev-route-watch.test.ts +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -41,6 +41,25 @@ const readRestartMarker = (): string | null => ? 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, @@ -127,6 +146,6 @@ test.describe('dev route watch', () => { ); await waitForRouteText(page, addedRouteUrl, editedAddedRouteText); - expect(readRestartMarker()).toBe(restartMarkerBefore); + await expectRestartMarkerStable(restartMarkerBefore); }); }); diff --git a/src/index.ts b/src/index.ts index 7f83f0f..963b595 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { watch, type FSWatcher, } from 'node:fs'; -import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; @@ -77,6 +77,10 @@ import { createRouteChunkArtifact, createRouteClientEntryArtifact, } from './route-artifacts.js'; +import { + createRouteManifestSnapshot, + ensureRestartMarker, +} from './route-watch.js'; import { validateRouteConfig } from './route-config.js'; import { getBuildManifest, @@ -121,7 +125,7 @@ const mergeWatchFiles = ( type RouteDirectoryState = { directories: Set; - routeFiles: Set; + routeTopology: Set; }; const areSetsEqual = (left: Set, right: Set): boolean => { @@ -138,10 +142,10 @@ const areSetsEqual = (left: Set, right: Set): boolean => { const readRouteDirectoryState = async ({ watchDirectory, - getRouteFiles, + getRouteTopology, }: { watchDirectory: string; - getRouteFiles: () => Promise>; + getRouteTopology: () => Promise>; }): Promise => { const directories = new Set(); @@ -167,24 +171,24 @@ const readRouteDirectoryState = async ({ await walkDirectory(watchDirectory); return { directories, - routeFiles: await getRouteFiles(), + routeTopology: await getRouteTopology(), }; }; -const createRouteFileSetWatcher = async ({ +const createRouteTopologyWatcher = async ({ watchDirectory, - getRouteFiles, + getRouteTopology, restartMarkerPath, onError, }: { watchDirectory: string; - getRouteFiles: () => Promise>; + getRouteTopology: () => Promise>; restartMarkerPath: string; onError: (error: unknown) => void; }): Promise<() => void> => { let state = await readRouteDirectoryState({ watchDirectory, - getRouteFiles, + getRouteTopology, }); let closed = false; let rescanTimer: ReturnType | undefined; @@ -236,10 +240,10 @@ const createRouteFileSetWatcher = async ({ try { const nextState = await readRouteDirectoryState({ watchDirectory, - getRouteFiles, + getRouteTopology, }); syncDirectoryWatchers(nextState.directories); - if (!areSetsEqual(state.routeFiles, nextState.routeFiles)) { + if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { state = nextState; await touchRestartMarker(); return; @@ -279,17 +283,6 @@ const createRouteFileSetWatcher = async ({ }; }; -export const ensureRestartMarker = async ( - restartMarkerPath: string -): Promise => { - await mkdir(dirname(restartMarkerPath), { recursive: true }); - try { - await access(restartMarkerPath); - } catch { - await writeFile(restartMarkerPath, String(Date.now())); - } -}; - const ensureFederationAsyncStartup = ( rspackConfig: Rspack.Configuration | undefined ): void => { @@ -579,17 +572,13 @@ 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 getWatchedRouteFiles = async (): Promise> => { + const getWatchedRouteTopology = async (): Promise> => { const latestRouteConfig = await loadRouteConfig(); const latestRoutes = { root: { path: '', id: 'root', file: rootRouteFile }, ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), }; - return new Set( - Object.values(latestRoutes).map(route => - resolve(appDirectory, route.file) - ) - ); + return createRouteManifestSnapshot(latestRoutes); }; const routes = { @@ -642,25 +631,25 @@ export const pluginReactRouter = ( type: 'reload-server', }, ]; - let closeRouteFileSetWatcher: (() => void) | undefined; + let closeRouteTopologyWatcher: (() => void) | undefined; api.onBeforeStartDevServer(async () => { await ensureRestartMarker(routeRestartMarkerPath); - closeRouteFileSetWatcher = await createRouteFileSetWatcher({ + closeRouteTopologyWatcher = await createRouteTopologyWatcher({ watchDirectory, - getRouteFiles: getWatchedRouteFiles, + getRouteTopology: getWatchedRouteTopology, restartMarkerPath: routeRestartMarkerPath, onError: error => { api.logger.warn( - `[${PLUGIN_NAME}] Failed to watch route file set changes: ${error}` + `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` ); }, }); }); api.onCloseDevServer(() => { - closeRouteFileSetWatcher?.(); - closeRouteFileSetWatcher = undefined; + closeRouteTopologyWatcher?.(); + closeRouteTopologyWatcher = undefined; }); type ReactRouterManifest = Awaited< diff --git a/src/route-watch.ts b/src/route-watch.ts new file mode 100644 index 0000000..f869eb2 --- /dev/null +++ b/src/route-watch.ts @@ -0,0 +1,38 @@ +import { access, mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'pathe'; +import type { Route } from './types.js'; + +type RouteManifestSnapshotEntry = Pick< + Route, + 'caseSensitive' | 'file' | 'id' | 'index' | 'parentId' | 'path' +>; + +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 ensureRestartMarker = async ( + restartMarkerPath: string +): Promise => { + await mkdir(dirname(restartMarkerPath), { recursive: true }); + try { + await access(restartMarkerPath); + } catch { + await writeFile(restartMarkerPath, String(Date.now())); + } +}; diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts index 8de83ea..9b70cda 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -9,7 +9,10 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { ensureRestartMarker } from '../src/index'; +import { + createRouteManifestSnapshot, + ensureRestartMarker, +} from '../src/route-watch'; describe('route watch restart marker', () => { it('creates the restart marker when missing', async () => { @@ -40,3 +43,53 @@ describe('route watch restart marker', () => { } }); }); + +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); + }); +}); From dcf9fe97c310545189be71fe5a3620a0b5144c5c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:13:39 +0200 Subject: [PATCH 7/7] fix: refine route watch restart marker --- .../default-template/playwright.config.ts | 6 +- .../tests/e2e/dev-route-watch.test.ts | 2 +- src/index.ts | 226 ++--------------- src/route-watch.ts | 234 +++++++++++++++++- tests/index.test.ts | 51 +++- tests/route-watch.test.ts | 13 +- tests/setup.ts | 4 +- 7 files changed, 323 insertions(+), 213 deletions(-) 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 index de1de84..32035c7 100644 --- a/examples/default-template/tests/e2e/dev-route-watch.test.ts +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -7,7 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const appDirectory = join(__dirname, '../../app'); const restartMarkerPath = join( __dirname, - '../../.react-router/route-watch' + '../../build/client/.react-router/route-watch' ); const routesConfigPath = join(appDirectory, 'routes.ts'); const addedRoutePath = join(appDirectory, 'routes/dev-added-route.tsx'); diff --git a/src/index.ts b/src/index.ts index 963b595..4019e36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,11 @@ -import { - existsSync, - readFileSync, - statSync, - watch, - type FSWatcher, -} from 'node:fs'; -import { mkdir, readdir, writeFile } from 'node:fs/promises'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import { - rspack, - type RsbuildConfig, - type RsbuildPlugin, - type Rspack, -} from '@rsbuild/core'; +import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; @@ -78,8 +67,13 @@ import { createRouteClientEntryArtifact, } from './route-artifacts.js'; import { + createRouteTopologyWatcher, createRouteManifestSnapshot, - ensureRestartMarker, + emitRouteRestartMarkerAsset, + ensureDevRestartMarker, + getRouteRestartMarkerPath, + mergeWatchFiles, + type WatchFileConfig, } from './route-watch.js'; import { validateRouteConfig } from './route-config.js'; import { @@ -103,186 +97,6 @@ type ModuleFederationPluginLike = { options?: { experiments?: { asyncStartup?: boolean } }; }; -type WatchFilesConfig = NonNullable< - NonNullable['watchFiles'] ->; -type WatchFileConfig = - | Exclude - | Extract[number]; - -const mergeWatchFiles = ( - existing: WatchFilesConfig | undefined, - additions: WatchFileConfig[] -): WatchFilesConfig => { - if (!existing) { - return additions as WatchFilesConfig; - } - return [ - ...(Array.isArray(existing) ? existing : [existing]), - ...additions, - ] as WatchFilesConfig; -}; - -type RouteDirectoryState = { - directories: Set; - routeTopology: Set; -}; - -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(), - }; -}; - -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(); - }; -}; - const ensureFederationAsyncStartup = ( rspackConfig: Rspack.Configuration | undefined ): void => { @@ -619,8 +433,10 @@ export const pluginReactRouter = ( isBuild, cache: routeChunkCache, }; + const outputClientPath = resolve(buildDirectory, 'client'); + const assetsBuildDirectory = relative(process.cwd(), outputClientPath); const watchDirectory = resolve(appDirectory); - const routeRestartMarkerPath = resolve('.react-router', 'route-watch'); + const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); const routeWatchFiles: WatchFileConfig[] = [ { paths: routesPath, @@ -634,7 +450,7 @@ export const pluginReactRouter = ( let closeRouteTopologyWatcher: (() => void) | undefined; api.onBeforeStartDevServer(async () => { - await ensureRestartMarker(routeRestartMarkerPath); + await ensureDevRestartMarker(routeRestartMarkerPath); closeRouteTopologyWatcher = await createRouteTopologyWatcher({ watchDirectory, getRouteTopology: getWatchedRouteTopology, @@ -706,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') { @@ -1574,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 index f869eb2..153a0c7 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -1,12 +1,81 @@ -import { access, mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'pathe'; +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 => @@ -26,13 +95,170 @@ export const createRouteManifestSnapshot = ( ) ); -export const ensureRestartMarker = async ( +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, String(Date.now())); + 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 95b4c0f..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', () => { @@ -44,13 +44,60 @@ describe('pluginReactRouter', () => { type: 'reload-server', }, { - paths: expect.stringMatching(/\.react-router\/route-watch$/), + 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 index 9b70cda..0784ef3 100644 --- a/tests/route-watch.test.ts +++ b/tests/route-watch.test.ts @@ -11,16 +11,23 @@ import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { createRouteManifestSnapshot, - ensureRestartMarker, + 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 ensureRestartMarker(markerPath); + await ensureDevRestartMarker(markerPath); expect(readFileSync(markerPath, 'utf8')).not.toBe(''); } finally { @@ -35,7 +42,7 @@ describe('route watch restart marker', () => { mkdirSync(join(root, 'build'), { recursive: true }); writeFileSync(markerPath, 'existing'); - await ensureRestartMarker(markerPath); + await ensureDevRestartMarker(markerPath); expect(readFileSync(markerPath, 'utf8')).toBe('existing'); } finally { diff --git a/tests/setup.ts b/tests/setup.ts index f24ecf8..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. @@ -128,7 +128,7 @@ rstest.mock('@scripts/test-helper', () => ({ }, context: { rootPath: '/Users/bytedance/dev/rsbuild-plugin-react-router', - action: 'dev', + action, }, compiler: { webpack: {