Repository: remix-run/remix
Pull request #6299 (2023-05-03 23:43:37)

feat(dev): cross-module loader change detection
## TODO

- [x] move HDR compiler to dev server flow
- [x] rip out HMR `onLoader` code from JS subcompiler
- [x] remove HMR stuff from manifest
- [x] compute `revalidate` HMR field from HDR results
- [x] Enable commented out check at end of `hmr-test` and `hmr-log-test`
- [x] MDX
- [x] pass updated ctx thru onBuildStart
- [x] changelog

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 1bcce40a6232c39a97e9bd99c7f6046ca541eb51

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/remix-run/remix/new/pedro/cross-module-loader-change-detection?filename=.changeset/moody-pumas-allow.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A---%0A%0Awip%0A)



Patch:
diff --git a/.changeset/afraid-coats-count.md b/.changeset/afraid-coats-count.md
new file mode 100644
index 000000000..b6c06cc7c
--- /dev/null
+++ b/.changeset/afraid-coats-count.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/dev": patch
+"@remix-run/react": patch
+---
+
+cross-module loader change detection for hdr
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 252449b9b..200a7a81d 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -183,7 +183,7 @@ export async function build(
       host: dev.httpHost,
       port: dev.httpPort,
     };
-    options.devWebsocketPort = dev.websocketPort;
+    options.devWebSocketPort = dev.webSocketPort;
   }
 
   fse.emptyDirSync(config.assetsBuildDirectory);
@@ -475,7 +475,7 @@ type DevBuildFlags = {
   httpScheme: string;
   httpHost: string;
   httpPort: number;
-  websocketPort: number;
+  webSocketPort: number;
 };
 let resolveDevBuild = async (
   config: RemixConfig,
@@ -500,16 +500,16 @@ let resolveDevBuild = async (
     (dev === true ? undefined : dev.httpPort) ??
     (await findPort());
   // prettier-ignore
-  let websocketPort =
-    flags.websocketPort ??
-    (dev === true ? undefined : dev.websocketPort) ??
+  let webSocketPort =
+    flags.webSocketPort ??
+    (dev === true ? undefined : dev.webSocketPort) ??
     (await findPort());
 
   return {
     httpScheme,
     httpHost,
     httpPort,
-    websocketPort,
+    webSocketPort,
   };
 };
 
@@ -524,7 +524,7 @@ let resolveDevServe = async (
   let dev = config.future.unstable_dev;
   if (dev === false) throw Error("Cannot resolve dev options");
 
-  let { httpScheme, httpHost, httpPort, websocketPort } = await resolveDevBuild(
+  let { httpScheme, httpHost, httpPort, webSocketPort } = await resolveDevBuild(
     config,
     flags
   );
@@ -561,7 +561,7 @@ let resolveDevServe = async (
     httpScheme,
     httpHost,
     httpPort,
-    websocketPort,
+    webSocketPort,
     restart,
   };
 };
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index c4448f122..524b1bdc8 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -48,7 +48,7 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
     --http-host         HTTP(S) host for the dev server. Default: localhost
     --http-port         HTTP(S) port for the dev server. Default: any open port
     --no-restart        Do not restart the app server when rebuilds occur.
-    --websocket-port    Websocket port for the dev server. Default: any open port
+    --websocket-port    WebSocket port for the dev server. Default: any open port
   \`init\` Options:
     --no-delete         Skip deleting the \`remix.init\` script
   \`routes\` Options:
@@ -226,7 +226,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
     delete flags["http-port"];
   }
   if (flags["websocket-port"]) {
-    flags.websocketPort = flags["websocket-port"];
+    flags.webSocketPort = flags["websocket-port"];
     delete flags["websocket-port"];
   }
 
diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts
index 83fb44b99..ce3fb3009 100644
--- a/packages/remix-dev/compiler/compiler.ts
+++ b/packages/remix-dev/compiler/compiler.ts
@@ -93,7 +93,6 @@ export let create = async (ctx: Context): Promise<Compiler> => {
     }
 
     // js compilation (implicitly writes artifacts/js)
-    // TODO: js task should not return metafile, but rather js assets
     let js = await tasks.js;
     if (!js.ok) throw error ?? js.error;
     let { metafile, hmr } = js.value;
diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts
index e2bed448f..d55e9ff98 100644
--- a/packages/remix-dev/compiler/js/compiler.ts
+++ b/packages/remix-dev/compiler/js/compiler.ts
@@ -72,7 +72,6 @@ const getExternals = (remixConfig: RemixConfig): string[] => {
 
 const createEsbuildConfig = (
   ctx: Context,
-  onLoader: (filename: string, code: string) => void,
   channels: { cssBundleHref: Channel.Type<string | undefined> }
 ): esbuild.BuildOptions => {
   let entryPoints: Record<string, string> = {
@@ -142,7 +141,7 @@ const createEsbuildConfig = (
     absoluteCssUrlsPlugin(),
     externalPlugin(/^https?:\/\//, { sideEffects: false }),
     ctx.config.future.unstable_dev
-      ? browserRouteModulesPlugin_v2(ctx, routeModulePaths, onLoader)
+      ? browserRouteModulesPlugin_v2(ctx, routeModulePaths)
       : browserRouteModulesPlugin(ctx, /\?browser$/),
     mdxPlugin(ctx),
     emptyModulesPlugin(ctx, /\.server(\.[jt]sx?)?$/),
@@ -237,19 +236,12 @@ export const create = async (
   ctx: Context,
   channels: { cssBundleHref: Channel.Type<string | undefined> }
 ): Promise<Compiler> => {
-  let hmrRoutes: Record<string, { loaderHash: string }> = {};
-  let onLoader = (filename: string, code: string) => {
-    let key = path.relative(ctx.config.rootDirectory, filename);
-    hmrRoutes[key] = { loaderHash: code };
-  };
-
   let compiler = await esbuild.context({
-    ...createEsbuildConfig(ctx, onLoader, channels),
+    ...createEsbuildConfig(ctx, channels),
     metafile: true,
   });
 
   let compile = async () => {
-    hmrRoutes = {};
     let { metafile } = await compiler.rebuild();
 
     let hmr: Manifest["hmr"] | undefined = undefined;
@@ -266,7 +258,6 @@ export const create = async (
         );
       hmr = {
         runtime: hmrRuntime,
-        routes: hmrRoutes,
         timestamp: Date.now(),
       };
     }
diff --git a/packages/remix-dev/compiler/js/plugins/routes_unstable.ts b/packages/remix-dev/compiler/js/plugins/routes_unstable.ts
index 1fbfcb405..7640a0dd9 100644
--- a/packages/remix-dev/compiler/js/plugins/routes_unstable.ts
+++ b/packages/remix-dev/compiler/js/plugins/routes_unstable.ts
@@ -12,7 +12,7 @@ import { processMDX } from "../../plugins/mdx";
 
 const serverOnlyExports = new Set(["action", "loader"]);
 
-let removeServerExports = (onLoader: (loader: string) => void) =>
+let removeServerExports = () =>
   Transform.create(({ types: t }) => {
     return {
       visitor: {
@@ -38,20 +38,12 @@ let removeServerExports = (onLoader: (loader: string) => void) =>
           if (t.isFunctionDeclaration(node.declaration)) {
             let name = node.declaration.id?.name;
             if (!name) return;
-            if (name === "loader") {
-              let { code } = generate(node);
-              onLoader(code);
-            }
             if (serverOnlyExports.has(name)) return path.remove();
           }
           if (t.isVariableDeclaration(node.declaration)) {
             let declarations = node.declaration.declarations.filter((d) => {
               let name = t.isIdentifier(d.id) ? d.id.name : undefined;
               if (!name) return false;
-              if (name === "loader") {
-                let { code } = generate(node);
-                onLoader(code);
-              }
               return !serverOnlyExports.has(name);
             });
             if (declarations.length === 0) return path.remove();
@@ -72,8 +64,7 @@ let removeServerExports = (onLoader: (loader: string) => void) =>
  */
 export function browserRouteModulesPlugin(
   { config, options }: Context,
-  routeModulePaths: Map<string, string>,
-  onLoader: (filename: string, code: string) => void
+  routeModulePaths: Map<string, string>
 ): esbuild.Plugin {
   return {
     name: "browser-route-modules",
@@ -119,9 +110,7 @@ export function browserRouteModulesPlugin(
               return mdxResult;
             }
 
-            let transform = removeServerExports((loader: string) =>
-              onLoader(routeFile, loader)
-            );
+            let transform = removeServerExports();
             mdxResult.contents = transform(
               mdxResult.contents,
               // Trick babel into allowing JSX syntax.
@@ -134,10 +123,7 @@ export function browserRouteModulesPlugin(
 
           let value = cache.get(file);
           if (!value || value.sourceCode !== sourceCode) {
-            let extractedLoader;
-            let transform = removeServerExports(
-              (loader: string) => (extractedLoader = loader)
-            );
+            let transform = removeServerExports();
             let contents = transform(sourceCode, routeFile);
 
             if (options.mode === "development" && config.future.unstable_dev) {
@@ -153,7 +139,6 @@ export function browserRouteModulesPlugin(
             }
             value = {
               sourceCode,
-              extractedLoader,
               output: {
                 contents,
                 loader: getLoaderForFile(routeFile),
@@ -163,10 +148,6 @@ export function browserRouteModulesPlugin(
             cache.set(file, value);
           }
 
-          if (value.extractedLoader) {
-            onLoader(routeFile, value.extractedLoader);
-          }
-
           return value.output;
         }
       );
diff --git a/packages/remix-dev/compiler/manifest.ts b/packages/remix-dev/compiler/manifest.ts
index 412574fc8..0f1de86f3 100644
--- a/packages/remix-dev/compiler/manifest.ts
+++ b/packages/remix-dev/compiler/manifest.ts
@@ -93,9 +93,7 @@ export async function create({
   invariant(entry, `Missing output for entry point`);
 
   optimizeRoutes(routes, entry.imports);
-  let version = getHash(
-    JSON.stringify({ entry, routes, hmrRoutes: hmr?.routes })
-  ).slice(0, 8);
+  let version = getHash(JSON.stringify({ entry, routes })).slice(0, 8);
 
   return { version, entry, routes, cssBundleHref, hmr };
 }
diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts
index d2f9dc979..165b89cba 100644
--- a/packages/remix-dev/compiler/options.ts
+++ b/packages/remix-dev/compiler/options.ts
@@ -11,5 +11,5 @@ export type Options = {
     host: string;
     port: number;
   };
-  devWebsocketPort?: number;
+  devWebSocketPort?: number;
 };
diff --git a/packages/remix-dev/compiler/server/plugins/entry.ts b/packages/remix-dev/compiler/server/plugins/entry.ts
index 76b16114d..3d64193e3 100644
--- a/packages/remix-dev/compiler/server/plugins/entry.ts
+++ b/packages/remix-dev/compiler/server/plugins/entry.ts
@@ -51,9 +51,9 @@ ${Object.keys(config.routes)
   export const publicPath = ${JSON.stringify(config.publicPath)};
   export const entry = { module: entryServer };
   ${
-    options.devWebsocketPort
+    options.devWebSocketPort
       ? `export const dev = ${JSON.stringify({
-          websocketPort: options.devWebsocketPort,
+          websocketPort: options.devWebSocketPort,
         })}`
       : ""
   }
diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts
index d8723aebe..0cede310a 100644
--- a/packages/remix-dev/compiler/watch.ts
+++ b/packages/remix-dev/compiler/watch.ts
@@ -57,7 +57,6 @@ export async function watch(
   onBuildFinish?.(ctx, Date.now() - start, manifest !== undefined);
 
   let restart = debounce(async () => {
-    onBuildStart?.(ctx);
     let start = Date.now();
     compiler.dispose();
 
@@ -67,6 +66,7 @@ export async function watch(
       logThrown(thrown);
       return;
     }
+    onBuildStart?.(ctx);
 
     compiler = await Compiler.create(ctx);
     let manifest = await compile();
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 8f427f9dc..a795c10cc 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -43,7 +43,7 @@ type Dev = {
   httpScheme?: string;
   httpHost?: string;
   httpPort?: number;
-  websocketPort?: number;
+  webSocketPort?: number;
   restart?: boolean;
   publicDirectory?: string;
 };
diff --git a/packages/remix-dev/devServer_unstable/hdr.ts b/packages/remix-dev/devServer_unstable/hdr.ts
new file mode 100644
index 000000000..f391e3b3c
--- /dev/null
+++ b/packages/remix-dev/devServer_unstable/hdr.ts
@@ -0,0 +1,108 @@
+import * as path from "node:path";
+import esbuild from "esbuild";
+
+import type { Context } from "../compiler/context";
+import { emptyModulesPlugin } from "../compiler/plugins/emptyModules";
+import { externalPlugin } from "../compiler/plugins/external";
+import { getRouteModuleExports } from "../compiler/utils/routeExports";
+import { createMatchPath } from "../compiler/utils/tsconfig";
+import invariant from "../invariant";
+import { mdxPlugin } from "../compiler/plugins/mdx";
+
+function isBareModuleId(id: string): boolean {
+  return !id.startsWith("node:") && !id.startsWith(".") && !path.isAbsolute(id);
+}
+
+type Route = Context["config"]["routes"][string];
+
+export let detectLoaderChanges = async (ctx: Context) => {
+  let entryPoints: Record<string, string> = {};
+  for (let id of Object.keys(ctx.config.routes)) {
+    entryPoints[id] = ctx.config.routes[id].file + "?loader";
+  }
+  let options: esbuild.BuildOptions = {
+    bundle: true,
+    entryPoints: entryPoints,
+    treeShaking: true,
+    metafile: true,
+    outdir: ".",
+    write: false,
+    entryNames: "[hash]",
+    plugins: [
+      {
+        name: "hmr-loader",
+        setup(build) {
+          let routesByFile: Map<string, Route> = Object.keys(
+            ctx.config.routes
+          ).reduce((map, key) => {
+            let route = ctx.config.routes[key];
+            map.set(route.file, route);
+            return map;
+          }, new Map());
+          let filter = /\?loader$/;
+          build.onResolve({ filter }, (args) => {
+            return { path: args.path, namespace: "hmr-loader" };
+          });
+          build.onLoad({ filter, namespace: "hmr-loader" }, async (args) => {
+            let file = args.path.replace(filter, "");
+            let route = routesByFile.get(file);
+            invariant(route, `Cannot get route by path: ${args.path}`);
+            let theExports = await getRouteModuleExports(ctx.config, route.id);
+            let contents = "module.exports = {};";
+            if (theExports.includes("loader")) {
+              contents = `export { loader } from ${JSON.stringify(
+                `./${file}`
+              )};`;
+            }
+            return {
+              contents,
+              resolveDir: ctx.config.appDirectory,
+              loader: "js",
+            };
+          });
+        },
+      },
+      externalPlugin(/^node:.*/, { sideEffects: false }),
+      externalPlugin(/\.css$/, { sideEffects: false }),
+      externalPlugin(/^https?:\/\//, { sideEffects: false }),
+      mdxPlugin(ctx),
+      emptyModulesPlugin(ctx, /\.client(\.[jt]sx?)?$/),
+      {
+        name: "hmr-bare-modules",
+        setup(build) {
+          let matchPath = ctx.config.tsconfigPath
+            ? createMatchPath(ctx.config.tsconfigPath)
+            : undefined;
+          function resolvePath(id: string) {
+            if (!matchPath) return id;
+            return (
+              matchPath(id, undefined, undefined, [
+                ".ts",
+                ".tsx",
+                ".js",
+                ".jsx",
+              ]) || id
+            );
+          }
+          build.onResolve({ filter: /.*/ }, (args) => {
+            if (!isBareModuleId(resolvePath(args.path))) {
+              return undefined;
+            }
+            return { path: args.path, external: true };
+          });
+        },
+      },
+    ],
+  };
+
+  let { metafile } = await esbuild.build(options);
+  let entries = Object.entries(metafile!.outputs).map(
+    ([hashjs, { entryPoint }]) => {
+      let file = entryPoint
+        ?.replace(/^hmr-loader:/, "")
+        ?.replace(/\?loader$/, "");
+      return [file, hashjs.replace(/\.js$/, "")];
+    }
+  );
+  return Object.fromEntries(entries);
+};
diff --git a/packages/remix-dev/devServer_unstable/hmr.ts b/packages/remix-dev/devServer_unstable/hmr.ts
index cc6a8f3aa..6f88ab803 100644
--- a/packages/remix-dev/devServer_unstable/hmr.ts
+++ b/packages/remix-dev/devServer_unstable/hmr.ts
@@ -5,6 +5,7 @@ import { type Manifest } from "../manifest";
 
 export type Update = {
   id: string;
+  routeId: string;
   url: string;
   revalidate: boolean;
   reason: string;
@@ -16,12 +17,10 @@ export type Update = {
 export let updates = (
   config: RemixConfig,
   manifest: Manifest,
-  prevManifest: Manifest
+  prevManifest: Manifest,
+  hdr: Record<string, string>,
+  prevHdr?: Record<string, string>
 ): Update[] => {
-  // TODO: probably want another map to correlate every input file to the
-  // routes that consume it
-  // ^check if route chunk hash changes when its dependencies change, even in different chunks
-
   let updates: Update[] = [];
   for (let [routeId, route] of Object.entries(manifest.routes)) {
     let prevRoute = prevManifest.routes[routeId] as typeof route | undefined;
@@ -35,6 +34,7 @@ export let updates = (
     if (!prevRoute) {
       updates.push({
         id: moduleId,
+        routeId: route.id,
         url: route.module,
         revalidate: true,
         reason: "Route added",
@@ -43,11 +43,12 @@ export let updates = (
     }
 
     // when loaders are diff
-    let loaderHash = manifest.hmr?.routes[moduleId]?.loaderHash;
-    let prevLoaderHash = prevManifest.hmr?.routes[moduleId]?.loaderHash;
+    let loaderHash = hdr[file];
+    let prevLoaderHash = prevHdr?.[file];
     if (loaderHash !== prevLoaderHash) {
       updates.push({
         id: moduleId,
+        routeId: route.id,
         url: route.module,
         revalidate: true,
         reason: "Loader changed",
@@ -62,6 +63,7 @@ export let updates = (
     if (diffModule || xorImports.size > 0) {
       updates.push({
         id: moduleId,
+        routeId: route.id,
         url: route.module,
         revalidate: false,
         reason: "Component changed",
diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts
index 05a427229..75e8c7fa2 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -14,6 +14,7 @@ import * as Socket from "./socket";
 import * as HMR from "./hmr";
 import { warnOnce } from "../warnOnce";
 import { detectPackageManager } from "../cli/detectPackageManager";
+import * as HDR from "./hdr";
 
 type Origin = {
   scheme: string;
@@ -41,12 +42,12 @@ export let serve = async (
     httpScheme: string;
     httpHost: string;
     httpPort: number;
-    websocketPort: number;
+    webSocketPort: number;
     restart: boolean;
   }
 ) => {
   await loadEnv(initialConfig.rootDirectory);
-  let websocket = Socket.serve({ port: options.websocketPort });
+  let websocket = Socket.serve({ port: options.webSocketPort });
   let httpOrigin: Origin = {
     scheme: options.httpScheme,
     host: options.httpHost,
@@ -58,6 +59,8 @@ export let serve = async (
     manifest?: Manifest;
     prevManifest?: Manifest;
     appReady?: Channel.Type<void>;
+    hdr?: Promise<Record<string, string>>;
+    prevLoaderHashes?: Record<string, string>;
   } = {};
 
   let bin = await detectBin();
@@ -111,14 +114,16 @@ export let serve = async (
         sourcemap: true,
         onWarning: warnOnce,
         devHttpOrigin: httpOrigin,
-        devWebsocketPort: options.websocketPort,
+        devWebSocketPort: options.webSocketPort,
       },
     },
     {
-      onBuildStart: (ctx) => {
+      onBuildStart: async (ctx) => {
         state.appReady?.err();
         clean(ctx.config);
         websocket.log(state.prevManifest ? "Rebuilding..." : "Building...");
+
+        state.hdr = HDR.detectLoaderChanges(ctx);
       },
       onBuildManifest: (manifest: Manifest) => {
         state.manifest = manifest;
@@ -143,14 +148,16 @@ export let serve = async (
         }
         let { ok } = await state.appReady.result;
         // result not ok -> new build started before this one finished. do not process outdated manifest
+        let loaderHashes = await state.hdr;
         if (ok) {
           console.log(`App server took ${prettyMs(Date.now() - start)}`);
-
-          if (state.manifest?.hmr && state.prevManifest) {
+          if (state.manifest && loaderHashes && state.prevManifest) {
             let updates = HMR.updates(
               ctx.config,
               state.manifest,
-              state.prevManifest
+              state.prevManifest,
+              loaderHashes,
+              state.prevLoaderHashes
             );
             websocket.hmr(state.manifest, updates);
 
@@ -162,6 +169,7 @@ export let serve = async (
           }
         }
         state.prevManifest = state.manifest;
+        state.prevLoaderHashes = loaderHashes;
       },
       onFileCreated: (file) =>
         websocket.log(`File created: ${relativePath(file)}`),
diff --git a/packages/remix-dev/manifest.ts b/packages/remix-dev/manifest.ts
index 48bd04359..12391a827 100644
--- a/packages/remix-dev/manifest.ts
+++ b/packages/remix-dev/manifest.ts
@@ -24,6 +24,5 @@ export type Manifest = {
   hmr?: {
     timestamp: number;
     runtime: string;
-    routes: Record<string, { loaderHash: string }>;
   };
 };
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 4453d171d..c81a6ed80 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -55,7 +55,7 @@ if (import.meta && import.meta.hot) {
       needsRevalidation,
     }: {
       assetsManifest: EntryContext["manifest"];
-      needsRevalidation: boolean;
+      needsRevalidation: Set<string>;
     }) => {
       let routeIds = [
         ...new Set(
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index c4077f161..89fa1b7e9 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -1759,12 +1759,12 @@ export const LiveReload =
                       }
                       if (!event.updates || !event.updates.length) return;
                       let updateAccepted = false;
-                      let needsRevalidation = false;
+                      let needsRevalidation = new Set();
                       for (let update of event.updates) {
                         console.log("[HMR] " + update.reason + " [" + update.id +"]")
                         if (update.revalidate) {
-                          needsRevalidation = true;
-                          console.log("[HMR] Revalidating [" + update.id + "]");
+                          needsRevalidation.add(update.routeId);
+                          console.log("[HMR] Revalidating [" + update.routeId + "]");
                         }
                         let imported = await import(update.url +  '?t=' + event.assetsManifest.hmr.timestamp);
                         if (window.__hmr__.contexts[update.id]) {
diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx
index 6ac78f824..3e49768e9 100644
--- a/packages/remix-react/routes.tsx
+++ b/packages/remix-react/routes.tsx
@@ -105,7 +105,7 @@ export function createServerRoutes(
 }
 
 export function createClientRoutesWithHMRRevalidationOptOut(
-  needsRevalidation: boolean,
+  needsRevalidation: Set<string>,
   manifest: RouteManifest<EntryRoute>,
   routeModulesCache: RouteModules,
   future: FutureConfig
@@ -129,7 +129,7 @@ export function createClientRoutes(
     string,
     Omit<EntryRoute, "children">[]
   > = groupRoutesByParentId(manifest),
-  needsRevalidation: boolean | undefined = undefined
+  needsRevalidation?: Set<string>
 ): DataRouteObject[] {
   return (routesByParentId[parentId] || []).map((route) => {
     let hasErrorBoundary =
@@ -163,7 +163,9 @@ export function createClientRoutes(
       manifest,
       routeModulesCache,
       future,
-      route.id
+      route.id,
+      routesByParentId,
+      needsRevalidation
     );
     if (children.length > 0) dataRoute.children = children;
     return dataRoute;
@@ -173,7 +175,7 @@ export function createClientRoutes(
 function createShouldRevalidate(
   route: EntryRoute,
   routeModules: RouteModules,
-  needsRevalidation: boolean | undefined
+  needsRevalidation?: Set<string>
 ): ShouldRevalidateFunction {
   let handledRevalidation = false;
   return function (arg) {
@@ -183,9 +185,9 @@ function createShouldRevalidate(
     // When an HMR / HDR update happens we opt out of all user-defined
     // revalidation logic and the do as the dev server tells us the first
     // time router.revalidate() is called.
-    if (typeof needsRevalidation === "boolean" && !handledRevalidation) {
+    if (needsRevalidation !== undefined && !handledRevalidation) {
       handledRevalidation = true;
-      return needsRevalidation;
+      return needsRevalidation.has(route.id);
     }
 
     if (module.shouldRevalidate) {


Test patch:
diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts
index b873ab525..cdb02556e 100644
--- a/integration/hmr-log-test.ts
+++ b/integration/hmr-log-test.ts
@@ -125,6 +125,11 @@ let fixture = (options: {
         ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [],
       ];
 
+      // dummy loader to make sure that HDR is granular
+      export const loader = () => {
+        return null;
+      };
+
       export default function Root() {
         return (
           <html lang="en" className="h-full">
@@ -141,6 +146,7 @@ let fixture = (options: {
                   <ul>
                     <li><Link to="/">Home</Link></li>
                     <li><Link to="/about">About</Link></li>
+                    <li><Link to="/mdx">MDX</Link></li>
                   </ul>
                 </nav>
               </header>
@@ -179,6 +185,18 @@ let fixture = (options: {
         )
       }
     `,
+    "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react'
+export const loader = () => "crazy"
+export const Component = () => {
+  const data = useLoaderData()
+  return <h1 id={data}>{data}</h1>
+}
+
+# heyo
+whatsup
+
+<Component/>
+`,
 
     "app/components/counter.tsx": js`
       import * as React from "react";
@@ -272,6 +290,8 @@ test("HMR", async ({ page }) => {
     let originalCounter = fs.readFileSync(counterPath, "utf8");
     let cssModulePath = path.join(projectDir, "app", "styles.module.css");
     let originalCssModule = fs.readFileSync(cssModulePath, "utf8");
+    let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx");
+    let originalMdx = fs.readFileSync(mdxPath, "utf8");
 
     // make content and style changed to index route
     let newCssModule = `
@@ -419,9 +439,30 @@ test("HMR", async ({ page }) => {
       `#about-counter:has-text("inc 0")`
     );
 
-    // This should not have triggered any revalidation but our detection is
-    // failing for x-module changes for route module imports
-    // expect(dataRequests).toBe(2);
+    expect(dataRequests).toBe(2);
+
+    // mdx
+    await page.click(`a[href="/mdx"]`);
+    await page.waitForSelector(`#crazy`);
+    let mdx = `import { useLoaderData } from '@remix-run/react'
+export const loader = () => "hot"
+export const Component = () => {
+  const data = useLoaderData()
+  return <h1 id={data}>{data}</h1>
+}
+
+# heyo
+whatsup
+
+<Component/>
+`;
+    fs.writeFileSync(mdxPath, mdx);
+    await page.waitForSelector(`#hot`);
+    expect(dataRequests).toBe(4);
+
+    fs.writeFileSync(mdxPath, originalMdx);
+    await page.waitForSelector(`#crazy`);
+    expect(dataRequests).toBe(5);
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index 1f54f4b63..b3c418906 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -125,6 +125,11 @@ let fixture = (options: {
         ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [],
       ];
 
+      // dummy loader to make sure that HDR is granular
+      export const loader = () => {
+        return null;
+      };
+
       export default function Root() {
         return (
           <html lang="en" className="h-full">
@@ -141,6 +146,7 @@ let fixture = (options: {
                   <ul>
                     <li><Link to="/">Home</Link></li>
                     <li><Link to="/about">About</Link></li>
+                    <li><Link to="/mdx">MDX</Link></li>
                   </ul>
                 </nav>
               </header>
@@ -179,7 +185,18 @@ let fixture = (options: {
         )
       }
     `,
-
+    "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react'
+export const loader = () => "crazy"
+export const Component = () => {
+  const data = useLoaderData()
+  return <h1 id={data}>{data}</h1>
+}
+
+# heyo
+whatsup
+
+<Component/>
+`,
     "app/components/counter.tsx": js`
       import * as React from "react";
       export default function Counter({ id }) {
@@ -272,6 +289,8 @@ test("HMR", async ({ page }) => {
     let originalCounter = fs.readFileSync(counterPath, "utf8");
     let cssModulePath = path.join(projectDir, "app", "styles.module.css");
     let originalCssModule = fs.readFileSync(cssModulePath, "utf8");
+    let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx");
+    let originalMdx = fs.readFileSync(mdxPath, "utf8");
 
     // make content and style changed to index route
     let newCssModule = `
@@ -419,9 +438,30 @@ test("HMR", async ({ page }) => {
       `#about-counter:has-text("inc 0")`
     );
 
-    // This should not have triggered any revalidation but our detection is
-    // failing for x-module changes for route module imports
-    // expect(dataRequests).toBe(2);
+    expect(dataRequests).toBe(2);
+
+    // mdx
+    await page.click(`a[href="/mdx"]`);
+    await page.waitForSelector(`#crazy`);
+    let mdx = `import { useLoaderData } from '@remix-run/react'
+export const loader = () => "hot"
+export const Component = () => {
+  const data = useLoaderData()
+  return <h1 id={data}>{data}</h1>
+}
+
+# heyo
+whatsup
+
+<Component/>
+`;
+    fs.writeFileSync(mdxPath, mdx);
+    await page.waitForSelector(`#hot`);
+    expect(dataRequests).toBe(4);
+
+    fs.writeFileSync(mdxPath, originalMdx);
+    await page.waitForSelector(`#crazy`);
+    expect(dataRequests).toBe(5);
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index ba57c1006..0ce720933 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -122,7 +122,7 @@ describe("remix CLI", () => {
             --http-host         HTTP(S) host for the dev server. Default: localhost
             --http-port         HTTP(S) port for the dev server. Default: any open port
             --no-restart        Do not restart the app server when rebuilds occur.
-            --websocket-port    Websocket port for the dev server. Default: any open port
+            --websocket-port    WebSocket port for the dev server. Default: any open port
           \`init\` Options:
             --no-delete         Skip deleting the \`remix.init\` script
           \`routes\` Options: