diff --git a/.changeset/hmr.md b/.changeset/hmr.md
new file mode 100644
index 00000000000..dd387a4b972
--- /dev/null
+++ b/.changeset/hmr.md
@@ -0,0 +1,20 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+---
+
+Hot Module Replacement and Hot Data Revalidation
+
+- Requires `unstable_dev` future flag to be enabled
+- HMR provided through React Refresh
+
+Features:
+- HMR for component and style changes
+- HDR when loaders for current route change
+
+Known limitations for MVP:
+- Only implemented for React via React Refresh
+- No `import.meta.hot` API exposed yet
+- Revalidates _all_ loaders on route when loader changes are detected
+- Loader changes do not account for imported dependencies changing
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
new file mode 100644
index 00000000000..1381038e0e4
--- /dev/null
+++ b/integration/hmr-test.ts
@@ -0,0 +1,363 @@
+import { test, expect } from "@playwright/test";
+import execa from "execa";
+import fs from "node:fs";
+import path from "node:path";
+import type { Readable } from "node:stream";
+import getPort, { makeRange } from "get-port";
+
+import { createFixtureProject } from "./helpers/create-fixture";
+
+let fixture = (options: { port: number; appServerPort: number }) => ({
+ future: {
+ unstable_dev: {
+ port: options.port,
+ appServerPort: options.appServerPort,
+ },
+ unstable_tailwind: true,
+ },
+ files: {
+ "package.json": `
+ {
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "dev:remix": "cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli.js dev",
+ "dev:app": "cross-env NODE_ENV=development nodemon --watch build/ ./server.js"
+ },
+ "dependencies": {
+ "@remix-run/node": "0.0.0-local-version",
+ "@remix-run/react": "0.0.0-local-version",
+ "cross-env": "0.0.0-local-version",
+ "express": "0.0.0-local-version",
+ "isbot": "0.0.0-local-version",
+ "nodemon": "0.0.0-local-version",
+ "react": "0.0.0-local-version",
+ "react-dom": "0.0.0-local-version",
+ "tailwindcss": "0.0.0-local-version"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "0.0.0-local-version",
+ "@types/react": "0.0.0-local-version",
+ "@types/react-dom": "0.0.0-local-version",
+ "typescript": "0.0.0-local-version"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ }
+ `,
+ "server.js": `
+ let path = require("path");
+ let express = require("express");
+ let { createRequestHandler } = require("@remix-run/express");
+
+ const app = express();
+ app.use(express.static("public", { immutable: true, maxAge: "1y" }));
+
+ const MODE = process.env.NODE_ENV;
+ const BUILD_DIR = path.join(process.cwd(), "build");
+
+ app.all(
+ "*",
+ createRequestHandler({
+ build: require(BUILD_DIR),
+ mode: MODE,
+ })
+ );
+
+ let port = ${options.appServerPort};
+ app.listen(port, () => {
+ require(BUILD_DIR);
+ console.log('✅ app ready: http://localhost:' + port);
+ });
+ `,
+ "tailwind.config.js": `
+ /** @type {import('tailwindcss').Config} */
+ module.exports = {
+ content: ["./app/**/*.{ts,tsx,jsx,js}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+ };
+ `,
+ "app/tailwind.css": `
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ "app/root.tsx": `
+ import type { LinksFunction } from "@remix-run/node";
+ import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
+
+ import Counter from "./components/counter";
+ import styles from "./tailwind.css";
+
+ export const links: LinksFunction = () => [
+ { rel: "stylesheet", href: styles },
+ ];
+
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ `,
+ "app/routes/index.tsx": `
+ import { useLoaderData } from "@remix-run/react";
+ export default function Index() {
+ const t = useLoaderData();
+ return (
+
+ Index Title
+
+ )
+ }
+ `,
+ "app/routes/about.tsx": `
+ import Counter from "../components/counter";
+ export default function About() {
+ return (
+
+ About Title
+
+
+ )
+ }
+ `,
+ "app/components/counter.tsx": `
+ import * as React from "react";
+ export default function Counter({ id }) {
+ let [count, setCount] = React.useState(0);
+ return (
+
+
+
+ );
+ }
+ `,
+ },
+});
+
+let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+let wait = async (
+ callback: () => boolean,
+ { timeoutMs = 1000, intervalMs = 250 } = {}
+) => {
+ let start = Date.now();
+ while (Date.now() - start <= timeoutMs) {
+ if (callback()) {
+ return;
+ }
+ await sleep(intervalMs);
+ }
+ throw Error(`wait: timeout ${timeoutMs}ms`);
+};
+
+let bufferize = (stream: Readable): (() => string) => {
+ let buffer = "";
+ stream.on("data", (data) => (buffer += data.toString()));
+ return () => buffer;
+};
+
+test("HMR", async ({ page }) => {
+ // uncomment for debugging
+ // page.on("console", (msg) => console.log(msg.text()));
+ page.on("pageerror", (err) => console.log(err.message));
+
+ let appServerPort = await getPort({ port: makeRange(3080, 3089) });
+ let port = await getPort({ port: makeRange(3090, 3099) });
+ let projectDir = await createFixtureProject(fixture({ port, appServerPort }));
+
+ // spin up dev server
+ let dev = execa("npm", ["run", "dev:remix"], { cwd: projectDir });
+ let devStdout = bufferize(dev.stdout!);
+ let devStderr = bufferize(dev.stderr!);
+ await wait(
+ () => {
+ let stderr = devStderr();
+ if (stderr.length > 0) throw Error(stderr);
+ return /💿 Built in /.test(devStdout());
+ },
+ { timeoutMs: 10_000 }
+ );
+
+ // spin up app server
+ let app = execa("npm", ["run", "dev:app"], { cwd: projectDir });
+ let appStdout = bufferize(app.stdout!);
+ let appStderr = bufferize(app.stderr!);
+ await wait(
+ () => {
+ let stderr = appStderr();
+ if (stderr.length > 0) throw Error(stderr);
+ return /✅ app ready: /.test(appStdout());
+ },
+ {
+ timeoutMs: 10_000,
+ }
+ );
+
+ try {
+ await page.goto(`http://localhost:${appServerPort}`, {
+ waitUntil: "networkidle",
+ });
+
+ // `` value as page state that
+ // would be wiped out by a full page refresh
+ // but should be persisted by hmr
+ let input = page.getByLabel("Root Input");
+ expect(input).toBeVisible();
+ await input.type("asdfasdf");
+
+ let counter = await page.waitForSelector("#root-counter");
+ await counter.click();
+ await page.waitForSelector(`#root-counter:has-text("inc 1")`);
+
+ let indexPath = path.join(projectDir, "app", "routes", "index.tsx");
+ let originalIndex = fs.readFileSync(indexPath, "utf8");
+ let counterPath = path.join(projectDir, "app", "components", "counter.tsx");
+ let originalCounter = fs.readFileSync(counterPath, "utf8");
+
+ // make content and style changed to index route
+ let newIndex = `
+ import { useLoaderData } from "@remix-run/react";
+ export default function Index() {
+ const t = useLoaderData();
+ return (
+
+ Changed
+
+ )
+ }
+ `;
+ fs.writeFileSync(indexPath, newIndex);
+
+ // detect HMR'd content and style changes
+ await page.waitForLoadState("networkidle");
+ let h1 = page.getByText("Changed");
+ await h1.waitFor({ timeout: 2000 });
+ expect(h1).toHaveCSS("color", "rgb(255, 255, 255)");
+ expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)");
+
+ // verify that `` value was persisted (i.e. hmr, not full page refresh)
+ expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
+ await page.waitForSelector(`#root-counter:has-text("inc 1")`);
+
+ // undo change
+ fs.writeFileSync(indexPath, originalIndex);
+ await page.getByText("Index Title").waitFor({ timeout: 2000 });
+ expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
+ await page.waitForSelector(`#root-counter:has-text("inc 1")`);
+
+ // add loader
+ let withLoader1 = `
+ import { json } from "@remix-run/node";
+ import { useLoaderData } from "@remix-run/react";
+
+ export let loader = () => json({ hello: "world" })
+
+ export default function Index() {
+ let { hello } = useLoaderData();
+ return (
+
+ Hello, {hello}
+
+ )
+ }
+ `;
+ fs.writeFileSync(indexPath, withLoader1);
+ await page.getByText("Hello, world").waitFor({ timeout: 2000 });
+ expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
+ await page.waitForSelector(`#root-counter:has-text("inc 1")`);
+
+ let withLoader2 = `
+ import { json } from "@remix-run/node";
+ import { useLoaderData } from "@remix-run/react";
+
+ export function loader() {
+ return json({ hello: "planet" })
+ }
+
+ export default function Index() {
+ let { hello } = useLoaderData();
+ return (
+
+ Hello, {hello}
+
+ )
+ }
+ `;
+ fs.writeFileSync(indexPath, withLoader2);
+ await page.getByText("Hello, planet").waitFor({ timeout: 2000 });
+ expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
+ await page.waitForSelector(`#root-counter:has-text("inc 1")`);
+
+ // change shared component
+ let updatedCounter = `
+ import * as React from "react";
+ export default function Counter({ id }) {
+ let [count, setCount] = React.useState(0);
+ return (
+
+
+
+ );
+ }
+ `;
+ fs.writeFileSync(counterPath, updatedCounter);
+ await page.waitForSelector(`#root-counter:has-text("dec 1")`);
+ counter = await page.waitForSelector("#root-counter");
+ await counter.click();
+ await counter.click();
+ await page.waitForSelector(`#root-counter:has-text("dec -1")`);
+
+ await page.click(`a[href="/about"]`);
+ let aboutCounter = await page.waitForSelector(
+ `#about-counter:has-text("dec 0")`
+ );
+ await aboutCounter.click();
+ await page.waitForSelector(`#about-counter:has-text("dec -1")`);
+
+ // undo change
+ fs.writeFileSync(counterPath, originalCounter);
+
+ counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`);
+ await counter.click();
+ counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`);
+
+ aboutCounter = await page.waitForSelector(
+ `#about-counter:has-text("inc -1")`
+ );
+ await aboutCounter.click();
+ aboutCounter = await page.waitForSelector(
+ `#about-counter:has-text("inc 0")`
+ );
+ } finally {
+ dev.kill();
+ app.kill();
+ console.log(devStderr());
+ console.log(appStderr());
+ }
+});
diff --git a/jest/transform.js b/jest/transform.js
index ed4a8fc9972..260606b6df4 100644
--- a/jest/transform.js
+++ b/jest/transform.js
@@ -1,6 +1,24 @@
let { default: babelJest } = require("babel-jest");
+let baseConfig = require("../babel.config.js");
+
+/**
+ * Replace `import.meta` with `undefined`
+ *
+ * Needed to support server-side CJS in Jest
+ * that access `@remix-run/react`, where `import.meta.hot`
+ * is used for HMR.
+ */
+let metaPlugin = ({ types: t }) => ({
+ visitor: {
+ MetaProperty: (path) => {
+ path.replaceWith(t.identifier("undefined"));
+ },
+ },
+});
+
module.exports = babelJest.createTransformer({
babelrc: false,
- configFile: require.resolve("../babel.config"),
+ ...baseConfig,
+ plugins: [...baseConfig.plugins, metaPlugin],
});
diff --git a/package.json b/package.json
index 2b3e25c95b6..9c6f0e2ba3f 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"@rollup/plugin-babel": "^5.2.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^11.0.1",
+ "@rollup/plugin-replace": "^5.0.2",
"@testing-library/cypress": "^8.0.2",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^13.3.0",
@@ -94,6 +95,7 @@
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.3",
"concurrently": "^7.0.0",
+ "cross-env": "^7.0.3",
"cross-spawn": "^7.0.3",
"cypress": "^9.6.0",
"eslint": "^8.23.1",
@@ -107,6 +109,7 @@
"jest-watch-typeahead": "^0.6.5",
"jsonfile": "^6.0.1",
"lodash": "^4.17.21",
+ "nodemon": "^2.0.20",
"npm-run-all": "^4.1.5",
"patch-package": "^6.5.0",
"prettier": "2.7.1",
@@ -124,10 +127,10 @@
"simple-git": "^3.2.4",
"sort-package-json": "^1.55.0",
"strip-indent": "^3.0.0",
- "to-vfile": "7.2.3",
"tailwindcss": "^3.1.8",
+ "to-vfile": "7.2.3",
"type-fest": "^2.16.0",
- "typescript": "^4.7.4",
+ "typescript": "^4.9.5",
"unified": "^10.1.2",
"unist-util-remove": "^3.1.0",
"unist-util-visit": "^4.1.1"
diff --git a/packages/remix-dev/codemod/replace-remix-magic-imports/transform.ts b/packages/remix-dev/codemod/replace-remix-magic-imports/transform.ts
index 8351b712957..b0c2b693b33 100644
--- a/packages/remix-dev/codemod/replace-remix-magic-imports/transform.ts
+++ b/packages/remix-dev/codemod/replace-remix-magic-imports/transform.ts
@@ -2,8 +2,7 @@ import type { NodePath } from "@babel/core";
import * as t from "@babel/types";
import _ from "lodash";
-import createTransform from "../createTransform";
-import type { BabelPlugin } from "../utils/babel";
+import * as Transform from "../../transform";
import { CodemodError } from "../utils/error";
import type { Export } from "./utils/export";
import {
@@ -155,9 +154,8 @@ const groupImportsBySource =