diff --git a/integration/helpers/express.ts b/integration/helpers/express.ts new file mode 100644 index 0000000000..ff6268c450 --- /dev/null +++ b/integration/helpers/express.ts @@ -0,0 +1,75 @@ +import tsx from "dedent"; + +export function server() { + return tsx` + import { createRequestHandler } from "@react-router/express"; + import express from "express"; + + const port = process.env.PORT ?? 3000 + const hmrPort = process.env.HMR_PORT ?? 3001 + + const app = express(); + + const getLoadContext = () => ({}); + + if (process.env.NODE_ENV === "production") { + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); + app.use(express.static("build/client", { maxAge: "1h" })); + app.all("*", createRequestHandler({ + build: await import("./build/index.js"), + getLoadContext, + })); + } else { + const viteDevServer = await import("vite").then( + (vite) => vite.createServer({ + server: { + middlewareMode: true, + hmr: { port: hmrPort }, + }, + }) + ); + app.use(viteDevServer.middlewares); + app.all("*", createRequestHandler({ + build:() => viteDevServer.ssrLoadModule("virtual:react-router/server-build"), + getLoadContext, + })); + } + + app.listen(port, () => console.log('http://localhost:' + port)); + `; +} + +export function rsc() { + return tsx` + import { createRequestListener } from "@mjackson/node-fetch-server"; + import express from "express"; + + const port = process.env.PORT ?? 3000 + const hmrPort = process.env.HMR_PORT ?? 3001 + + const app = express(); + + if (process.env.NODE_ENV === "production") { + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); + app.all("*", createRequestListener((await import("./build/server/index.js")).default)); + } else { + const viteDevServer = await import("vite").then( + (vite) => vite.createServer({ + server: { + middlewareMode: true, + hmr: { port: hmrPort }, + }, + }) + ); + app.use(viteDevServer.middlewares); + } + + app.listen(port, () => console.log('http://localhost:' + port)); + `; +} diff --git a/integration/helpers/stream.ts b/integration/helpers/stream.ts new file mode 100644 index 0000000000..2b9fe49c49 --- /dev/null +++ b/integration/helpers/stream.ts @@ -0,0 +1,29 @@ +import type { Readable } from "node:stream"; + +export async function match( + stream: Readable, + pattern: string | RegExp, + options: { + /** Measured in ms */ + timeout?: number; + } = {}, +): Promise { + // Prepare error outside of promise so that stacktrace points to caller of `matchLine` + const timeoutError = new Error( + `Timed out - Could not find pattern: ${pattern}`, + ); + return new Promise(async (resolve, reject) => { + const timeout = setTimeout( + () => reject(timeoutError), + options.timeout ?? 10_000, + ); + stream.on("data", (data) => { + const line: string = data.toString(); + const matches = line.match(pattern); + if (matches) { + resolve(matches); + clearTimeout(timeout); + } + }); + }); +} diff --git a/integration/helpers/templates.ts b/integration/helpers/templates.ts new file mode 100644 index 0000000000..6e580c5ba9 --- /dev/null +++ b/integration/helpers/templates.ts @@ -0,0 +1,30 @@ +const templates = [ + // Vite Major templates + { name: "vite-5-template", displayName: "Vite 5" }, + { name: "vite-6-template", displayName: "Vite 6" }, + { name: "vite-7-beta-template", displayName: "Vite 7 Beta" }, + { name: "vite-rolldown-template", displayName: "Vite Rolldown" }, + + // RSC templates + { name: "rsc-vite", displayName: "RSC (Vite)" }, + { name: "rsc-parcel", displayName: "RSC (Parcel)" }, + { name: "rsc-vite-framework", displayName: "RSC Framework" }, + + // Cloudflare + // { name: "cloudflare-dev-proxy-template", displayName: "Cloudflare Dev Proxy" }, + { name: "vite-plugin-cloudflare-template", displayName: "Cloudflare" }, +] as const; + +export type Template = (typeof templates)[number]; + +export function getTemplates(names?: Array) { + if (names === undefined) return templates; + return templates.filter(({ name }) => names.includes(name)); +} + +export const viteMajorTemplates = getTemplates([ + "vite-5-template", + "vite-6-template", + "vite-7-beta-template", + "vite-rolldown-template", +]); diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts index 59bab4ff2a..549587229e 100644 --- a/integration/vite-hmr-hdr-test.ts +++ b/integration/vite-hmr-hdr-test.ts @@ -1,155 +1,164 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import type { Page, PlaywrightWorkerOptions } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import getPort from "get-port"; +import dedent from "dedent"; -import type { Files, TemplateName } from "./helpers/vite.js"; -import { - test, - createEditor, - EXPRESS_SERVER, - viteConfig, - viteMajorTemplates, -} from "./helpers/vite.js"; +import * as Express from "./helpers/express"; +import { test } from "./helpers/fixtures"; +import * as Stream from "./helpers/stream"; +import { viteMajorTemplates, getTemplates } from "./helpers/templates"; + +const tsx = dedent; +const mdx = dedent; const templates = [ ...viteMajorTemplates, - { - templateName: "rsc-vite-framework", - templateDisplayName: "RSC Framework Mode", - }, -] as const satisfies ReadonlyArray<{ - templateName: TemplateName; - templateDisplayName: string; -}>; - -const indexRoute = ` - // imports - import { useState, useEffect } from "react"; - - export const meta = () => [{ title: "HMR updated title: 0" }] - - // loader - - export default function IndexRoute() { - // hooks - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - return ( -
-

Index

- -

Mounted: {mounted ? "yes" : "no"}

-

HMR updated: 0

- {/* elements */} -
- ); - } -`; - -test.describe("Vite HMR & HDR", () => { - templates.forEach(({ templateName, templateDisplayName }) => { - test.describe(templateDisplayName, () => { - test("vite dev", async ({ page, browserName, dev }) => { - let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port, templateName }), - "app/routes/_index.tsx": indexRoute, - }); - let { cwd, port } = await dev(files, templateName); - await workflow({ templateName, page, browserName, cwd, port }); + ...getTemplates(["rsc-vite-framework"]), +]; + +templates.forEach((template) => { + const isRsc = template.name.startsWith("rsc-"); + + test.describe(`${template.displayName} - HMR & HDR`, () => { + test.use({ + template: template.name, + files: { + "app/routes/_index.tsx": tsx` + // imports + import { useState, useEffect } from "react"; + + export const meta = () => [{ title: "HMR updated title: 0" }] + + // loader + + export default function IndexRoute() { + // hooks + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
+

Index

+ +

Mounted: {mounted ? "yes" : "no"}

+

HMR updated: 0

+ {/* elements */} +
+ ); + } + `, + }, + }); + + test("vite dev", async ({ page, edit, $ }) => { + const port = await getPort(); + const url = `http://localhost:${port}`; + + const dev = $(`pnpm dev --port ${port}`); + await Stream.match(dev.stdout, url); + + await workflow({ isRsc, page, edit, url }); + }); + + test("express", async ({ page, edit, $ }) => { + await edit({ + "server.mjs": isRsc ? Express.rsc() : Express.server(), }); - test("express", async ({ page, browserName, customDev }) => { - let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port, templateName }), - "server.mjs": EXPRESS_SERVER({ port, templateName }), - "app/routes/_index.tsx": indexRoute, - }); - let { cwd, port } = await customDev(files, templateName); - await workflow({ templateName, page, browserName, cwd, port }); + await $("pnpm build"); + + const port = await getPort(); + const url = `http://localhost:${port}`; + + const server = $("node server.mjs", { + env: { + PORT: String(port), + HMR_PORT: String(await getPort()), + }, }); + await Stream.match(server.stdout, url); - test("mdx", async ({ page, dev }) => { - test.skip(templateName.includes("rsc"), "RSC is not supported"); - let files: Files = async ({ port }) => ({ - "vite.config.ts": ` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - import mdx from "@mdx-js/rollup"; - - export default defineConfig({ - ${await viteConfig.server({ port })} - plugins: [ - mdx(), - reactRouter(), - ], - }); - `, - "app/component.tsx": ` - import {useState} from "react"; - - export const Counter = () => { - const [count, setCount] = useState(0); - return - } - `, - "app/routes/mdx.mdx": ` - import { Counter } from "../component"; - - # MDX Title (HMR: 0) - - - `, - }); - - let { port, cwd } = await dev(files, templateName); - let edit = createEditor(cwd); - await page.goto(`http://localhost:${port}/mdx`, { - waitUntil: "networkidle", - }); - - await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 0)"); - let button = page.locator("button"); - await expect(button).toHaveText("Count: 0"); - await button.click(); - await expect(button).toHaveText("Count: 1"); - - await edit("app/routes/mdx.mdx", (contents) => - contents.replace("(HMR: 0)", "(HMR: 1)"), - ); - await page.waitForLoadState("networkidle"); + await workflow({ isRsc, page, edit, url }); + }); + + test("mdx", async ({ page, edit, $ }) => { + test.skip(template.name.includes("rsc"), "RSC is not supported"); + + await edit({ + "vite.config.ts": tsx` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + plugins: [ + mdx(), + reactRouter(), + ], + }); + `, + "app/component.tsx": tsx` + import {useState} from "react"; + + export const Counter = () => { + const [count, setCount] = useState(0); + return + } + `, + "app/routes/mdx.mdx": mdx` + import { Counter } from "../component"; + + # MDX Title (HMR: 0) + + + `, + }); + + const port = await getPort(); + const url = `http://localhost:${port}`; - await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 1)"); - await expect(page.locator("button")).toHaveText("Count: 1"); + const dev = $(`pnpm dev --port ${port}`); + await Stream.match(dev.stdout, url); - expect(page.errors).toEqual([]); + await page.goto(url + "/mdx", { waitUntil: "networkidle" }); + + await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 0)"); + let button = page.locator("button"); + await expect(button).toHaveText("Count: 0"); + await button.click(); + await expect(button).toHaveText("Count: 1"); + + await edit({ + "app/routes/mdx.mdx": (contents) => + contents.replace("(HMR: 0)", "(HMR: 1)"), }); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 1)"); + await expect(page.locator("button")).toHaveText("Count: 1"); + + expect(page.errors).toEqual([]); }); }); }); async function workflow({ - templateName, + isRsc, page, - browserName, - cwd, - port, + edit, + url, }: { - templateName: TemplateName; + isRsc: boolean; page: Page; - browserName: PlaywrightWorkerOptions["browserName"]; - cwd: string; - port: number; + edit: ( + edits: Record string)>, + ) => Promise; + url: string; }) { - let edit = createEditor(cwd); - // setup: initial render - await page.goto(`http://localhost:${port}/`, { - waitUntil: "networkidle", - }); + await page.goto(url, { waitUntil: "networkidle" }); await expect(page.locator("#index [data-title]")).toHaveText("Index"); // setup: hydration @@ -164,15 +173,16 @@ async function workflow({ await expect(hmrStatus).toHaveText("HMR updated: 0"); let input = page.locator("#index input"); await expect(input).toBeVisible(); - await input.type("stateful"); + await input.fill("stateful"); expect(page.errors).toEqual([]); // route: HMR - await edit("app/routes/_index.tsx", (contents) => - contents - .replace("HMR updated title: 0", "HMR updated title: 1") - .replace("HMR updated: 0", "HMR updated: 1"), - ); + await edit({ + "app/routes/_index.tsx": (contents) => + contents + .replace("HMR updated title: 0", "HMR updated title: 1") + .replace("HMR updated: 0", "HMR updated: 1"), + }); await page.waitForLoadState("networkidle"); await expect(page).toHaveTitle("HMR updated title: 1"); @@ -181,31 +191,33 @@ async function workflow({ expect(page.errors).toEqual([]); // route: add loader - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { useLoaderData } from "react-router"`, - ) - .replace( - "// loader", - `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`, - ) - .replace( - "// hooks", - "// hooks\nconst { message } = useLoaderData();", - ) - .replace( - "{/* elements */}", - `{/* elements */}\n

{message}

`, - ), - ); + await edit({ + "app/routes/_index.tsx": (contents) => + contents + .replace( + "// imports", + `// imports\nimport { useLoaderData } from "react-router"`, + ) + .replace( + "// loader", + `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`, + ) + .replace( + "// hooks", + "// hooks\nconst { message } = useLoaderData();", + ) + .replace( + "{/* elements */}", + `{/* elements */}\n

{message}

`, + ), + }); await page.waitForLoadState("networkidle"); let hdrStatus = page.locator("#index [data-hdr]"); await expect(hdrStatus).toHaveText("HDR updated: 0"); + // React Fast Refresh cannot preserve state for a component when hooks are added or removed await expect(input).toHaveValue(""); - await input.type("stateful"); + await input.fill("stateful"); expect(page.errors.length).toBeGreaterThan(0); expect( // When adding a loader, a harmless error is logged to the browser console. @@ -220,19 +232,21 @@ async function workflow({ page.errors = []; // route: HDR - await edit("app/routes/_index.tsx", (contents) => - contents.replace("HDR updated: 0", "HDR updated: 1"), - ); + await edit({ + "app/routes/_index.tsx": (contents) => + contents.replace("HDR updated: 0", "HDR updated: 1"), + }); await page.waitForLoadState("networkidle"); await expect(hdrStatus).toHaveText("HDR updated: 1"); await expect(input).toHaveValue("stateful"); // route: HMR + HDR - await edit("app/routes/_index.tsx", (contents) => - contents - .replace("HMR updated: 1", "HMR updated: 2") - .replace("HDR updated: 1", "HDR updated: 2"), - ); + await edit({ + "app/routes/_index.tsx": (contents) => + contents + .replace("HMR updated: 1", "HMR updated: 2") + .replace("HDR updated: 1", "HDR updated: 2"), + }); await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("HMR updated: 2"); await expect(hdrStatus).toHaveText("HDR updated: 2"); @@ -240,23 +254,20 @@ async function workflow({ expect(page.errors).toEqual([]); // create new non-route component module - await fs.writeFile( - path.join(cwd, "app/component.tsx"), - String.raw` - export function MyComponent() { - return

Component HMR: 0

; - } + await edit({ + "app/component.tsx": tsx` + export function MyComponent() { + return

Component HMR: 0

; + } `, - "utf8", - ); - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { MyComponent } from "../component";`, - ) - .replace("{/* elements */}", "{/* elements */}\n"), - ); + "app/routes/_index.tsx": (contents) => + contents + .replace( + "// imports", + `// imports\nimport { MyComponent } from "../component";`, + ) + .replace("{/* elements */}", "{/* elements */}\n"), + }); await page.waitForLoadState("networkidle"); let component = page.locator("#index [data-component]"); await expect(component).toBeVisible(); @@ -265,57 +276,53 @@ async function workflow({ expect(page.errors).toEqual([]); // non-route: HMR - await edit("app/component.tsx", (contents) => - contents.replace("Component HMR: 0", "Component HMR: 1"), - ); + await edit({ + "app/component.tsx": (contents) => + contents.replace("Component HMR: 0", "Component HMR: 1"), + }); await page.waitForLoadState("networkidle"); await expect(component).toHaveText("Component HMR: 1"); await expect(input).toHaveValue("stateful"); expect(page.errors).toEqual([]); // create new non-route server module - await fs.writeFile( - path.join(cwd, "app/indirect-hdr-dep.ts"), - String.raw`export const indirect = "indirect 0"`, - "utf8", - ); - await fs.writeFile( - path.join(cwd, "app/direct-hdr-dep.ts"), - String.raw` + await edit({ + "app/indirect-hdr-dep.ts": tsx`export const indirect = "indirect 0"`, + "app/direct-hdr-dep.ts": tsx` import { indirect } from "./indirect-hdr-dep" export const direct = "direct 0 & " + indirect `, - "utf8", - ); - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { direct } from "../direct-hdr-dep"`, - ) - .replace( - `{ message: "HDR updated: 2" }`, - `{ message: "HDR updated: " + direct }`, - ), - ); + "app/routes/_index.tsx": (contents) => + contents + .replace( + "// imports", + `// imports\nimport { direct } from "../direct-hdr-dep"`, + ) + .replace( + `{ message: "HDR updated: 2" }`, + `{ message: "HDR updated: " + direct }`, + ), + }); await page.waitForLoadState("networkidle"); await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0"); await expect(input).toHaveValue("stateful"); expect(page.errors).toEqual([]); // non-route: HDR for direct dependency - await edit("app/direct-hdr-dep.ts", (contents) => - contents.replace("direct 0 &", "direct 1 &"), - ); + await edit({ + "app/direct-hdr-dep.ts": (contents) => + contents.replace("direct 0 &", "direct 1 &"), + }); await page.waitForLoadState("networkidle"); await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0"); await expect(input).toHaveValue("stateful"); expect(page.errors).toEqual([]); // non-route: HDR for indirect dependency - await edit("app/indirect-hdr-dep.ts", (contents) => - contents.replace("indirect 0", "indirect 1"), - ); + await edit({ + "app/indirect-hdr-dep.ts": (contents) => + contents.replace("indirect 0", "indirect 1"), + }); await page.waitForLoadState("networkidle"); await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1"); await expect(input).toHaveValue("stateful"); @@ -323,20 +330,24 @@ async function workflow({ // everything everywhere all at once await Promise.all([ - edit("app/routes/_index.tsx", (contents) => - contents - .replace("HMR updated: 2", "HMR updated: 3") - .replace("HDR updated: ", "HDR updated: route & "), - ), - edit("app/component.tsx", (contents) => - contents.replace("Component HMR: 1", "Component HMR: 2"), - ), - edit("app/direct-hdr-dep.ts", (contents) => - contents.replace("direct 1 &", "direct 2 &"), - ), - edit("app/indirect-hdr-dep.ts", (contents) => - contents.replace("indirect 1", "indirect 2"), - ), + edit({ + "app/routes/_index.tsx": (contents) => + contents + .replace("HMR updated: 2", "HMR updated: 3") + .replace("HDR updated: ", "HDR updated: route & "), + }), + edit({ + "app/component.tsx": (contents) => + contents.replace("Component HMR: 1", "Component HMR: 2"), + }), + edit({ + "app/direct-hdr-dep.ts": (contents) => + contents.replace("direct 1 &", "direct 2 &"), + }), + edit({ + "app/indirect-hdr-dep.ts": (contents) => + contents.replace("indirect 1", "indirect 2"), + }), ]); await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("HMR updated: 3"); @@ -345,8 +356,9 @@ async function workflow({ "HDR updated: route & direct 2 & indirect 2", ); // TODO: Investigate why this is flaky in CI for RSC Framework Mode - if (!templateName.includes("rsc")) { + if (isRsc) { await expect(input).toHaveValue("stateful"); } + expect(page.errors).toEqual([]); }