Skip to content

Commit 4b2434d

Browse files
committed
Improve config change handling
1 parent a18acbb commit 4b2434d

File tree

3 files changed

+207
-79
lines changed

3 files changed

+207
-79
lines changed

.changeset/stupid-kiwis-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Update config when `react-router.config.ts` is created or deleted during development.

packages/react-router-dev/config/config.ts

Lines changed: 187 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
33
import PackageJson from "@npmcli/package-json";
44
import * as ViteNode from "../vite/vite-node";
55
import type * as Vite from "vite";
6-
import path from "pathe";
6+
import Path from "pathe";
77
import chokidar, {
88
type FSWatcher,
99
type EmitArgs as ChokidarEmitArgs,
@@ -17,12 +17,13 @@ import isEqual from "lodash/isEqual";
1717
import {
1818
type RouteManifest,
1919
type RouteManifestEntry,
20-
type RouteConfig,
2120
setAppDirectory,
2221
validateRouteConfig,
2322
configRoutesToRouteManifest,
2423
} from "./routes";
2524
import { detectPackageManager } from "../cli/detectPackageManager";
25+
import { importViteEsmSync } from "../vite/import-vite-esm-sync";
26+
import { preloadViteEsm } from "../vite/import-vite-esm-sync";
2627

2728
const excludedConfigPresetKeys = ["presets"] as const satisfies ReadonlyArray<
2829
keyof ReactRouterConfig
@@ -405,14 +406,14 @@ async function resolveConfig({
405406
);
406407
}
407408

408-
let appDirectory = path.resolve(root, userAppDirectory || "app");
409-
let buildDirectory = path.resolve(root, userBuildDirectory);
409+
let appDirectory = Path.resolve(root, userAppDirectory || "app");
410+
let buildDirectory = Path.resolve(root, userBuildDirectory);
410411

411412
let rootRouteFile = findEntry(appDirectory, "root");
412413
if (!rootRouteFile) {
413-
let rootRouteDisplayPath = path.relative(
414+
let rootRouteDisplayPath = Path.relative(
414415
root,
415-
path.join(appDirectory, "root.tsx")
416+
Path.join(appDirectory, "root.tsx")
416417
);
417418
return err(
418419
`Could not find a root route module in the app directory as "${rootRouteDisplayPath}"`
@@ -427,17 +428,17 @@ async function resolveConfig({
427428

428429
try {
429430
if (!routeConfigFile) {
430-
let routeConfigDisplayPath = path.relative(
431+
let routeConfigDisplayPath = Path.relative(
431432
root,
432-
path.join(appDirectory, "routes.ts")
433+
Path.join(appDirectory, "routes.ts")
433434
);
434435
return err(`Route config file not found at "${routeConfigDisplayPath}".`);
435436
}
436437

437438
setAppDirectory(appDirectory);
438439
let routeConfigExport = (
439440
await viteNodeContext.runner.executeFile(
440-
path.join(appDirectory, routeConfigFile)
441+
Path.join(appDirectory, routeConfigFile)
441442
)
442443
).default;
443444
let routeConfig = await routeConfigExport;
@@ -462,7 +463,7 @@ async function resolveConfig({
462463
"",
463464
error.loc?.file && error.loc?.column && error.frame
464465
? [
465-
path.relative(appDirectory, error.loc.file) +
466+
Path.relative(appDirectory, error.loc.file) +
466467
":" +
467468
error.loc.line +
468469
":" +
@@ -506,7 +507,8 @@ type ChokidarEventName = ChokidarEmitArgs[0];
506507

507508
type ChangeHandler = (args: {
508509
result: Result<ResolvedReactRouterConfig>;
509-
configCodeUpdated: boolean;
510+
configCodeChanged: boolean;
511+
routeConfigCodeChanged: boolean;
510512
configChanged: boolean;
511513
routeConfigChanged: boolean;
512514
path: string;
@@ -526,20 +528,25 @@ export async function createConfigLoader({
526528
watch: boolean;
527529
rootDirectory?: string;
528530
}): Promise<ConfigLoader> {
529-
root = root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd();
531+
root = Path.normalize(root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd());
530532

531533
let viteNodeContext = await ViteNode.createContext({
532534
root,
533535
mode: watch ? "development" : "production",
534536
server: !watch ? { watch: null } : {},
535-
ssr: {
536-
external: ssrExternals,
537-
},
537+
ssr: { external: ssrExternals },
538+
customLogger: await createCustomLogger(),
538539
});
539540

540-
let reactRouterConfigFile = findEntry(root, "react-router.config", {
541-
absolute: true,
542-
});
541+
let reactRouterConfigFile: string | undefined;
542+
543+
let updateReactRouterConfigFile = () => {
544+
reactRouterConfigFile = findEntry(root, "react-router.config", {
545+
absolute: true,
546+
});
547+
};
548+
549+
updateReactRouterConfigFile();
543550

544551
let getConfig = () =>
545552
resolveConfig({ root, viteNodeContext, reactRouterConfigFile });
@@ -552,9 +559,9 @@ export async function createConfigLoader({
552559
throw new Error(initialConfigResult.error);
553560
}
554561

555-
appDirectory = initialConfigResult.value.appDirectory;
562+
appDirectory = Path.normalize(initialConfigResult.value.appDirectory);
556563

557-
let lastConfig = initialConfigResult.value;
564+
let currentConfig = initialConfigResult.value;
558565

559566
let fsWatcher: FSWatcher | undefined;
560567
let changeHandlers: ChangeHandler[] = [];
@@ -571,54 +578,106 @@ export async function createConfigLoader({
571578
changeHandlers.push(handler);
572579

573580
if (!fsWatcher) {
574-
fsWatcher = chokidar.watch(
575-
[
576-
...(reactRouterConfigFile ? [reactRouterConfigFile] : []),
577-
appDirectory,
578-
],
579-
{ ignoreInitial: true }
580-
);
581+
fsWatcher = chokidar.watch([root, appDirectory], {
582+
ignoreInitial: true,
583+
ignored: (path) => {
584+
let dirname = Path.dirname(path);
585+
586+
return (
587+
path !== root &&
588+
dirname !== root &&
589+
!dirname.startsWith(appDirectory)
590+
);
591+
},
592+
});
581593

582594
fsWatcher.on("all", async (...args: ChokidarEmitArgs) => {
583595
let [event, rawFilepath] = args;
584-
let filepath = path.normalize(rawFilepath);
596+
let filepath = Path.normalize(rawFilepath);
597+
598+
let fileAddedOrRemoved = event === "add" || event === "unlink";
585599

586600
let appFileAddedOrRemoved =
587-
appDirectory &&
588-
(event === "add" || event === "unlink") &&
589-
filepath.startsWith(path.normalize(appDirectory));
601+
fileAddedOrRemoved &&
602+
filepath.startsWith(Path.normalize(appDirectory));
590603

591-
let configCodeUpdated = Boolean(
592-
viteNodeContext.devServer?.moduleGraph.getModuleById(filepath)
593-
);
604+
let rootRelativeFilepath = Path.relative(root, filepath);
605+
606+
let configFileAddedOrRemoved =
607+
fileAddedOrRemoved &&
608+
isEntryFile("react-router.config", rootRelativeFilepath);
594609

595-
if (configCodeUpdated || appFileAddedOrRemoved) {
596-
viteNodeContext.devServer?.moduleGraph.invalidateAll();
597-
viteNodeContext.runner?.moduleCache.clear();
610+
if (configFileAddedOrRemoved) {
611+
updateReactRouterConfigFile();
598612
}
599613

600-
if (appFileAddedOrRemoved || configCodeUpdated) {
601-
let result = await getConfig();
614+
let moduleGraphChanged =
615+
configFileAddedOrRemoved ||
616+
Boolean(
617+
viteNodeContext.devServer?.moduleGraph.getModuleById(filepath)
618+
);
602619

603-
let configChanged = result.ok && !isEqual(lastConfig, result.value);
620+
// Bail out if no relevant changes detected
621+
if (!moduleGraphChanged && !appFileAddedOrRemoved) {
622+
return;
623+
}
624+
625+
viteNodeContext.devServer?.moduleGraph.invalidateAll();
626+
viteNodeContext.runner?.moduleCache.clear();
627+
628+
let result = await getConfig();
629+
630+
let prevAppDirectory = appDirectory;
631+
appDirectory = Path.normalize(
632+
(result.value ?? currentConfig).appDirectory
633+
);
604634

605-
let routeConfigChanged =
606-
result.ok && !isEqual(lastConfig?.routes, result.value.routes);
635+
if (appDirectory !== prevAppDirectory) {
636+
fsWatcher!.unwatch(prevAppDirectory);
637+
fsWatcher!.add(appDirectory);
638+
}
607639

608-
for (let handler of changeHandlers) {
609-
handler({
610-
result,
611-
configCodeUpdated,
612-
configChanged,
613-
routeConfigChanged,
614-
path: filepath,
615-
event,
616-
});
617-
}
640+
let configCodeChanged =
641+
configFileAddedOrRemoved ||
642+
(typeof reactRouterConfigFile === "string" &&
643+
isEntryFileDependency(
644+
viteNodeContext.devServer.moduleGraph,
645+
reactRouterConfigFile,
646+
filepath
647+
));
648+
649+
let routeConfigFile = findEntry(appDirectory, "routes", {
650+
absolute: true,
651+
});
652+
let routeConfigCodeChanged =
653+
typeof routeConfigFile === "string" &&
654+
isEntryFileDependency(
655+
viteNodeContext.devServer.moduleGraph,
656+
routeConfigFile,
657+
filepath
658+
);
659+
660+
let configChanged =
661+
result.ok &&
662+
!isEqual(omitRoutes(currentConfig), omitRoutes(result.value));
663+
664+
let routeConfigChanged =
665+
result.ok && !isEqual(currentConfig?.routes, result.value.routes);
666+
667+
for (let handler of changeHandlers) {
668+
handler({
669+
result,
670+
configCodeChanged,
671+
routeConfigCodeChanged,
672+
configChanged,
673+
routeConfigChanged,
674+
path: filepath,
675+
event,
676+
});
677+
}
618678

619-
if (result.ok) {
620-
lastConfig = result.value;
621-
}
679+
if (result.ok) {
680+
currentConfig = result.value;
622681
}
623682
});
624683
}
@@ -656,8 +715,8 @@ export async function resolveEntryFiles({
656715
}) {
657716
let { appDirectory } = reactRouterConfig;
658717

659-
let defaultsDirectory = path.resolve(
660-
path.dirname(require.resolve("@react-router/dev/package.json")),
718+
let defaultsDirectory = Path.resolve(
719+
Path.dirname(require.resolve("@react-router/dev/package.json")),
661720
"dist",
662721
"config",
663722
"defaults"
@@ -707,12 +766,12 @@ export async function resolveEntryFiles({
707766
}
708767

709768
let entryClientFilePath = userEntryClientFile
710-
? path.resolve(reactRouterConfig.appDirectory, userEntryClientFile)
711-
: path.resolve(defaultsDirectory, entryClientFile);
769+
? Path.resolve(reactRouterConfig.appDirectory, userEntryClientFile)
770+
: Path.resolve(defaultsDirectory, entryClientFile);
712771

713772
let entryServerFilePath = userEntryServerFile
714-
? path.resolve(reactRouterConfig.appDirectory, userEntryServerFile)
715-
: path.resolve(defaultsDirectory, entryServerFile);
773+
? Path.resolve(reactRouterConfig.appDirectory, userEntryServerFile)
774+
: Path.resolve(defaultsDirectory, entryServerFile);
716775

717776
return { entryClientFilePath, entryServerFilePath };
718777
}
@@ -736,28 +795,90 @@ export const ssrExternals = isInReactRouterMonorepo()
736795
function isInReactRouterMonorepo() {
737796
// We use '@react-router/node' for this check since it's a
738797
// dependency of this package and guaranteed to be in node_modules
739-
let serverRuntimePath = path.dirname(
798+
let serverRuntimePath = Path.dirname(
740799
require.resolve("@react-router/node/package.json")
741800
);
742-
let serverRuntimeParentDir = path.basename(
743-
path.resolve(serverRuntimePath, "..")
801+
let serverRuntimeParentDir = Path.basename(
802+
Path.resolve(serverRuntimePath, "..")
744803
);
745804
return serverRuntimeParentDir === "packages";
746805
}
747806

807+
async function createCustomLogger() {
808+
await preloadViteEsm();
809+
const vite = importViteEsmSync();
810+
811+
let customLogger = vite.createLogger(undefined, {
812+
prefix: "[react-router config]",
813+
});
814+
815+
// Patch the info method to filter out page reload messages that show up in
816+
// the terminal when adding or removing the config file
817+
let originalInfo = customLogger.info;
818+
customLogger.info = function (msg, options) {
819+
if (msg.includes("page reload")) {
820+
return;
821+
}
822+
return originalInfo.call(this, msg, options);
823+
};
824+
825+
return customLogger;
826+
}
827+
828+
function omitRoutes(
829+
config: ResolvedReactRouterConfig
830+
): ResolvedReactRouterConfig {
831+
return {
832+
...config,
833+
routes: {},
834+
};
835+
}
836+
748837
const entryExts = [".js", ".jsx", ".ts", ".tsx"];
749838

839+
function isEntryFile(entryBasename: string, filename: string) {
840+
return entryExts.some((ext) => filename === `${entryBasename}${ext}`);
841+
}
842+
750843
function findEntry(
751844
dir: string,
752845
basename: string,
753846
options?: { absolute?: boolean }
754847
): string | undefined {
755848
for (let ext of entryExts) {
756-
let file = path.resolve(dir, basename + ext);
849+
let file = Path.resolve(dir, basename + ext);
757850
if (fs.existsSync(file)) {
758-
return options?.absolute ?? false ? file : path.relative(dir, file);
851+
return options?.absolute ?? false ? file : Path.relative(dir, file);
759852
}
760853
}
761854

762855
return undefined;
763856
}
857+
858+
function isEntryFileDependency(
859+
moduleGraph: Vite.ModuleGraph,
860+
entryFilepath: string,
861+
filepath: string
862+
): boolean {
863+
// Ensure normalized paths
864+
entryFilepath = Path.normalize(entryFilepath);
865+
filepath = Path.normalize(filepath);
866+
867+
if (filepath === entryFilepath) {
868+
return true;
869+
}
870+
871+
let mod = moduleGraph.getModuleById(filepath);
872+
873+
if (!mod) {
874+
return false;
875+
}
876+
877+
for (let importer of mod.importers) {
878+
if (importer.id === entryFilepath) {
879+
return true;
880+
}
881+
}
882+
883+
return false;
884+
}

0 commit comments

Comments
 (0)