From 17dfe016e9c923338b589478eb99811739500727 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:28:13 +0000 Subject: [PATCH 1/6] fix config parity gaps --- src/config-dependencies.ts | 82 +++++++++++++++++++++++++++ src/index.ts | 9 ++- tests/config-dependencies.test.ts | 41 ++++++++++++++ tests/index.test.ts | 65 +++++++++++++++++++++ tests/modify-browser-manifest.test.ts | 2 +- 5 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/config-dependencies.ts create mode 100644 tests/config-dependencies.test.ts diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts new file mode 100644 index 0000000..878dc3b --- /dev/null +++ b/src/config-dependencies.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'pathe'; +import { init, parse } from 'es-module-lexer'; +import { JS_EXTENSIONS } from './constants.js'; + +const requireDependencyRE = + /\brequire\s*\(\s*(['"])(?\.{1,2}\/[^'"]+)\1\s*\)/g; + +const resolveDependencyFile = ( + importerPath: string, + specifier: string +): string | undefined => { + if (!specifier.startsWith('.')) { + return undefined; + } + + const absolutePath = resolve(dirname(importerPath), specifier); + const candidates = [absolutePath]; + for (const extension of JS_EXTENSIONS) { + candidates.push(`${absolutePath}${extension}`); + } + for (const extension of JS_EXTENSIONS) { + candidates.push(resolve(absolutePath, `index${extension}`)); + } + + return candidates.find(candidate => { + try { + return existsSync(candidate) && statSync(candidate).isFile(); + } catch { + return false; + } + }); +}; + +const collectDependencySpecifiers = async ( + filePath: string +): Promise => { + let source: string; + try { + source = readFileSync(filePath, 'utf8'); + } catch { + return []; + } + await init; + const [imports] = parse(source); + const specifiers = imports + .map(importSpecifier => importSpecifier.n) + .filter((specifier): specifier is string => Boolean(specifier)); + + for (const match of source.matchAll(requireDependencyRE)) { + const specifier = match.groups?.specifier; + if (specifier) { + specifiers.push(specifier); + } + } + + return specifiers; +}; + +export const collectConfigDependencyWatchPaths = async ( + configPath: string +): Promise => { + const dependencies: string[] = []; + const visited = new Set([configPath]); + const queue = [configPath]; + + while (queue.length > 0) { + const currentPath = queue.shift()!; + for (const specifier of await collectDependencySpecifiers(currentPath)) { + const dependencyPath = resolveDependencyFile(currentPath, specifier); + if (!dependencyPath || visited.has(dependencyPath)) { + continue; + } + + visited.add(dependencyPath); + dependencies.push(dependencyPath); + queue.push(dependencyPath); + } + } + + return dependencies; +}; diff --git a/src/index.ts b/src/index.ts index 41ab044..4bfcc21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,7 @@ import { import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; import { registerReactRouterTypegen } from './typegen.js'; +import { collectConfigDependencyWatchPaths } from './config-dependencies.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; @@ -184,8 +185,13 @@ export const pluginReactRouter = ( const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); + const configDependencyWatchPaths = configExists + ? await collectConfigDependencyWatchPaths(configPath) + : []; const configWatchPaths = configExists - ? configPath + ? configDependencyWatchPaths.length > 0 + ? [configPath, ...configDependencyWatchPaths] + : configPath : JS_EXTENSIONS.map(extension => resolve(`react-router.config${extension}`) ); @@ -230,6 +236,7 @@ export const pluginReactRouter = ( serverBuildFile, serverModuleFormat, splitRouteModules, + subResourceIntegrity, buildEnd, } = resolvedConfig; diff --git a/tests/config-dependencies.test.ts b/tests/config-dependencies.test.ts new file mode 100644 index 0000000..3eaea48 --- /dev/null +++ b/tests/config-dependencies.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { collectConfigDependencyWatchPaths } from '../src/config-dependencies'; + +describe('collectConfigDependencyWatchPaths', () => { + it('recursively collects relative config imports and requires', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-config-deps-')); + + try { + const configPath = join(root, 'react-router.config.ts'); + const serverBundlesPath = join(root, 'config/server-bundles.ts'); + const sharedPath = join(root, 'config/shared.js'); + + mkdirSync(join(root, 'config')); + writeFileSync( + configPath, + [ + "import { serverBundles } from './config/server-bundles';", + "const shared = require('./config/shared.js');", + 'export default { serverBundles, basename: shared.basename };', + ].join('\n') + ); + writeFileSync( + serverBundlesPath, + [ + "export { bundleId } from './shared.js';", + "export const serverBundles = async () => 'main';", + ].join('\n') + ); + writeFileSync(sharedPath, "export const basename = '/app';"); + + await expect(collectConfigDependencyWatchPaths(configPath)).resolves.toEqual( + [serverBundlesPath, sharedPath] + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index a989d75..8de4e97 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -129,6 +129,71 @@ describe('pluginReactRouter', () => { ); }); + it('reloads the dev server when imported config dependencies change', async () => { + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockImplementation(path => { + const filePath = String(path); + if (filePath.endsWith('react-router.config.ts')) { + return "import './config/server-bundles'; export default {};"; + } + if (filePath.endsWith('config/server-bundles.ts')) { + return 'export const value = 1;'; + } + return ''; + }); + const statSync = rstest.spyOn(fs, 'statSync').mockImplementation(path => { + const filePath = String(path); + if ( + filePath.endsWith('react-router.config.ts') || + filePath.endsWith('config/server-bundles.ts') + ) { + return { isFile: () => true } as fs.Stats; + } + throw new Error(`Missing test file: ${filePath}`); + }); + const existsSyncMock = fs.existsSync as unknown as { + mockImplementation: (implementation: (path: unknown) => boolean) => void; + mockReturnValue: (value: boolean) => void; + }; + existsSyncMock.mockImplementation(path => { + const filePath = String(path); + if (filePath.includes('react-router.config')) { + return filePath.endsWith('react-router.config.ts'); + } + if (filePath.includes('config/server-bundles')) { + return filePath.endsWith('config/server-bundles.ts'); + } + return ( + filePath.endsWith('app/routes.ts') || + filePath.endsWith('app/root.tsx') + ); + }); + + try { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + const configWatch = config.dev.watchFiles.find( + (watchFile: { paths: unknown }) => Array.isArray(watchFile.paths) + ); + expect(configWatch).toMatchObject({ + paths: expect.arrayContaining([ + expect.stringMatching(/config\/server-bundles\.ts$/), + ]), + type: 'reload-server', + }); + } finally { + readFileSync.mockRestore(); + statSync.mockRestore(); + existsSyncMock.mockReturnValue(true); + } + }); + it('watches all supported config filenames when the config does not exist yet', async () => { const existsSyncMock = fs.existsSync as unknown as { mockImplementation: (implementation: (path: unknown) => boolean) => void; diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index eb7a327..0f51f9e 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -299,7 +299,7 @@ describe('modify browser manifest plugin', () => { '/', { isBuild: true }, { - future: { unstable_subResourceIntegrity: true }, + subResourceIntegrity: true, onManifest(_manifest, sri) { reportedSri = sri; }, From c4f1d2f66b5ef92fc0b2f8c3f76a0729b4a29fe3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:57:29 +0000 Subject: [PATCH 2/6] chore: simplify config dependency watch cleanup --- src/config-dependencies.ts | 4 ++-- src/index.ts | 23 +++++++++++++---------- tests/index.test.ts | 16 ++++------------ 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts index 878dc3b..c65f484 100644 --- a/src/config-dependencies.ts +++ b/src/config-dependencies.ts @@ -64,8 +64,8 @@ export const collectConfigDependencyWatchPaths = async ( const visited = new Set([configPath]); const queue = [configPath]; - while (queue.length > 0) { - const currentPath = queue.shift()!; + for (let index = 0; index < queue.length; index += 1) { + const currentPath = queue[index]; for (const specifier of await collectDependencySpecifiers(currentPath)) { const dependencyPath = resolveDependencyFile(currentPath, specifier); if (!dependencyPath || visited.has(dependencyPath)) { diff --git a/src/index.ts b/src/index.ts index 4bfcc21..4a9eb91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,16 +185,19 @@ export const pluginReactRouter = ( const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); - const configDependencyWatchPaths = configExists - ? await collectConfigDependencyWatchPaths(configPath) - : []; - const configWatchPaths = configExists - ? configDependencyWatchPaths.length > 0 - ? [configPath, ...configDependencyWatchPaths] - : configPath - : JS_EXTENSIONS.map(extension => - resolve(`react-router.config${extension}`) - ); + let configWatchPaths: string | string[]; + if (configExists) { + const dependencyWatchPaths = + await collectConfigDependencyWatchPaths(configPath); + configWatchPaths = + dependencyWatchPaths.length > 0 + ? [configPath, ...dependencyWatchPaths] + : configPath; + } else { + configWatchPaths = JS_EXTENSIONS.map(extension => + resolve(`react-router.config${extension}`) + ); + } let reactRouterUserConfig: Config = {}; if (!configExists) { console.warn( diff --git a/tests/index.test.ts b/tests/index.test.ts index 8de4e97..ff2d3af 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -152,11 +152,7 @@ describe('pluginReactRouter', () => { } throw new Error(`Missing test file: ${filePath}`); }); - const existsSyncMock = fs.existsSync as unknown as { - mockImplementation: (implementation: (path: unknown) => boolean) => void; - mockReturnValue: (value: boolean) => void; - }; - existsSyncMock.mockImplementation(path => { + 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'); @@ -190,16 +186,12 @@ describe('pluginReactRouter', () => { } finally { readFileSync.mockRestore(); statSync.mockRestore(); - existsSyncMock.mockReturnValue(true); + existsSync.mockReturnValue(true); } }); it('watches all supported config filenames when the config does not exist yet', async () => { - const existsSyncMock = fs.existsSync as unknown as { - mockImplementation: (implementation: (path: unknown) => boolean) => void; - mockReturnValue: (value: boolean) => void; - }; - existsSyncMock.mockImplementation( + const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation( path => !String(path).includes('react-router.config') ); @@ -226,7 +218,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }); } finally { - existsSyncMock.mockReturnValue(true); + existsSync.mockReturnValue(true); } }); From 7c0269cd846838aeea6a672dfa89a5d0b17a1b04 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:51:43 +0000 Subject: [PATCH 3/6] chore: polish config dependency watch --- .changeset/config-dependency-watch.md | 5 ++ package.json | 1 - pnpm-lock.yaml | 43 +++++++++-- src/config-dependencies.ts | 100 +++++++++----------------- src/index.ts | 49 +++++++++---- tests/config-dependencies.test.ts | 77 +++++++++++--------- tests/index.test.ts | 44 +++++------- tests/modify-browser-manifest.test.ts | 2 +- tests/setup.ts | 83 ++++++++++++--------- 9 files changed, 222 insertions(+), 182 deletions(-) create mode 100644 .changeset/config-dependency-watch.md diff --git a/.changeset/config-dependency-watch.md b/.changeset/config-dependency-watch.md new file mode 100644 index 0000000..3d26688 --- /dev/null +++ b/.changeset/config-dependency-watch.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Reload the dev server when files imported by React Router config change. diff --git a/package.json b/package.json index 5cf15b2..285cfd5 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "@types/node": "^25.0.10", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "es-module-lexer": "1.7.0", "kill-port": "^2.0.1", "pkg-pr-new": "^0.0.75", "playwright": "1.58.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625a4e7..0510224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.1.0 version: 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -96,9 +96,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) - es-module-lexer: - specifier: 1.7.0 - version: 1.7.0 kill-port: specifier: ^2.0.1 version: 2.0.1 @@ -9868,7 +9865,7 @@ snapshots: '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.5 + lru-cache: 11.5.1 '@asamuzakjp/dom-selector@6.8.1': dependencies: @@ -12440,6 +12437,15 @@ snapshots: core-js: 3.47.0 jiti: 2.7.0 + '@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)': + dependencies: + '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + optionalDependencies: + core-js: 3.47.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + '@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)': dependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23) @@ -12602,6 +12608,17 @@ snapshots: - '@rspack/core' - webpack + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - '@typescript/native-preview' + - core-js + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0) @@ -12768,6 +12785,13 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 + '@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.0.8 + optionalDependencies: + '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) + '@swc/helpers': 0.5.23 + '@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.0.8 @@ -14471,7 +14495,7 @@ snapshots: '@asamuzakjp/css-color': 4.1.2 '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) css-tree: 3.2.1 - lru-cache: 11.2.5 + lru-cache: 11.5.1 csstype@3.2.3: {} @@ -17117,6 +17141,13 @@ snapshots: transitivePeerDependencies: - supports-color + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + optionalDependencies: + typescript: 5.9.3 + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts index c65f484..9ff8cb0 100644 --- a/src/config-dependencies.ts +++ b/src/config-dependencies.ts @@ -1,82 +1,48 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { dirname, resolve } from 'pathe'; -import { init, parse } from 'es-module-lexer'; -import { JS_EXTENSIONS } from './constants.js'; +import type { ModuleCache } from 'jiti'; +import { resolve } from 'pathe'; -const requireDependencyRE = - /\brequire\s*\(\s*(['"])(?\.{1,2}\/[^'"]+)\1\s*\)/g; +const normalizePath = (filePath: string): string => resolve(filePath); -const resolveDependencyFile = ( - importerPath: string, - specifier: string -): string | undefined => { - if (!specifier.startsWith('.')) { - return undefined; - } +const isNodeModulePath = (filePath: string): boolean => + filePath.split(/[\\/]/).includes('node_modules'); - const absolutePath = resolve(dirname(importerPath), specifier); - const candidates = [absolutePath]; - for (const extension of JS_EXTENSIONS) { - candidates.push(`${absolutePath}${extension}`); - } - for (const extension of JS_EXTENSIONS) { - candidates.push(resolve(absolutePath, `index${extension}`)); - } +export const collectConfigDependencyWatchPaths = ( + configPath: string, + moduleCache: ModuleCache, + previousCacheKeys: ReadonlySet = new Set() +): string[] => { + const normalizedConfigPath = normalizePath(configPath); + const dependencies = new Set(); - return candidates.find(candidate => { - try { - return existsSync(candidate) && statSync(candidate).isFile(); - } catch { - return false; + for (const [cacheKey, module] of Object.entries(moduleCache)) { + if (previousCacheKeys.has(cacheKey)) { + continue; } - }); -}; -const collectDependencySpecifiers = async ( - filePath: string -): Promise => { - let source: string; - try { - source = readFileSync(filePath, 'utf8'); - } catch { - return []; - } - await init; - const [imports] = parse(source); - const specifiers = imports - .map(importSpecifier => importSpecifier.n) - .filter((specifier): specifier is string => Boolean(specifier)); - - for (const match of source.matchAll(requireDependencyRE)) { - const specifier = match.groups?.specifier; - if (specifier) { - specifiers.push(specifier); + const dependencyPath = normalizePath(module?.filename ?? cacheKey); + if ( + dependencyPath === normalizedConfigPath || + isNodeModulePath(dependencyPath) + ) { + continue; } + + dependencies.add(dependencyPath); } - return specifiers; + return Array.from(dependencies); }; -export const collectConfigDependencyWatchPaths = async ( - configPath: string -): Promise => { - const dependencies: string[] = []; - const visited = new Set([configPath]); - const queue = [configPath]; +export const clearConfigModuleCache = ( + moduleCache: ModuleCache, + filePaths: readonly string[] +): void => { + const normalizedFilePaths = new Set(filePaths.map(normalizePath)); - for (let index = 0; index < queue.length; index += 1) { - const currentPath = queue[index]; - for (const specifier of await collectDependencySpecifiers(currentPath)) { - const dependencyPath = resolveDependencyFile(currentPath, specifier); - if (!dependencyPath || visited.has(dependencyPath)) { - continue; - } - - visited.add(dependencyPath); - dependencies.push(dependencyPath); - queue.push(dependencyPath); + for (const [cacheKey, module] of Object.entries(moduleCache)) { + const cachedPath = normalizePath(module?.filename ?? cacheKey); + if (normalizedFilePaths.has(cachedPath)) { + delete moduleCache[cacheKey]; } } - - return dependencies; }; diff --git a/src/index.ts b/src/index.ts index 4a9eb91..655ee31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,10 @@ import { import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; import { registerReactRouterTypegen } from './typegen.js'; -import { collectConfigDependencyWatchPaths } from './config-dependencies.js'; +import { + clearConfigModuleCache, + collectConfigDependencyWatchPaths, +} from './config-dependencies.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; @@ -179,24 +182,15 @@ export const pluginReactRouter = ( registerReactRouterTypegen(api); - const jiti = createJiti(process.cwd(), { - moduleCache: false, - }); - const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); let configWatchPaths: string | string[]; - if (configExists) { - const dependencyWatchPaths = - await collectConfigDependencyWatchPaths(configPath); - configWatchPaths = - dependencyWatchPaths.length > 0 - ? [configPath, ...dependencyWatchPaths] - : configPath; - } else { + if (!configExists) { configWatchPaths = JS_EXTENSIONS.map(extension => resolve(`react-router.config${extension}`) ); + } else { + configWatchPaths = configPath; } let reactRouterUserConfig: Config = {}; if (!configExists) { @@ -205,10 +199,23 @@ 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 dependencyWatchPaths = collectConfigDependencyWatchPaths( + configPath, + configJiti.cache, + cacheKeysBeforeImport + ); + configWatchPaths = + dependencyWatchPaths.length > 0 + ? [configPath, ...dependencyWatchPaths] + : configPath; if (imported === undefined) { throw new Error(`${displayPath} must provide a default export`); } @@ -218,9 +225,22 @@ export const pluginReactRouter = ( reactRouterUserConfig = imported; } catch (error) { throw new Error(`Error loading ${displayPath}: ${error}`); + } finally { + clearConfigModuleCache(configJiti.cache, [ + configPath, + ...collectConfigDependencyWatchPaths( + configPath, + configJiti.cache, + cacheKeysBeforeImport + ), + ]); } } + const jiti = createJiti(process.cwd(), { + moduleCache: false, + }); + const { resolved: resolvedConfig, presets: configPresets, @@ -239,7 +259,6 @@ export const pluginReactRouter = ( serverBuildFile, serverModuleFormat, splitRouteModules, - subResourceIntegrity, buildEnd, } = resolvedConfig; diff --git a/tests/config-dependencies.test.ts b/tests/config-dependencies.test.ts index 3eaea48..1bd43d0 100644 --- a/tests/config-dependencies.test.ts +++ b/tests/config-dependencies.test.ts @@ -1,41 +1,52 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { collectConfigDependencyWatchPaths } from '../src/config-dependencies'; +import type { ModuleCache } from 'jiti'; +import { + clearConfigModuleCache, + collectConfigDependencyWatchPaths, +} from '../src/config-dependencies'; -describe('collectConfigDependencyWatchPaths', () => { - it('recursively collects relative config imports and requires', async () => { - const root = mkdtempSync(join(tmpdir(), 'rr-config-deps-')); +const createModuleCache = (paths: string[]): ModuleCache => + Object.fromEntries( + paths.map(filePath => [ + filePath, + { + filename: filePath, + }, + ]) + ) as ModuleCache; - try { - const configPath = join(root, 'react-router.config.ts'); - const serverBundlesPath = join(root, 'config/server-bundles.ts'); - const sharedPath = join(root, 'config/shared.js'); +describe('config dependency helpers', () => { + it('collects non-package modules loaded while importing config', () => { + const configPath = '/project/react-router.config.ts'; + const dependencyPath = '/project/config/server-bundles.ts'; + const preexistingPath = '/project/build-tool.js'; - mkdirSync(join(root, 'config')); - writeFileSync( + expect( + collectConfigDependencyWatchPaths( configPath, - [ - "import { serverBundles } from './config/server-bundles';", - "const shared = require('./config/shared.js');", - 'export default { serverBundles, basename: shared.basename };', - ].join('\n') - ); - writeFileSync( - serverBundlesPath, - [ - "export { bundleId } from './shared.js';", - "export const serverBundles = async () => 'main';", - ].join('\n') - ); - writeFileSync(sharedPath, "export const basename = '/app';"); + createModuleCache([ + preexistingPath, + '/project/node_modules/jiti/dist/jiti.cjs', + configPath, + dependencyPath, + ]), + new Set([preexistingPath]) + ) + ).toEqual([dependencyPath]); + }); + + it('clears only config modules loaded while collecting dependencies', () => { + const configPath = '/project/react-router.config.ts'; + const dependencyPath = '/project/config/server-bundles.ts'; + const buildToolPath = '/project/node_modules/@rspack/core/dist/index.js'; + const moduleCache = createModuleCache([ + buildToolPath, + configPath, + dependencyPath, + ]); + + clearConfigModuleCache(moduleCache, [configPath, dependencyPath]); - await expect(collectConfigDependencyWatchPaths(configPath)).resolves.toEqual( - [serverBundlesPath, sharedPath] - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } + expect(moduleCache).toEqual(createModuleCache([buildToolPath])); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index ff2d3af..fb83d44 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 = { @@ -130,28 +132,6 @@ describe('pluginReactRouter', () => { }); it('reloads the dev server when imported config dependencies change', async () => { - const readFileSync = rstest - .spyOn(fs, 'readFileSync') - .mockImplementation(path => { - const filePath = String(path); - if (filePath.endsWith('react-router.config.ts')) { - return "import './config/server-bundles'; export default {};"; - } - if (filePath.endsWith('config/server-bundles.ts')) { - return 'export const value = 1;'; - } - return ''; - }); - const statSync = rstest.spyOn(fs, 'statSync').mockImplementation(path => { - const filePath = String(path); - if ( - filePath.endsWith('react-router.config.ts') || - filePath.endsWith('config/server-bundles.ts') - ) { - return { isFile: () => true } as fs.Stats; - } - throw new Error(`Missing test file: ${filePath}`); - }); const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation(path => { const filePath = String(path); if (filePath.includes('react-router.config')) { @@ -165,6 +145,20 @@ describe('pluginReactRouter', () => { 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({ @@ -184,9 +178,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }); } finally { - readFileSync.mockRestore(); - statSync.mockRestore(); - existsSync.mockReturnValue(true); + existsSync.mockRestore(); } }); @@ -218,7 +210,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }); } finally { - existsSync.mockReturnValue(true); + existsSync.mockRestore(); } }); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 0f51f9e..eb7a327 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -299,7 +299,7 @@ describe('modify browser manifest plugin', () => { '/', { isBuild: true }, { - subResourceIntegrity: true, + future: { unstable_subResourceIntegrity: true }, onManifest(_manifest, sri) { reportedSri = sri; }, 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 From aa47e43dc2adf4bcf30f4cb47f7ee444e6870a3c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:41:23 +0000 Subject: [PATCH 4/6] chore: increase benchmark ci iterations --- .github/workflows/benchmark.yml | 2 +- benchmarks/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From d372c22fdd208532678184e8271a40f1b9f36425 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:44:59 +0000 Subject: [PATCH 5/6] chore: drop config dependency discovery --- .changeset/config-dependency-watch.md | 5 -- package.json | 1 + pnpm-lock.yaml | 43 ++------------ src/config-dependencies.ts | 48 ---------------- src/index.ts | 49 ++++------------ tests/config-dependencies.test.ts | 52 ----------------- tests/index.test.ts | 61 ++------------------ tests/setup.ts | 83 +++++++++++---------------- 8 files changed, 56 insertions(+), 286 deletions(-) delete mode 100644 .changeset/config-dependency-watch.md delete mode 100644 src/config-dependencies.ts delete mode 100644 tests/config-dependencies.test.ts diff --git a/.changeset/config-dependency-watch.md b/.changeset/config-dependency-watch.md deleted file mode 100644 index 3d26688..0000000 --- a/.changeset/config-dependency-watch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'rsbuild-plugin-react-router': patch ---- - -Reload the dev server when files imported by React Router config change. diff --git a/package.json b/package.json index 285cfd5..5cf15b2 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@types/node": "^25.0.10", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", + "es-module-lexer": "1.7.0", "kill-port": "^2.0.1", "pkg-pr-new": "^0.0.75", "playwright": "1.58.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0510224..625a4e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 2.1.0(@rsbuild/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.1.0 version: 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -96,6 +96,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) + es-module-lexer: + specifier: 1.7.0 + version: 1.7.0 kill-port: specifier: ^2.0.1 version: 2.0.1 @@ -9865,7 +9868,7 @@ snapshots: '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.5.1 + lru-cache: 11.2.5 '@asamuzakjp/dom-selector@6.8.1': dependencies: @@ -12437,15 +12440,6 @@ snapshots: core-js: 3.47.0 jiti: 2.7.0 - '@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)': - dependencies: - '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - '@swc/helpers': 0.5.23 - optionalDependencies: - core-js: 3.47.0 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - '@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)': dependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23) @@ -12608,17 +12602,6 @@ snapshots: - '@rspack/core' - webpack - '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': - dependencies: - '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - '@typescript/native-preview' - - core-js - '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0) @@ -12785,13 +12768,6 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 - '@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.0.8 - optionalDependencies: - '@module-federation/runtime-tools': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) - '@swc/helpers': 0.5.23 - '@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.0.8 @@ -14495,7 +14471,7 @@ snapshots: '@asamuzakjp/css-color': 4.1.2 '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) css-tree: 3.2.1 - lru-cache: 11.5.1 + lru-cache: 11.2.5 csstype@3.2.3: {} @@ -17141,13 +17117,6 @@ snapshots: transitivePeerDependencies: - supports-color - rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): - dependencies: - '@ast-grep/napi': 0.37.0 - '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - optionalDependencies: - typescript: 5.9.3 - rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 diff --git a/src/config-dependencies.ts b/src/config-dependencies.ts deleted file mode 100644 index 9ff8cb0..0000000 --- a/src/config-dependencies.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 collectConfigDependencyWatchPaths = ( - configPath: string, - moduleCache: ModuleCache, - previousCacheKeys: ReadonlySet = new Set() -): string[] => { - const normalizedConfigPath = normalizePath(configPath); - const dependencies = new Set(); - - for (const [cacheKey, module] of Object.entries(moduleCache)) { - if (previousCacheKeys.has(cacheKey)) { - continue; - } - - const dependencyPath = normalizePath(module?.filename ?? cacheKey); - if ( - dependencyPath === normalizedConfigPath || - isNodeModulePath(dependencyPath) - ) { - continue; - } - - dependencies.add(dependencyPath); - } - - return Array.from(dependencies); -}; - -export const clearConfigModuleCache = ( - 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 655ee31..41ab044 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,10 +82,6 @@ import { import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; import { registerReactRouterTypegen } from './typegen.js'; -import { - clearConfigModuleCache, - collectConfigDependencyWatchPaths, -} from './config-dependencies.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; export { resolveReactRouterServerBuild }; @@ -182,16 +178,17 @@ export const pluginReactRouter = ( registerReactRouterTypegen(api); + const jiti = createJiti(process.cwd(), { + moduleCache: false, + }); + const configPath = findEntryFile(resolve('react-router.config')); const configExists = existsSync(configPath); - let configWatchPaths: string | string[]; - if (!configExists) { - configWatchPaths = JS_EXTENSIONS.map(extension => - resolve(`react-router.config${extension}`) - ); - } else { - configWatchPaths = configPath; - } + const configWatchPaths = configExists + ? configPath + : JS_EXTENSIONS.map(extension => + resolve(`react-router.config${extension}`) + ); let reactRouterUserConfig: Config = {}; if (!configExists) { console.warn( @@ -199,23 +196,10 @@ 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 configJiti.import(configPath, { + const imported = await jiti.import(configPath, { default: true, }); - const dependencyWatchPaths = collectConfigDependencyWatchPaths( - configPath, - configJiti.cache, - cacheKeysBeforeImport - ); - configWatchPaths = - dependencyWatchPaths.length > 0 - ? [configPath, ...dependencyWatchPaths] - : configPath; if (imported === undefined) { throw new Error(`${displayPath} must provide a default export`); } @@ -225,22 +209,9 @@ export const pluginReactRouter = ( reactRouterUserConfig = imported; } catch (error) { throw new Error(`Error loading ${displayPath}: ${error}`); - } finally { - clearConfigModuleCache(configJiti.cache, [ - configPath, - ...collectConfigDependencyWatchPaths( - configPath, - configJiti.cache, - cacheKeysBeforeImport - ), - ]); } } - const jiti = createJiti(process.cwd(), { - moduleCache: false, - }); - const { resolved: resolvedConfig, presets: configPresets, diff --git a/tests/config-dependencies.test.ts b/tests/config-dependencies.test.ts deleted file mode 100644 index 1bd43d0..0000000 --- a/tests/config-dependencies.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; -import type { ModuleCache } from 'jiti'; -import { - clearConfigModuleCache, - collectConfigDependencyWatchPaths, -} from '../src/config-dependencies'; - -const createModuleCache = (paths: string[]): ModuleCache => - Object.fromEntries( - paths.map(filePath => [ - filePath, - { - filename: filePath, - }, - ]) - ) as ModuleCache; - -describe('config dependency helpers', () => { - it('collects non-package modules loaded while importing config', () => { - const configPath = '/project/react-router.config.ts'; - const dependencyPath = '/project/config/server-bundles.ts'; - const preexistingPath = '/project/build-tool.js'; - - expect( - collectConfigDependencyWatchPaths( - configPath, - createModuleCache([ - preexistingPath, - '/project/node_modules/jiti/dist/jiti.cjs', - configPath, - dependencyPath, - ]), - new Set([preexistingPath]) - ) - ).toEqual([dependencyPath]); - }); - - it('clears only config modules loaded while collecting dependencies', () => { - const configPath = '/project/react-router.config.ts'; - const dependencyPath = '/project/config/server-bundles.ts'; - const buildToolPath = '/project/node_modules/@rspack/core/dist/index.js'; - const moduleCache = createModuleCache([ - buildToolPath, - configPath, - dependencyPath, - ]); - - clearConfigModuleCache(moduleCache, [configPath, dependencyPath]); - - expect(moduleCache).toEqual(createModuleCache([buildToolPath])); - }); -}); diff --git a/tests/index.test.ts b/tests/index.test.ts index fb83d44..a989d75 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -5,8 +5,6 @@ import { pluginReactRouter } from '../src'; type ReactRouterTestGlobal = typeof globalThis & { __reactRouterTestConfig?: unknown; - __reactRouterTestJitiCache?: Record; - __reactRouterTestJitiCacheAfterImport?: Record; }; type LazyCompilationTestModule = { @@ -131,59 +129,12 @@ describe('pluginReactRouter', () => { ); }); - it('reloads the dev server when imported config dependencies 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'); - } - if (filePath.includes('config/server-bundles')) { - return filePath.endsWith('config/server-bundles.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(); - - const configWatch = config.dev.watchFiles.find( - (watchFile: { paths: unknown }) => Array.isArray(watchFile.paths) - ); - expect(configWatch).toMatchObject({ - paths: expect.arrayContaining([ - expect.stringMatching(/config\/server-bundles\.ts$/), - ]), - type: 'reload-server', - }); - } finally { - existsSync.mockRestore(); - } - }); - it('watches all supported config filenames when the config does not exist yet', async () => { - const existsSync = rstest.spyOn(fs, 'existsSync').mockImplementation( + const existsSyncMock = fs.existsSync as unknown as { + mockImplementation: (implementation: (path: unknown) => boolean) => void; + mockReturnValue: (value: boolean) => void; + }; + existsSyncMock.mockImplementation( path => !String(path).includes('react-router.config') ); @@ -210,7 +161,7 @@ describe('pluginReactRouter', () => { type: 'reload-server', }); } finally { - existsSync.mockRestore(); + existsSyncMock.mockReturnValue(true); } }); diff --git a/tests/setup.ts b/tests/setup.ts index 80e6865..79eb4bc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,15 +3,10 @@ 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 @@ -20,52 +15,40 @@ rstest.spyOn(fs, 'existsSync').mockReturnValue(true); // Mock jiti rstest.mock('jiti', () => ({ - 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, - }, - ]); + 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, + })) + ); } - 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 ?? {} - ); - }), - }; - }, + 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 ?? {} + ); + }), + }), })); // Mock webpack sources From 956ef5fdecb1d3ace46dc528d2f9a91d37a00d48 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:57:54 +0000 Subject: [PATCH 6/6] fix: watch React Router config helpers --- .changeset/config-helper-watch.md | 5 ++ src/config-imports.ts | 45 +++++++++++++++++ src/index.ts | 48 ++++++++++++++---- tests/config-imports.test.ts | 48 ++++++++++++++++++ tests/index.test.ts | 52 +++++++++++++++++++ tests/setup.ts | 83 +++++++++++++++++++------------ 6 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 .changeset/config-helper-watch.md create mode 100644 src/config-imports.ts create mode 100644 tests/config-imports.test.ts 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/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