diff --git a/.changeset/config-helper-watch.md b/.changeset/config-helper-watch.md new file mode 100644 index 0000000..1d8626b --- /dev/null +++ b/.changeset/config-helper-watch.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Reload the dev server when local helper modules imported by React Router config change. diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index bab0658..a82bafa 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -21,7 +21,7 @@ jobs: env: BENCHMARK_PROFILE: large BENCHMARK_MODE: dev - BENCHMARK_ITERATIONS: '3' + BENCHMARK_ITERATIONS: '5' BENCHMARK_WARMUP: '0' BENCHMARK_DEV_ROUTES: auto BENCHMARK_HISTORY_BRANCH: benchmark-results diff --git a/benchmarks/README.md b/benchmarks/README.md index 1a15002..4693cc0 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -31,7 +31,7 @@ Pass `--mode dev` to measure dev-server startup readiness instead of production builds: ```sh -node scripts/bench-builds.mjs --profile=large --mode=dev --iterations=3 --warmup=0 +node scripts/bench-builds.mjs --profile=large --mode=dev --iterations=5 --warmup=0 ``` Dev mode starts `rsbuild dev`, waits for the required compilers to print ready diff --git a/src/config-imports.ts b/src/config-imports.ts new file mode 100644 index 0000000..c8d13f9 --- /dev/null +++ b/src/config-imports.ts @@ -0,0 +1,45 @@ +import type { ModuleCache } from 'jiti'; +import { resolve } from 'pathe'; + +const normalizePath = (filePath: string): string => resolve(filePath); + +const isNodeModulePath = (filePath: string): boolean => + filePath.split(/[\\/]/).includes('node_modules'); + +export const collectConfigImportWatchPaths = ( + configPath: string, + moduleCache: ModuleCache, + previousCacheKeys: ReadonlySet +): string[] => { + const normalizedConfigPath = normalizePath(configPath); + const watchPaths = new Set(); + + for (const [cacheKey, module] of Object.entries(moduleCache)) { + if (previousCacheKeys.has(cacheKey)) { + continue; + } + + const importPath = normalizePath(module?.filename ?? cacheKey); + if (importPath === normalizedConfigPath || isNodeModulePath(importPath)) { + continue; + } + + watchPaths.add(importPath); + } + + return Array.from(watchPaths); +}; + +export const clearConfigImportCache = ( + moduleCache: ModuleCache, + filePaths: readonly string[] +): void => { + const normalizedFilePaths = new Set(filePaths.map(normalizePath)); + + for (const [cacheKey, module] of Object.entries(moduleCache)) { + const cachedPath = normalizePath(module?.filename ?? cacheKey); + if (normalizedFilePaths.has(cachedPath)) { + delete moduleCache[cacheKey]; + } + } +}; diff --git a/src/index.ts b/src/index.ts index 41ab044..6ec7d8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,10 @@ import { import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; import { registerReactRouterTypegen } from './typegen.js'; +import { + clearConfigImportCache, + collectConfigImportWatchPaths, +} from './config-imports.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; @@ -178,17 +182,16 @@ export const pluginReactRouter = ( registerReactRouterTypegen(api); - const jiti = createJiti(process.cwd(), { - moduleCache: false, - }); - const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); - const configWatchPaths = configExists - ? configPath - : JS_EXTENSIONS.map(extension => - resolve(`react-router.config${extension}`) - ); + let configWatchPaths: string | string[]; + if (configExists) { + configWatchPaths = configPath; + } else { + configWatchPaths = JS_EXTENSIONS.map(extension => + resolve(`react-router.config${extension}`) + ); + } let reactRouterUserConfig: Config = {}; if (!configExists) { console.warn( @@ -196,10 +199,22 @@ export const pluginReactRouter = ( ); } else { const displayPath = relative(process.cwd(), configPath); + const configJiti = createJiti(process.cwd(), { + moduleCache: true, + }); + const cacheKeysBeforeImport = new Set(Object.keys(configJiti.cache)); try { - const imported = await jiti.import(configPath, { + const imported = await configJiti.import(configPath, { default: true, }); + const importedConfigPaths = collectConfigImportWatchPaths( + configPath, + configJiti.cache, + cacheKeysBeforeImport + ); + if (importedConfigPaths.length > 0) { + configWatchPaths = [configPath, ...importedConfigPaths]; + } if (imported === undefined) { throw new Error(`${displayPath} must provide a default export`); } @@ -209,9 +224,22 @@ export const pluginReactRouter = ( reactRouterUserConfig = imported; } catch (error) { throw new Error(`Error loading ${displayPath}: ${error}`); + } finally { + clearConfigImportCache(configJiti.cache, [ + configPath, + ...collectConfigImportWatchPaths( + configPath, + configJiti.cache, + cacheKeysBeforeImport + ), + ]); } } + const jiti = createJiti(process.cwd(), { + moduleCache: false, + }); + const { resolved: resolvedConfig, presets: configPresets, diff --git a/tests/config-imports.test.ts b/tests/config-imports.test.ts new file mode 100644 index 0000000..0833415 --- /dev/null +++ b/tests/config-imports.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from '@rstest/core'; +import type { ModuleCache } from 'jiti'; +import { + clearConfigImportCache, + collectConfigImportWatchPaths, +} from '../src/config-imports'; + +const createModuleCache = (paths: string[]): ModuleCache => + Object.fromEntries( + paths.map(filePath => [ + filePath, + { + filename: filePath, + }, + ]) + ) as ModuleCache; + +describe('config import helpers', () => { + it('collects local modules loaded while importing config', () => { + const configPath = '/project/react-router.config.ts'; + const helperPath = '/project/config/server-bundles.ts'; + const preexistingPath = '/project/build-tool.js'; + + expect( + collectConfigImportWatchPaths( + configPath, + createModuleCache([ + preexistingPath, + '/project/node_modules/jiti/dist/jiti.cjs', + configPath, + helperPath, + ]), + new Set([preexistingPath]) + ) + ).toEqual([helperPath]); + }); + + it('clears only config modules loaded while collecting imports', () => { + const configPath = '/project/react-router.config.ts'; + const helperPath = '/project/config/server-bundles.ts'; + const rspackPath = '/project/node_modules/@rspack/core/dist/index.js'; + const moduleCache = createModuleCache([rspackPath, configPath, helperPath]); + + clearConfigImportCache(moduleCache, [configPath, helperPath]); + + expect(moduleCache).toEqual(createModuleCache([rspackPath])); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index a989d75..e3344fc 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -5,6 +5,8 @@ import { pluginReactRouter } from '../src'; type ReactRouterTestGlobal = typeof globalThis & { __reactRouterTestConfig?: unknown; + __reactRouterTestJitiCache?: Record; + __reactRouterTestJitiCacheAfterImport?: Record; }; type LazyCompilationTestModule = { @@ -165,6 +167,56 @@ describe('pluginReactRouter', () => { } }); + it('reloads the dev server when imported config helpers change', async () => { + const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation(path => { + const filePath = String(path); + if (filePath.includes('react-router.config')) { + return filePath.endsWith('react-router.config.ts'); + } + return ( + filePath.endsWith('app/routes.ts') || + filePath.endsWith('app/root.tsx') + ); + }); + (globalThis as ReactRouterTestGlobal).__reactRouterTestJitiCache = { + '/project/node_modules/jiti/dist/jiti.cjs': { + filename: '/project/node_modules/jiti/dist/jiti.cjs', + }, + }; + (globalThis as ReactRouterTestGlobal).__reactRouterTestJitiCacheAfterImport = + { + '/project/react-router.config.ts': { + filename: '/project/react-router.config.ts', + }, + '/project/config/server-bundles.ts': { + filename: '/project/config/server-bundles.ts', + }, + }; + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.watchFiles).toEqual( + expect.arrayContaining([ + { + paths: expect.arrayContaining([ + expect.stringMatching(/react-router\.config\.ts$/), + expect.stringMatching(/config\/server-bundles\.ts$/), + ]), + type: 'reload-server', + }, + ]) + ); + } finally { + existsSync.mockRestore(); + } + }); + it('lets custom route topology callbacks own route restart handling', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/setup.ts b/tests/setup.ts index 79eb4bc..80e6865 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,10 +3,15 @@ import { afterEach, rstest } from '@rstest/core'; type ReactRouterTestGlobal = typeof globalThis & { __reactRouterTestConfig?: unknown; + __reactRouterTestJitiCache?: Record; + __reactRouterTestJitiCacheAfterImport?: Record; }; afterEach(() => { delete (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig; + delete (globalThis as ReactRouterTestGlobal).__reactRouterTestJitiCache; + delete (globalThis as ReactRouterTestGlobal) + .__reactRouterTestJitiCacheAfterImport; }); // Mock the file system @@ -15,40 +20,52 @@ rstest.spyOn(fs, 'existsSync').mockReturnValue(true); // Mock jiti rstest.mock('jiti', () => ({ - createJiti: () => ({ - import: rstest.fn().mockImplementation((path) => { - if (path.includes('routes.ts')) { - const routeCount = Number(process.env.RR_TEST_ROUTE_COUNT ?? 0); - if (routeCount > 0) { - const childRouteCount = Math.max(0, routeCount - 1); - return Promise.resolve( - Array.from({ length: childRouteCount }, (_, index) => ({ - id: `routes/route-${index}`, - file: `routes/route-${index}.tsx`, - index: index === 0, - })) - ); + createJiti: () => { + const cache = + (globalThis as ReactRouterTestGlobal).__reactRouterTestJitiCache ?? {}; + + return { + cache, + import: rstest.fn().mockImplementation((path) => { + Object.assign( + cache, + (globalThis as ReactRouterTestGlobal) + .__reactRouterTestJitiCacheAfterImport + ); + + if (path.includes('routes.ts')) { + const routeCount = Number(process.env.RR_TEST_ROUTE_COUNT ?? 0); + if (routeCount > 0) { + const childRouteCount = Math.max(0, routeCount - 1); + return Promise.resolve( + Array.from({ length: childRouteCount }, (_, index) => ({ + id: `routes/route-${index}`, + file: `routes/route-${index}.tsx`, + index: index === 0, + })) + ); + } + return Promise.resolve([ + { + id: 'routes/index', + file: 'routes/index.tsx', + index: true, + }, + ]); } - return Promise.resolve([ - { - id: 'routes/index', - file: 'routes/index.tsx', - index: true, - }, - ]); - } - if (process.env.RR_TEST_SPLIT_ROUTE_MODULES === 'true') { - return Promise.resolve({ - ...((globalThis as ReactRouterTestGlobal) - .__reactRouterTestConfig as object | undefined), - splitRouteModules: true, - }); - } - return Promise.resolve( - (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig ?? {} - ); - }), - }), + if (process.env.RR_TEST_SPLIT_ROUTE_MODULES === 'true') { + return Promise.resolve({ + ...((globalThis as ReactRouterTestGlobal) + .__reactRouterTestConfig as object | undefined), + splitRouteModules: true, + }); + } + return Promise.resolve( + (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig ?? {} + ); + }), + }; + }, })); // Mock webpack sources