Skip to content

Commit 42a0817

Browse files
pcattorijacob-ebey
andauthored
feat(dev): HMR + Hot Data Revalidation (#5259)
Co-authored-by: Jacob Ebey <[email protected]>
1 parent e715511 commit 42a0817

File tree

33 files changed

+1383
-238
lines changed

33 files changed

+1383
-238
lines changed

.changeset/hmr.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@remix-run/dev": minor
3+
"@remix-run/react": minor
4+
"@remix-run/server-runtime": minor
5+
---
6+
7+
Hot Module Replacement and Hot Data Revalidation
8+
9+
- Requires `unstable_dev` future flag to be enabled
10+
- HMR provided through React Refresh
11+
12+
Features:
13+
- HMR for component and style changes
14+
- HDR when loaders for current route change
15+
16+
Known limitations for MVP:
17+
- Only implemented for React via React Refresh
18+
- No `import.meta.hot` API exposed yet
19+
- Revalidates _all_ loaders on route when loader changes are detected
20+
- Loader changes do not account for imported dependencies changing

integration/hmr-test.ts

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import { test, expect } from "@playwright/test";
2+
import execa from "execa";
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import type { Readable } from "node:stream";
6+
import getPort, { makeRange } from "get-port";
7+
8+
import { createFixtureProject } from "./helpers/create-fixture";
9+
10+
let fixture = (options: { port: number; appServerPort: number }) => ({
11+
future: {
12+
unstable_dev: {
13+
port: options.port,
14+
appServerPort: options.appServerPort,
15+
},
16+
unstable_tailwind: true,
17+
},
18+
files: {
19+
"package.json": `
20+
{
21+
"private": true,
22+
"sideEffects": false,
23+
"scripts": {
24+
"dev:remix": "cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli.js dev",
25+
"dev:app": "cross-env NODE_ENV=development nodemon --watch build/ ./server.js"
26+
},
27+
"dependencies": {
28+
"@remix-run/node": "0.0.0-local-version",
29+
"@remix-run/react": "0.0.0-local-version",
30+
"cross-env": "0.0.0-local-version",
31+
"express": "0.0.0-local-version",
32+
"isbot": "0.0.0-local-version",
33+
"nodemon": "0.0.0-local-version",
34+
"react": "0.0.0-local-version",
35+
"react-dom": "0.0.0-local-version",
36+
"tailwindcss": "0.0.0-local-version"
37+
},
38+
"devDependencies": {
39+
"@remix-run/dev": "0.0.0-local-version",
40+
"@types/react": "0.0.0-local-version",
41+
"@types/react-dom": "0.0.0-local-version",
42+
"typescript": "0.0.0-local-version"
43+
},
44+
"engines": {
45+
"node": ">=14"
46+
}
47+
}
48+
`,
49+
"server.js": `
50+
let path = require("path");
51+
let express = require("express");
52+
let { createRequestHandler } = require("@remix-run/express");
53+
54+
const app = express();
55+
app.use(express.static("public", { immutable: true, maxAge: "1y" }));
56+
57+
const MODE = process.env.NODE_ENV;
58+
const BUILD_DIR = path.join(process.cwd(), "build");
59+
60+
app.all(
61+
"*",
62+
createRequestHandler({
63+
build: require(BUILD_DIR),
64+
mode: MODE,
65+
})
66+
);
67+
68+
let port = ${options.appServerPort};
69+
app.listen(port, () => {
70+
require(BUILD_DIR);
71+
console.log('✅ app ready: http://localhost:' + port);
72+
});
73+
`,
74+
"tailwind.config.js": `
75+
/** @type {import('tailwindcss').Config} */
76+
module.exports = {
77+
content: ["./app/**/*.{ts,tsx,jsx,js}"],
78+
theme: {
79+
extend: {},
80+
},
81+
plugins: [],
82+
};
83+
`,
84+
"app/tailwind.css": `
85+
@tailwind base;
86+
@tailwind components;
87+
@tailwind utilities;
88+
`,
89+
"app/root.tsx": `
90+
import type { LinksFunction } from "@remix-run/node";
91+
import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
92+
93+
import Counter from "./components/counter";
94+
import styles from "./tailwind.css";
95+
96+
export const links: LinksFunction = () => [
97+
{ rel: "stylesheet", href: styles },
98+
];
99+
100+
export default function Root() {
101+
return (
102+
<html lang="en" className="h-full">
103+
<head>
104+
<Meta />
105+
<Links />
106+
</head>
107+
<body className="h-full">
108+
<header>
109+
<label htmlFor="root-input">Root Input</label>
110+
<input id="root-input" />
111+
<Counter id="root-counter" />
112+
<nav>
113+
<ul>
114+
<li><Link to="/">Home</Link></li>
115+
<li><Link to="/about">About</Link></li>
116+
</ul>
117+
</nav>
118+
</header>
119+
<Outlet />
120+
<Scripts />
121+
<LiveReload />
122+
</body>
123+
</html>
124+
);
125+
}
126+
`,
127+
"app/routes/index.tsx": `
128+
import { useLoaderData } from "@remix-run/react";
129+
export default function Index() {
130+
const t = useLoaderData();
131+
return (
132+
<main>
133+
<h1>Index Title</h1>
134+
</main>
135+
)
136+
}
137+
`,
138+
"app/routes/about.tsx": `
139+
import Counter from "../components/counter";
140+
export default function About() {
141+
return (
142+
<main>
143+
<h1>About Title</h1>
144+
<Counter id="about-counter" />
145+
</main>
146+
)
147+
}
148+
`,
149+
"app/components/counter.tsx": `
150+
import * as React from "react";
151+
export default function Counter({ id }) {
152+
let [count, setCount] = React.useState(0);
153+
return (
154+
<p>
155+
<button id={id} onClick={() => setCount(count + 1)}>inc {count}</button>
156+
</p>
157+
);
158+
}
159+
`,
160+
},
161+
});
162+
163+
let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
164+
165+
let wait = async (
166+
callback: () => boolean,
167+
{ timeoutMs = 1000, intervalMs = 250 } = {}
168+
) => {
169+
let start = Date.now();
170+
while (Date.now() - start <= timeoutMs) {
171+
if (callback()) {
172+
return;
173+
}
174+
await sleep(intervalMs);
175+
}
176+
throw Error(`wait: timeout ${timeoutMs}ms`);
177+
};
178+
179+
let bufferize = (stream: Readable): (() => string) => {
180+
let buffer = "";
181+
stream.on("data", (data) => (buffer += data.toString()));
182+
return () => buffer;
183+
};
184+
185+
test("HMR", async ({ page }) => {
186+
// uncomment for debugging
187+
// page.on("console", (msg) => console.log(msg.text()));
188+
page.on("pageerror", (err) => console.log(err.message));
189+
190+
let appServerPort = await getPort({ port: makeRange(3080, 3089) });
191+
let port = await getPort({ port: makeRange(3090, 3099) });
192+
let projectDir = await createFixtureProject(fixture({ port, appServerPort }));
193+
194+
// spin up dev server
195+
let dev = execa("npm", ["run", "dev:remix"], { cwd: projectDir });
196+
let devStdout = bufferize(dev.stdout!);
197+
let devStderr = bufferize(dev.stderr!);
198+
await wait(
199+
() => {
200+
let stderr = devStderr();
201+
if (stderr.length > 0) throw Error(stderr);
202+
return /💿 Built in /.test(devStdout());
203+
},
204+
{ timeoutMs: 10_000 }
205+
);
206+
207+
// spin up app server
208+
let app = execa("npm", ["run", "dev:app"], { cwd: projectDir });
209+
let appStdout = bufferize(app.stdout!);
210+
let appStderr = bufferize(app.stderr!);
211+
await wait(
212+
() => {
213+
let stderr = appStderr();
214+
if (stderr.length > 0) throw Error(stderr);
215+
return / app ready: /.test(appStdout());
216+
},
217+
{
218+
timeoutMs: 10_000,
219+
}
220+
);
221+
222+
try {
223+
await page.goto(`http://localhost:${appServerPort}`, {
224+
waitUntil: "networkidle",
225+
});
226+
227+
// `<input />` value as page state that
228+
// would be wiped out by a full page refresh
229+
// but should be persisted by hmr
230+
let input = page.getByLabel("Root Input");
231+
expect(input).toBeVisible();
232+
await input.type("asdfasdf");
233+
234+
let counter = await page.waitForSelector("#root-counter");
235+
await counter.click();
236+
await page.waitForSelector(`#root-counter:has-text("inc 1")`);
237+
238+
let indexPath = path.join(projectDir, "app", "routes", "index.tsx");
239+
let originalIndex = fs.readFileSync(indexPath, "utf8");
240+
let counterPath = path.join(projectDir, "app", "components", "counter.tsx");
241+
let originalCounter = fs.readFileSync(counterPath, "utf8");
242+
243+
// make content and style changed to index route
244+
let newIndex = `
245+
import { useLoaderData } from "@remix-run/react";
246+
export default function Index() {
247+
const t = useLoaderData();
248+
return (
249+
<main>
250+
<h1 className="text-white bg-black">Changed</h1>
251+
</main>
252+
)
253+
}
254+
`;
255+
fs.writeFileSync(indexPath, newIndex);
256+
257+
// detect HMR'd content and style changes
258+
await page.waitForLoadState("networkidle");
259+
let h1 = page.getByText("Changed");
260+
await h1.waitFor({ timeout: 2000 });
261+
expect(h1).toHaveCSS("color", "rgb(255, 255, 255)");
262+
expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)");
263+
264+
// verify that `<input />` value was persisted (i.e. hmr, not full page refresh)
265+
expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
266+
await page.waitForSelector(`#root-counter:has-text("inc 1")`);
267+
268+
// undo change
269+
fs.writeFileSync(indexPath, originalIndex);
270+
await page.getByText("Index Title").waitFor({ timeout: 2000 });
271+
expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
272+
await page.waitForSelector(`#root-counter:has-text("inc 1")`);
273+
274+
// add loader
275+
let withLoader1 = `
276+
import { json } from "@remix-run/node";
277+
import { useLoaderData } from "@remix-run/react";
278+
279+
export let loader = () => json({ hello: "world" })
280+
281+
export default function Index() {
282+
let { hello } = useLoaderData<typeof loader>();
283+
return (
284+
<main>
285+
<h1>Hello, {hello}</h1>
286+
</main>
287+
)
288+
}
289+
`;
290+
fs.writeFileSync(indexPath, withLoader1);
291+
await page.getByText("Hello, world").waitFor({ timeout: 2000 });
292+
expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
293+
await page.waitForSelector(`#root-counter:has-text("inc 1")`);
294+
295+
let withLoader2 = `
296+
import { json } from "@remix-run/node";
297+
import { useLoaderData } from "@remix-run/react";
298+
299+
export function loader() {
300+
return json({ hello: "planet" })
301+
}
302+
303+
export default function Index() {
304+
let { hello } = useLoaderData<typeof loader>();
305+
return (
306+
<main>
307+
<h1>Hello, {hello}</h1>
308+
</main>
309+
)
310+
}
311+
`;
312+
fs.writeFileSync(indexPath, withLoader2);
313+
await page.getByText("Hello, planet").waitFor({ timeout: 2000 });
314+
expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
315+
await page.waitForSelector(`#root-counter:has-text("inc 1")`);
316+
317+
// change shared component
318+
let updatedCounter = `
319+
import * as React from "react";
320+
export default function Counter({ id }) {
321+
let [count, setCount] = React.useState(0);
322+
return (
323+
<p>
324+
<button id={id} onClick={() => setCount(count - 1)}>dec {count}</button>
325+
</p>
326+
);
327+
}
328+
`;
329+
fs.writeFileSync(counterPath, updatedCounter);
330+
await page.waitForSelector(`#root-counter:has-text("dec 1")`);
331+
counter = await page.waitForSelector("#root-counter");
332+
await counter.click();
333+
await counter.click();
334+
await page.waitForSelector(`#root-counter:has-text("dec -1")`);
335+
336+
await page.click(`a[href="/about"]`);
337+
let aboutCounter = await page.waitForSelector(
338+
`#about-counter:has-text("dec 0")`
339+
);
340+
await aboutCounter.click();
341+
await page.waitForSelector(`#about-counter:has-text("dec -1")`);
342+
343+
// undo change
344+
fs.writeFileSync(counterPath, originalCounter);
345+
346+
counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`);
347+
await counter.click();
348+
counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`);
349+
350+
aboutCounter = await page.waitForSelector(
351+
`#about-counter:has-text("inc -1")`
352+
);
353+
await aboutCounter.click();
354+
aboutCounter = await page.waitForSelector(
355+
`#about-counter:has-text("inc 0")`
356+
);
357+
} finally {
358+
dev.kill();
359+
app.kill();
360+
console.log(devStderr());
361+
console.log(appStderr());
362+
}
363+
});

0 commit comments

Comments
 (0)