Repository: remix-run/remix
Pull request #6135 (2023-04-21 22:14:49)

feat: revalidate loaders only when a change to one is detected
Begin scoping loader revalidation, currently only all or none but lays the plumbing for further granularity. 

Closes: #

- [ ] Docs
- [x] Tests

Testing Strategy:

Update integration tests to add shouldRevalidate function to verify we 


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: acbce26bed2a9630d551f096df8e62f388b47dbd

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 18 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/react              | Patch |
| @remix-run/testing            | Patch |
| create-remix                  | Patch |
| remix                         | Patch |
| @remix-run/architect          | Patch |
| @remix-run/cloudflare         | Patch |
| @remix-run/cloudflare-pages   | Patch |
| @remix-run/cloudflare-workers | Patch |
| @remix-run/css-bundle         | Patch |
| @remix-run/deno               | Patch |
| @remix-run/dev                | Patch |
| @remix-run/eslint-config      | Patch |
| @remix-run/express            | Patch |
| @remix-run/netlify            | Patch |
| @remix-run/node               | Patch |
| @remix-run/serve              | Patch |
| @remix-run/server-runtime     | Patch |
| @remix-run/vercel             | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/revalidate_only_on_loader_change_detected?filename=.changeset/silly-hotels-fry.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A---%0A%0Afeat%3A%20revalidate%20loaders%20only%20when%20a%20change%20to%20one%20is%20detected%0A)



Patch:
diff --git a/.changeset/dev-server.md b/.changeset/dev-server.md
index f0be4b1a5..570a90389 100644
--- a/.changeset/dev-server.md
+++ b/.changeset/dev-server.md
@@ -46,7 +46,7 @@ For any other servers, specify the command you use to run your production server
 }
 ```
 
-## 3. Call `ping` in your app server
+## 3. Call `devReady` in your app server
 
 For example, in an Express server:
 
@@ -56,7 +56,7 @@ import path from "node:path";
 
 import express from "express";
 import { createRequestHandler } from "@remix-run/express";
-import { ping } from "@remix-run/dev";
+import { devReady } from "@remix-run/node";
 
 let BUILD_DIR = path.join(process.cwd(), "build"); // path to Remix's server build directory (`build/` by default)
 
@@ -74,9 +74,9 @@ app.listen(3000, () => {
   let build = require(BUILD_DIR);
   console.log("Ready: http://localhost:" + port);
 
-  // in development, call `ping` _after_ your server is ready
+  // in development, call `devReady` _after_ your server is ready
   if (process.env.NODE_ENV === "development") {
-    ping(build);
+    devReady(build);
   }
 });
 ```
@@ -101,7 +101,7 @@ Example:
 {
   future: {
     unstable_dev: {
-      // Port internally used by the dev server to receive app server `ping`s
+      // Port internally used by the dev server to receive app server `devReady` messages
       httpPort: 3001, // by default, Remix chooses an open port in the range 3001-3099
       // Port internally used by the dev server to send live reload, HMR, and HDR updates to the browser
       websocketPort: 3002, // by default, Remix chooses an open port in the range 3001-3099
@@ -123,10 +123,10 @@ remix dev -c 'node ./server.mjs' --http-port=3001 --websocket-port=3002 --no-res
 
 ### Dev server scheme/host/port
 
-If you've customized the dev server's origin (e.g. for Docker or SSL support), you can use the `ping` options to specify the scheme/host/port for the dev server:
+If you've customized the dev server's origin (e.g. for Docker or SSL support), you can use the `devReady` options to specify the scheme/host/port for the dev server:
 
 ```js
-ping(build, {
+devReady(build, {
   scheme: "https", // defaults to http
   host: "mycustomhost", // defaults to localhost
   port: 3003, // defaults to REMIX_DEV_HTTP_PORT environment variable
@@ -139,13 +139,13 @@ If you want to manage app server updates yourself, you can use the `--no-restart
 
 For example, if you rely on require cache purging to keep your app server running while server changes are pulled in, then you'll want to use `--no-restart`.
 
-🚨 It is then your responsibility to call `ping` whenever server changes are incorporated in your app server. 🚨
+🚨 It is then your responsibility to call `devReady` whenever server changes are incorporated in your app server. 🚨
 
 So for require cache purging, you'd want to:
 
 1. Purge the require cache
 2. `require` your server build
-3. Call `ping` within a `if (process.env.NODE_ENV === 'development')`
+3. Call `devReady` within a `if (process.env.NODE_ENV === 'development')`
 
 ([Looking at you, Kent](https://github.com/kentcdodds/kentcdodds.com/blob/main/server/index.ts#L298) 😆)
 
diff --git a/.changeset/hdr-revalidate-only-when-needed.md b/.changeset/hdr-revalidate-only-when-needed.md
new file mode 100644
index 000000000..38690e4e3
--- /dev/null
+++ b/.changeset/hdr-revalidate-only-when-needed.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Revalidate loaders only when a change to one is detected.
diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts
index e0e81f959..1f6f1ffa0 100644
--- a/packages/remix-cloudflare/index.ts
+++ b/packages/remix-cloudflare/index.ts
@@ -28,6 +28,7 @@ export {
   createRequestHandler,
   createSession,
   defer,
+  devReady,
   isCookie,
   isSession,
   json,
diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts
index 77ef4d86c..946342f8c 100644
--- a/packages/remix-deno/index.ts
+++ b/packages/remix-deno/index.ts
@@ -16,6 +16,7 @@ export {
 export {
   createSession,
   defer,
+  devReady,
   isCookie,
   isSession,
   json,
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 39b4f05e7..995c4fd7f 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -17,13 +17,14 @@ import type { RemixConfig } from "../config";
 import { readConfig } from "../config";
 import { formatRoutes, RoutesFormat, isRoutesFormat } from "../config/format";
 import { createApp } from "./create";
-import { getPreferredPackageManager } from "./getPreferredPackageManager";
+import { detectPackageManager } from "./detectPackageManager";
 import { setupRemix, isSetupPlatform, SetupPlatform } from "./setup";
 import runCodemod from "../codemod";
 import { CodemodError } from "../codemod/utils/error";
 import { TaskError } from "../codemod/utils/task";
 import { transpile as convertFileToJS } from "./useJavascript";
 import { warnOnce } from "../warnOnce";
+import type { Options } from "../compiler/options";
 
 export async function create({
   appTemplate,
@@ -82,7 +83,7 @@ export async function init(
 
   let initPackageJson = path.resolve(initScriptDir, "package.json");
   let isTypeScript = fse.existsSync(path.join(projectDir, "tsconfig.json"));
-  let packageManager = getPreferredPackageManager();
+  let packageManager = detectPackageManager() ?? "npm";
 
   if (await fse.pathExists(initPackageJson)) {
     execSync(`${packageManager} install`, {
@@ -169,24 +170,27 @@ export async function build(
 
   let start = Date.now();
   let config = await readConfig(remixRoot);
+  let options: Options = {
+    mode,
+    sourcemap,
+    onWarning: warnOnce,
+  };
+  if (config.future.unstable_dev) {
+    let dev = await resolveDev(config.future.unstable_dev);
+    options.devHttpPort = dev.httpPort;
+    options.devWebsocketPort = dev.websocketPort;
+  }
+
   fse.emptyDirSync(config.assetsBuildDirectory);
-  await compiler
-    .build({
-      config,
-      options: {
-        mode,
-        sourcemap,
-        onWarning: warnOnce,
-      },
-    })
-    .catch((thrown) => {
-      compiler.logThrown(thrown);
-      process.exit(1);
-    });
+  await compiler.build({ config, options }).catch((thrown) => {
+    compiler.logThrown(thrown);
+    process.exit(1);
+  });
 
   console.log(`built in ${prettyMs(Date.now() - start)}`);
 }
 
+// TODO: replace watch in v2
 export async function watch(
   remixRootOrConfig: string | RemixConfig,
   modeArg?: string
@@ -232,28 +236,7 @@ export async function dev(
   }
 
   let { unstable_dev } = config.future;
-
-  let command =
-    flags.command ?? (unstable_dev === true ? undefined : unstable_dev.command);
-  let httpPort =
-    flags.httpPort ??
-    (unstable_dev === true ? undefined : unstable_dev.httpPort) ??
-    (await findPort());
-  let websocketPort =
-    flags.websocketPort ??
-    (unstable_dev === true ? undefined : unstable_dev.websocketPort) ??
-    (await findPort());
-  let restart =
-    flags.restart ??
-    (unstable_dev === true ? undefined : unstable_dev.restart) ??
-    true;
-
-  await devServer_unstable.serve(config, {
-    command,
-    httpPort,
-    websocketPort,
-    restart,
-  });
+  await devServer_unstable.serve(config, await resolveDev(unstable_dev, flags));
 }
 
 export async function codemod(
@@ -481,3 +464,37 @@ let parseMode = (
 };
 
 let findPort = async () => getPort({ port: makeRange(3001, 3100) });
+
+let resolveDev = async (
+  dev: Exclude<RemixConfig["future"]["unstable_dev"], false>,
+  flags: {
+    command?: string;
+    httpPort?: number;
+    restart?: boolean;
+    websocketPort?: number;
+  } = {}
+): Promise<{
+  command?: string;
+  httpPort: number;
+  restart: boolean;
+  websocketPort: number;
+}> => {
+  let command = flags.command ?? (dev === true ? undefined : dev.command);
+  let httpPort =
+    flags.httpPort ??
+    (dev === true ? undefined : dev.httpPort) ??
+    (await findPort());
+  let websocketPort =
+    flags.websocketPort ??
+    (dev === true ? undefined : dev.websocketPort) ??
+    (await findPort());
+  let restart =
+    flags.restart ?? (dev === true ? undefined : dev.restart) ?? true;
+
+  return {
+    command,
+    httpPort,
+    websocketPort,
+    restart,
+  };
+};
diff --git a/packages/remix-dev/cli/create.ts b/packages/remix-dev/cli/create.ts
index 4d58d935f..c26fe90be 100644
--- a/packages/remix-dev/cli/create.ts
+++ b/packages/remix-dev/cli/create.ts
@@ -15,7 +15,7 @@ import tar from "tar-fs";
 import * as colors from "../colors";
 import invariant from "../invariant";
 import packageJson from "../package.json";
-import { getPreferredPackageManager } from "./getPreferredPackageManager";
+import { detectPackageManager } from "./detectPackageManager";
 import * as useJavascript from "./useJavascript";
 
 const remixDevPackageVersion = packageJson.version;
@@ -208,7 +208,7 @@ export async function createApp({
   }
 
   if (installDeps) {
-    let packageManager = getPreferredPackageManager();
+    let packageManager = detectPackageManager() ?? "npm";
 
     let npmConfig = execSync(
       `${packageManager} config get @remix-run:registry`,
diff --git a/packages/remix-dev/cli/detectPackageManager.ts b/packages/remix-dev/cli/detectPackageManager.ts
new file mode 100644
index 000000000..f77f5c7bb
--- /dev/null
+++ b/packages/remix-dev/cli/detectPackageManager.ts
@@ -0,0 +1,22 @@
+type PackageManager = "npm" | "pnpm" | "yarn";
+
+/**
+ * Determine which package manager the user prefers.
+ *
+ * npm, pnpm and Yarn set the user agent environment variable
+ * that can be used to determine which package manager ran
+ * the command.
+ */
+export const detectPackageManager = (): PackageManager | undefined => {
+  let { npm_config_user_agent } = process.env;
+  if (!npm_config_user_agent) return undefined;
+  try {
+    let pkgManager = npm_config_user_agent.split("/")[0];
+    if (pkgManager === "npm") return "npm";
+    if (pkgManager === "pnpm") return "pnpm";
+    if (pkgManager === "yarn") return "yarn";
+    return undefined;
+  } catch {
+    return undefined;
+  }
+};
diff --git a/packages/remix-dev/cli/getPreferredPackageManager.ts b/packages/remix-dev/cli/getPreferredPackageManager.ts
deleted file mode 100644
index d4dd9dd42..000000000
--- a/packages/remix-dev/cli/getPreferredPackageManager.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-type PackageManager = "npm" | "pnpm" | "yarn";
-
-/**
- * Determine which package manager the user prefers.
- *
- * npm, pnpm and Yarn set the user agent environment variable
- * that can be used to determine which package manager ran
- * the command.
- */
-export const getPreferredPackageManager = () =>
-  ((process.env.npm_config_user_agent ?? "").split("/")[0] ||
-    "npm") as PackageManager;
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index 3801db892..44367f940 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -8,7 +8,7 @@ import fse from "fs-extra";
 import * as colors from "../colors";
 import * as commands from "./commands";
 import { validateNewProjectPath, validateTemplate } from "./create";
-import { getPreferredPackageManager } from "./getPreferredPackageManager";
+import { detectPackageManager } from "./detectPackageManager";
 
 const helpText = `
 ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
@@ -313,7 +313,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
         return;
       }
 
-      let packageManager = getPreferredPackageManager();
+      let packageManager = detectPackageManager() ?? "npm";
       let answers = await inquirer
         .prompt<{
           appType: "template" | "stack";
diff --git a/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts b/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts
index 24b46f634..525becb93 100644
--- a/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts
+++ b/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts
@@ -6,7 +6,7 @@ import semver from "semver";
 
 import { blue, cyan, gray } from "../../colors";
 import { readConfig } from "../../config";
-import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager";
+import { detectPackageManager } from "../../cli/detectPackageManager";
 import type { Codemod } from "../codemod";
 import { CodemodError } from "../utils/error";
 import * as log from "../utils/log";
@@ -177,7 +177,7 @@ const codemod: Codemod = async (projectDir, options) => {
   );
 
   if (!options.dry) {
-    let packageManager = getPreferredPackageManager();
+    let packageManager = detectPackageManager() ?? "npm";
     log.info(
       `👉 To update your lockfile, run ${code(`${packageManager} install`)}`
     );
diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts
index 3f527c004..bb77e3522 100644
--- a/packages/remix-dev/compiler/js/compiler.ts
+++ b/packages/remix-dev/compiler/js/compiler.ts
@@ -22,7 +22,7 @@ import { vanillaExtractPlugin } from "../plugins/vanillaExtract";
 import invariant from "../../invariant";
 import { hmrPlugin } from "./plugins/hmr";
 import { createMatchPath } from "../utils/tsconfig";
-import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager";
+import { detectPackageManager } from "../../cli/detectPackageManager";
 import type * as Channel from "../../channel";
 import type { Context } from "../context";
 
@@ -145,7 +145,7 @@ const createEsbuildConfig = (
           }
 
           let packageName = getNpmPackageName(args.path);
-          let pkgManager = getPreferredPackageManager();
+          let pkgManager = detectPackageManager() ?? "npm";
           if (
             ctx.options.onWarning &&
             !isNodeBuiltIn(packageName) &&
diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts
index 21cb13d48..e42f1156d 100644
--- a/packages/remix-dev/compiler/options.ts
+++ b/packages/remix-dev/compiler/options.ts
@@ -2,7 +2,10 @@ type Mode = "development" | "production" | "test";
 
 export type Options = {
   mode: Mode;
-  liveReloadPort?: number;
   sourcemap: boolean;
   onWarning?: (message: string, key: string) => void;
+
+  // TODO: required in v2
+  devHttpPort?: number;
+  devWebsocketPort?: number;
 };
diff --git a/packages/remix-dev/compiler/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts
index 60c8f69d7..cc7576d91 100644
--- a/packages/remix-dev/compiler/server/compiler.ts
+++ b/packages/remix-dev/compiler/server/compiler.ts
@@ -98,9 +98,13 @@ const createEsbuildConfig = (
     publicPath: ctx.config.publicPath,
     define: {
       "process.env.NODE_ENV": JSON.stringify(ctx.options.mode),
+      // TODO: remove REMIX_DEV_SERVER_WS_PORT in v2
       "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify(
         ctx.config.devServerPort
       ),
+      "process.env.REMIX_DEV_HTTP_PORT": JSON.stringify(
+        ctx.options.devHttpPort ?? ""
+      ),
     },
     jsx: "automatic",
     jsxDev: ctx.options.mode !== "production",
diff --git a/packages/remix-dev/compiler/server/plugins/bareImports.ts b/packages/remix-dev/compiler/server/plugins/bareImports.ts
index bcac6a884..ba7e78b96 100644
--- a/packages/remix-dev/compiler/server/plugins/bareImports.ts
+++ b/packages/remix-dev/compiler/server/plugins/bareImports.ts
@@ -10,7 +10,7 @@ import {
 } from "../virtualModules";
 import { isCssSideEffectImportPath } from "../../plugins/cssSideEffectImports";
 import { createMatchPath } from "../../utils/tsconfig";
-import { getPreferredPackageManager } from "../../../cli/getPreferredPackageManager";
+import { detectPackageManager } from "../../../cli/detectPackageManager";
 import type { Context } from "../../context";
 
 /**
@@ -72,7 +72,7 @@ export function serverBareModulesPlugin({ config, options }: Context): Plugin {
         }
 
         let packageName = getNpmPackageName(path);
-        let pkgManager = getPreferredPackageManager();
+        let pkgManager = detectPackageManager() ?? "npm";
 
         // Warn if we can't find an import for a package.
         if (
diff --git a/packages/remix-dev/compiler/server/plugins/entry.ts b/packages/remix-dev/compiler/server/plugins/entry.ts
index ecf4922cf..76b16114d 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.liveReloadPort
+    options.devWebsocketPort
       ? `export const dev = ${JSON.stringify({
-          liveReloadPort: options.liveReloadPort,
+          websocketPort: options.devWebsocketPort,
         })}`
       : ""
   }
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 696a2983c..af7d0455b 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -13,7 +13,7 @@ import { ServerMode, isValidServerMode } from "./config/serverModes";
 import { writeConfigDefaults } from "./config/write-tsconfig-defaults";
 import { serverBuildVirtualModule } from "./compiler/server/virtualModules";
 import { flatRoutes } from "./config/flat-routes";
-import { getPreferredPackageManager } from "./cli/getPreferredPackageManager";
+import { detectPackageManager } from "./cli/detectPackageManager";
 import { warnOnce } from "./warnOnce";
 
 export interface RemixMdxConfig {
@@ -457,7 +457,7 @@ export async function readConfig(
   let serverMinify = appConfig.serverMinify;
 
   if (!appConfig.serverModuleFormat) {
-    warnOnce(serverModuleFormatWarning, "serverModuleFormatWarning")
+    warnOnce(serverModuleFormatWarning, "serverModuleFormatWarning");
   }
 
   let serverModuleFormat = appConfig.serverModuleFormat || "cjs";
@@ -599,7 +599,7 @@ export async function readConfig(
 
       await pkgJson.save();
 
-      let packageManager = getPreferredPackageManager();
+      let packageManager = detectPackageManager() ?? "npm";
 
       execSync(`${packageManager} install`, {
         cwd: remixRoot,
diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts
index 9b509a992..a117f97fe 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -12,6 +12,7 @@ import { loadEnv } from "./env";
 import * as Socket from "./socket";
 import * as HMR from "./hmr";
 import { warnOnce } from "../warnOnce";
+import { detectPackageManager } from "../cli/detectPackageManager";
 
 export let serve = async (
   config: RemixConfig,
@@ -32,12 +33,14 @@ export let serve = async (
     prevManifest?: Manifest;
   } = {};
 
+  let pkgManager = detectPackageManager() ?? "npm";
+  let bin = (await execa(pkgManager, ["bin"])).stdout.trim();
   let startAppServer = (command: string) => {
     return execa.command(command, {
       stdio: "inherit",
       env: {
         NODE_ENV: "development",
-        PATH: `${process.cwd()}/node_modules/.bin:${process.env.PATH}`,
+        PATH: `${bin}:${process.env.PATH}`,
         REMIX_DEV_HTTP_PORT: String(options.httpPort),
       },
     });
@@ -48,9 +51,10 @@ export let serve = async (
       config,
       options: {
         mode: "development",
-        liveReloadPort: options.websocketPort, // TODO: rename liveReloadPort
         sourcemap: true,
         onWarning: warnOnce,
+        devHttpPort: options.httpPort,
+        devWebsocketPort: options.websocketPort,
       },
     },
     {
diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts
index 28e02a58a..70796298f 100644
--- a/packages/remix-dev/index.ts
+++ b/packages/remix-dev/index.ts
@@ -7,5 +7,3 @@ export { createApp } from "./cli/create";
 
 export { type Manifest as AssetsManifest } from "./manifest";
 export { getDependenciesToBundle } from "./dependencies";
-
-export { ping } from "./devServer_unstable/ping";
diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts
index e384ffa23..ec5466e61 100644
--- a/packages/remix-node/index.ts
+++ b/packages/remix-node/index.ts
@@ -39,6 +39,7 @@ export {
   createRequestHandler,
   createSession,
   defer,
+  devReady,
   isCookie,
   isSession,
   json,
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 5bd38fe7d..75dfc972a 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -13,7 +13,7 @@ import {
 } from "./errorBoundaries";
 import { deserializeErrors } from "./errors";
 import type { RouteModules } from "./routeModules";
-import { createClientRoutes } from "./routes";
+import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut } from "./routes";
 
 /* eslint-disable prefer-let/prefer-let */
 declare global {
@@ -23,7 +23,7 @@ declare global {
     // The number of active deferred keys rendered on the server
     a?: number;
     dev?: {
-      liveReloadPort?: number;
+      websocketPort?: number;
       hmrRuntime?: string;
     };
   };
@@ -44,12 +44,18 @@ declare global {
 }
 
 let router: Router;
-let hmrAbortController: AbortController;
+let hmrAbortController: AbortController | undefined;
 
 if (import.meta && import.meta.hot) {
   import.meta.hot.accept(
     "remix:manifest",
-    async (newManifest: EntryContext["manifest"]) => {
+    async ({
+      assetsManifest,
+      needsRevalidation,
+    }: {
+      assetsManifest: EntryContext["manifest"];
+      needsRevalidation: boolean;
+    }) => {
       let routeIds = [
         ...new Set(
           router.state.matches
@@ -58,6 +64,12 @@ if (import.meta && import.meta.hot) {
         ),
       ];
 
+      if (hmrAbortController) {
+        hmrAbortController.abort();
+      }
+      hmrAbortController = new AbortController();
+      let signal = hmrAbortController.signal;
+
       // Load new route modules that we've seen.
       let newRouteModules = Object.assign(
         {},
@@ -66,12 +78,12 @@ if (import.meta && import.meta.hot) {
           (
             await Promise.all(
               routeIds.map(async (id) => {
-                if (!newManifest.routes[id]) {
+                if (!assetsManifest.routes[id]) {
                   return null;
                 }
                 let imported = await import(
-                  newManifest.routes[id].module +
-                    `?t=${newManifest.hmr?.timestamp}`
+                  assetsManifest.routes[id].module +
+                    `?t=${assetsManifest.hmr?.timestamp}`
                 );
                 return [
                   id,
@@ -101,29 +113,28 @@ if (import.meta && import.meta.hot) {
 
       Object.assign(window.__remixRouteModules, newRouteModules);
       // Create new routes
-      let routes = createClientRoutes(
-        newManifest.routes,
+      let routes = createClientRoutesWithHMRRevalidationOptOut(
+        needsRevalidation,
+        assetsManifest.routes,
         window.__remixRouteModules,
-        window.__remixContext.future
+        window.__remixContext.future,
       );
 
       // This is temporary API and will be more granular before release
       router._internalSetRoutes(routes);
 
-      if (hmrAbortController) {
-        hmrAbortController.abort();
-      }
-      hmrAbortController = new AbortController();
-      let signal = hmrAbortController.signal;
       // Wait for router to be idle before updating the manifest and route modules
       // and triggering a react-refresh
       let unsub = router.subscribe((state) => {
-        if (state.revalidation === "idle" && !signal.aborted) {
+        if (state.revalidation === "idle") {
           unsub();
-          // TODO: Handle race conditions here. Should abort if a new update
-          // comes in while we're waiting for the router to be idle.
-          Object.assign(window.__remixManifest, newManifest);
-          window.$RefreshRuntime$.performReactRefresh();
+          // Abort if a new update comes in while we're waiting for the
+          // router to be idle.
+          if (signal.aborted) return;
+          setTimeout(() => {
+            Object.assign(window.__remixManifest, assetsManifest);
+            window.$RefreshRuntime$.performReactRefresh();
+          }, 0);
         }
       });
       router.revalidate();
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index f844d9d6d..cbcf94803 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -1689,6 +1689,7 @@ export const LiveReload =
   process.env.NODE_ENV !== "development"
     ? () => null
     : function LiveReload({
+        // TODO: remove REMIX_DEV_SERVER_WS_PORT in v2
         port = Number(process.env.REMIX_DEV_SERVER_WS_PORT || 8002),
         timeoutMs = 1000,
         nonce = undefined,
@@ -1707,7 +1708,7 @@ export const LiveReload =
                 function remixLiveReloadConnect(config) {
                   let protocol = location.protocol === "https:" ? "wss:" : "ws:";
                   let host = location.hostname;
-                  let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || ${String(
+                  let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || ${String(
                     port
                   )};
                   let socketPath = protocol + "//" + host + ":" + port + "/socket";
@@ -1729,9 +1730,11 @@ export const LiveReload =
                       }
                       if (!event.updates || !event.updates.length) return;
                       let updateAccepted = false;
+                      let needsRevalidation = false;
                       for (let update of event.updates) {
                         console.log("[HMR] " + update.reason + " [" + update.id +"]")
                         if (update.revalidate) {
+                          needsRevalidation = true;
                           console.log("[HMR] Revalidating [" + update.id + "]");
                         }
                         let imported = await import(update.url +  '?t=' + event.assetsManifest.hmr.timestamp);
@@ -1747,7 +1750,7 @@ export const LiveReload =
                       }
                       if (event.assetsManifest && window.__hmr__.contexts["remix:manifest"]) {
                         let accepted = window.__hmr__.contexts["remix:manifest"].emit(
-                          event.assetsManifest
+                          { needsRevalidation, assetsManifest: event.assetsManifest }
                         );
                         if (accepted) {
                           console.log("[HMR] Updated accepted by", "remix:manifest");
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index 4df10fc98..fde03eca1 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -10,7 +10,7 @@ export interface RemixContextObject {
   serverHandoffString?: string;
   future: FutureConfig;
   abortDelay?: number;
-  dev?: { liveReloadPort: number };
+  dev?: { websocketPort: number };
 }
 
 // Additional React-Router information needed at runtime, but not hydrated
diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx
index f82216f8a..88d326416 100644
--- a/packages/remix-react/routes.tsx
+++ b/packages/remix-react/routes.tsx
@@ -104,6 +104,22 @@ export function createServerRoutes(
   });
 }
 
+export function createClientRoutesWithHMRRevalidationOptOut(
+  needsRevalidation: boolean,
+  manifest: RouteManifest<EntryRoute>,
+  routeModulesCache: RouteModules,
+  future: FutureConfig
+) {
+  return createClientRoutes(
+    manifest,
+    routeModulesCache,
+    future,
+    "",
+    groupRoutesByParentId(manifest),
+    needsRevalidation
+  );
+}
+
 export function createClientRoutes(
   manifest: RouteManifest<EntryRoute>,
   routeModulesCache: RouteModules,
@@ -112,7 +128,8 @@ export function createClientRoutes(
   routesByParentId: Record<
     string,
     Omit<EntryRoute, "children">[]
-  > = groupRoutesByParentId(manifest)
+  > = groupRoutesByParentId(manifest),
+  needsRevalidation: boolean | undefined = undefined
 ): DataRouteObject[] {
   return (routesByParentId[parentId] || []).map((route) => {
     let hasErrorBoundary =
@@ -136,7 +153,11 @@ export function createClientRoutes(
       handle: undefined,
       loader: createDataFunction(route, routeModulesCache, false),
       action: createDataFunction(route, routeModulesCache, true),
-      shouldRevalidate: createShouldRevalidate(route, routeModulesCache),
+      shouldRevalidate: createShouldRevalidate(
+        route,
+        routeModulesCache,
+        needsRevalidation
+      ),
     };
     let children = createClientRoutes(
       manifest,
@@ -151,14 +172,29 @@ export function createClientRoutes(
 
 function createShouldRevalidate(
   route: EntryRoute,
-  routeModules: RouteModules
+  routeModules: RouteModules,
+  needsRevalidation: boolean | undefined
 ): ShouldRevalidateFunction {
+  let handledRevalidation = false;
   return function (arg) {
     let module = routeModules[route.id];
     invariant(module, `Expected route module to be loaded for ${route.id}`);
+
     if (module.shouldRevalidate) {
+      if (typeof needsRevalidation === "boolean" && !handledRevalidation) {
+        handledRevalidation = true;
+        return module.shouldRevalidate({
+          ...arg,
+          defaultShouldRevalidate: needsRevalidation,
+        });
+      }
       return module.shouldRevalidate(arg);
     }
+
+    if (typeof needsRevalidation === "boolean" && !handledRevalidation) {
+      handledRevalidation = true;
+      return needsRevalidation;
+    }
     return arg.defaultShouldRevalidate;
   };
 }
diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts
index 0c87b7c07..4a3f666e8 100644
--- a/packages/remix-server-runtime/build.ts
+++ b/packages/remix-server-runtime/build.ts
@@ -14,7 +14,7 @@ export interface ServerBuild {
   publicPath: string;
   assetsBuildDirectory: string;
   future: FutureConfig;
-  dev?: { liveReloadPort: number };
+  dev?: { websocketPort: number };
 }
 
 export interface HandleDocumentRequestFunction {
diff --git a/packages/remix-dev/devServer_unstable/ping.ts b/packages/remix-server-runtime/dev.ts
similarity index 88%
rename from packages/remix-dev/devServer_unstable/ping.ts
rename to packages/remix-server-runtime/dev.ts
index c9f58d899..1ef987d6c 100644
--- a/packages/remix-dev/devServer_unstable/ping.ts
+++ b/packages/remix-server-runtime/dev.ts
@@ -1,6 +1,6 @@
-import type { ServerBuild } from "@remix-run/server-runtime";
+import type { ServerBuild } from "./build";
 
-export let ping = (
+export let devReady = (
   build: ServerBuild,
   options: {
     scheme?: string;
diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts
index d6fb090fb..141121128 100644
--- a/packages/remix-server-runtime/index.ts
+++ b/packages/remix-server-runtime/index.ts
@@ -15,6 +15,7 @@ export { createCookieSessionStorageFactory } from "./sessions/cookieStorage";
 export { createMemorySessionStorageFactory } from "./sessions/memoryStorage";
 export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./upload/memoryUploadHandler";
 export { MaxPartSizeExceededError } from "./upload/errors";
+export { devReady } from "./dev";
 
 // Types for the Remix server runtime interface
 export type {
diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts
index a6165841c..edc4d4b30 100644
--- a/packages/remix-server-runtime/serverHandoff.ts
+++ b/packages/remix-server-runtime/serverHandoff.ts
@@ -20,7 +20,7 @@ export function createServerHandoffString<T>(serverHandoff: {
   // we'd end up including duplicate info
   state: ValidateShape<T, HydrationState>;
   future: FutureConfig;
-  dev?: { liveReloadPort: number };
+  dev?: { websocketPort: number };
 }): string {
   // Uses faster alternative of jsesc to escape data returned from the loaders.
   // This string is inserted directly into the HTML in the `<Scripts>` element.


Test patch:
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index 8e7299810..e6d3a1e73 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -63,7 +63,7 @@ let fixture = (options: {
       let path = require("path");
       let express = require("express");
       let { createRequestHandler } = require("@remix-run/express");
-      let { ping } = require("@remix-run/dev");
+      let { devReady } = require("@remix-run/node");
 
       const app = express();
       app.use(express.static("public", { immutable: true, maxAge: "1y" }));
@@ -84,7 +84,7 @@ let fixture = (options: {
         let build = require(BUILD_DIR);
         console.log('✅ app ready: http://localhost:' + port);
         if (process.env.NODE_ENV === 'development') {
-          ping(build);
+          devReady(build);
         }
       });
     `,
@@ -155,6 +155,9 @@ let fixture = (options: {
 
     "app/routes/_index.tsx": js`
       import { useLoaderData } from "@remix-run/react";
+      export function shouldRevalidate(args) {
+        return args.defaultShouldRevalidate;
+      }
       export default function Index() {
         const t = useLoaderData();
         return (
@@ -219,6 +222,13 @@ test("HMR", async ({ page }) => {
   // uncomment for debugging
   // page.on("console", (msg) => console.log(msg.text()));
   page.on("pageerror", (err) => console.log(err.message));
+  let dataRequests = 0;
+  page.on("request", (request) => {
+    let url = new URL(request.url());
+    if (url.searchParams.has("_data")) {
+      dataRequests++;
+    }
+  });
 
   let portRange = makeRange(3080, 3099);
   let appServerPort = await getPort({ port: portRange });
@@ -276,6 +286,9 @@ test("HMR", async ({ page }) => {
     let newIndex = `
       import { useLoaderData } from "@remix-run/react";
       import styles from "~/styles.module.css";
+      export function shouldRevalidate(args) {
+        return args.defaultShouldRevalidate;
+      }
       export default function Index() {
         const t = useLoaderData();
         return (
@@ -289,6 +302,7 @@ test("HMR", async ({ page }) => {
 
     // detect HMR'd content and style changes
     await page.waitForLoadState("networkidle");
+    
     let h1 = page.getByText("Changed");
     await h1.waitFor({ timeout: HMR_TIMEOUT_MS });
     expect(h1).toHaveCSS("color", "rgb(255, 255, 255)");
@@ -305,13 +319,19 @@ test("HMR", async ({ page }) => {
     expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
     await page.waitForSelector(`#root-counter:has-text("inc 1")`);
 
+    // We should not have done any revalidation yet as only UI has changed
+    expect(dataRequests).toBe(0);
+
     // add loader
     let withLoader1 = `
       import { json } from "@remix-run/node";
       import { useLoaderData } from "@remix-run/react";
 
-      export let loader = () => json({ hello: "world" })
+      export let loader = () => json({ hello: "world" });
 
+      export function shouldRevalidate(args) {
+        return args.defaultShouldRevalidate;
+      }
       export default function Index() {
         let { hello } = useLoaderData<typeof loader>();
         return (
@@ -322,10 +342,14 @@ test("HMR", async ({ page }) => {
       }
     `;
     fs.writeFileSync(indexPath, withLoader1);
+    await page.waitForLoadState("networkidle");
+
     await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS });
     expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
     await page.waitForSelector(`#root-counter:has-text("inc 1")`);
 
+    expect(dataRequests).toBe(1);
+
     let withLoader2 = `
       import { json } from "@remix-run/node";
       import { useLoaderData } from "@remix-run/react";
@@ -334,6 +358,9 @@ test("HMR", async ({ page }) => {
         return json({ hello: "planet" })
       }
 
+      export function shouldRevalidate(args) {
+        return args.defaultShouldRevalidate;
+      }
       export default function Index() {
         let { hello } = useLoaderData<typeof loader>();
         return (
@@ -344,10 +371,14 @@ test("HMR", async ({ page }) => {
       }
     `;
     fs.writeFileSync(indexPath, withLoader2);
+    await page.waitForLoadState("networkidle");
+
     await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS });
     expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
     await page.waitForSelector(`#root-counter:has-text("inc 1")`);
 
+    expect(dataRequests).toBe(2);
+
     // change shared component
     let updatedCounter = `
       import * as React from "react";
@@ -388,6 +419,10 @@ test("HMR", async ({ page }) => {
     aboutCounter = await page.waitForSelector(
       `#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);
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx
index d9ef31e43..b088805a4 100644
--- a/packages/remix-react/__tests__/components-test.tsx
+++ b/packages/remix-react/__tests__/components-test.tsx
@@ -47,14 +47,14 @@ describe("<LiveReload />", () => {
       LiveReload = require("../components").LiveReload;
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 8002;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 8002;"
       );
     });
 
     it("can set the port explicitly", () => {
       let { container } = render(<LiveReload port={4321} />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 4321;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 4321;"
       );
     });
 
@@ -62,7 +62,7 @@ describe("<LiveReload />", () => {
       process.env.REMIX_DEV_SERVER_WS_PORT = "1234";
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 1234;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 1234;"
       );
     });