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