diff --git a/integration/cli-test.ts b/integration/cli-test.ts index 6d0d2c5e10..0352460a2a 100644 --- a/integration/cli-test.ts +++ b/integration/cli-test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { copyFileSync, existsSync, @@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; import dedent from "dedent"; +import getPort from "get-port"; import semver from "semver"; import { build, createProject, reactRouterConfig } from "./helpers/vite"; @@ -22,6 +23,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDirectory = path.resolve(__dirname, ".."); const nodeBin = process.argv[0]; const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js"; +const reactRouterPackageBinPath = "node_modules/@react-router/dev/bin.cjs"; const reactRouterPackageBin = path.join( rootDirectory, "packages/react-router-dev/bin.cjs", @@ -30,6 +32,121 @@ const reactRouterPackageBin = path.join( const run = (command: string[], options: Parameters[2]) => spawnSync(nodeBin, [reactRouterBin, ...command], options); +function bufferize(stream: NodeJS.ReadableStream | null): () => string { + let buffer = ""; + stream?.on("data", (data) => (buffer += data.toString())); + return () => buffer; +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function restartCount(output: string) { + return ( + output.match(/\[restart\] Relaunching with NODE_OPTIONS:/g)?.length ?? 0 + ); +} + +function getLogs(stdout: string, stderr: string) { + return [ + `stdout:\n${stdout || ""}`, + `stderr:\n${stderr || ""}`, + ].join("\n\n"); +} + +async function waitForDevServer(args: { + port: number; + proc: ChildProcess; + stdout: () => string; + stderr: () => string; +}) { + let timeout = process.platform === "win32" ? 20_000 : 10_000; + let start = Date.now(); + let lastError: unknown; + + while (Date.now() - start < timeout) { + let stdout = args.stdout(); + let stderr = args.stderr(); + + if (restartCount(stdout) > 1) { + throw new Error( + `Expected react-router dev to restart once, but it restarted ${restartCount( + stdout, + )} times.\n\n${getLogs(stdout, stderr)}`, + ); + } + + if (args.proc.exitCode !== null || args.proc.signalCode !== null) { + throw new Error( + `react-router dev exited before the server started.\n\n${getLogs( + stdout, + stderr, + )}`, + ); + } + + try { + let response = await fetch(`http://127.0.0.1:${args.port}/`, { + signal: AbortSignal.timeout(1_000), + }); + let html = await response.text(); + if (response.ok && html.includes("Welcome to React Router")) { + return; + } + lastError = new Error(`Unexpected response ${response.status}: ${html}`); + } catch (error) { + lastError = error; + } + + await delay(100); + } + + throw new Error( + [ + `Timed out waiting for react-router dev to start: ${String(lastError)}`, + getLogs(args.stdout(), args.stderr()), + ].join("\n\n"), + ); +} + +function waitForExit(proc: ChildProcess, timeout: number) { + return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + if (proc.exitCode !== null || proc.signalCode !== null) { + resolve({ code: proc.exitCode, signal: proc.signalCode }); + return; + } + + let timer = setTimeout(() => { + reject(new Error("Timed out waiting for react-router dev to exit")); + }, timeout); + + proc.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }, + ); +} + +function killProcessGroup(proc: ChildProcess) { + if (proc.exitCode !== null || proc.signalCode !== null) { + return; + } + + if (proc.pid && process.platform !== "win32") { + try { + process.kill(-proc.pid, "SIGKILL"); + return; + } catch { + // Fall back to killing just the parent process below. + } + } + + proc.kill("SIGKILL"); +} + const getBinNodeEnv = (command: string[]) => { let cwd = mkdtempSync(path.join(tmpdir(), "react-router-bin-")); let env = { ...process.env }; @@ -162,6 +279,60 @@ test.describe("cli", () => { ); }); + test("dev restarts with the development condition and starts the server", async ({ + browserName: _browserName, + }, { project }) => { + test.skip( + project.name !== "chromium", + "CLI smoke test only needs one browser project", + ); + + let cwd = await createProject(); + let port = await getPort(); + let proc = spawn( + nodeBin, + [ + reactRouterPackageBinPath, + "dev", + "--host", + "127.0.0.1", + "--port", + String(port), + "--strictPort", + ], + { + cwd, + detached: process.platform !== "win32", + env: { + ...process.env, + FORCE_COLOR: undefined, + NO_COLOR: "1", + NODE_OPTIONS: "--no-warnings=ExperimentalWarning", + }, + stdio: "pipe", + }, + ); + let stdout = bufferize(proc.stdout); + let stderr = bufferize(proc.stderr); + + try { + await waitForDevServer({ port, proc, stdout, stderr }); + expect(restartCount(stdout())).toBe(1); + + proc.kill("SIGTERM"); + await expect(waitForExit(proc, 5_000)).resolves.toBeDefined(); + } catch (error) { + throw new Error( + [ + error instanceof Error ? error.message : String(error), + getLogs(stdout(), stderr()), + ].join("\n\n"), + ); + } finally { + killProcessGroup(proc); + } + }); + test("routes", async () => { const cwd = await createProject(); let { stdout, stderr, status } = run(["routes"], { cwd }); diff --git a/packages/react-router-dev/.changes/minor.development-conditions.md b/packages/react-router-dev/.changes/minor.development-conditions.md new file mode 100644 index 0000000000..635e6ecf71 --- /dev/null +++ b/packages/react-router-dev/.changes/minor.development-conditions.md @@ -0,0 +1,2 @@ +- Restart `react-router dev` with `--conditions=development` when not enabled +- diff --git a/packages/react-router-dev/cli/commands.ts b/packages/react-router-dev/cli/commands.ts index e83556b168..4a86cbcc65 100644 --- a/packages/react-router-dev/cli/commands.ts +++ b/packages/react-router-dev/cli/commands.ts @@ -8,6 +8,7 @@ import colors from "picocolors"; // Workaround for "ERR_REQUIRE_CYCLE_MODULE" in Node 22.10.0+ import "react-router"; +import developmentConditionEnabled from "#development-condition-enabled"; import type { ViteDevOptions } from "../vite/dev"; import type { ViteBuildOptions } from "../vite/build"; import { hasNodeDependency, loadConfig } from "../config/config"; @@ -18,6 +19,7 @@ import * as profiler from "../vite/profiler"; import * as Typegen from "../typegen"; import { preloadVite, getVite } from "../vite/vite"; import { hasReactRouterRscPlugin } from "../vite/has-rsc-plugin"; +import { restartWithMergedOptions } from "../restart-with-conditions"; const nodeRequire = createRequire(import.meta.url); @@ -62,14 +64,18 @@ export async function build( } export async function dev(root?: string, options: ViteDevOptions = {}) { - let { dev } = await import("../vite/dev"); - if (options.profile) { - await profiler.start(); - } - exitHook(() => profiler.stop(console.info)); + if (developmentConditionEnabled) { + let { dev } = await import("../vite/dev"); + if (options.profile) { + await profiler.start(); + } + exitHook(() => profiler.stop(console.info)); - root = resolveRootDirectory(root, options); - await dev(root, options); + root = resolveRootDirectory(root, options); + await dev(root, options); + } else { + restartWithMergedOptions("--conditions=development"); + } // keep `react-router dev` alive by waiting indefinitely await new Promise(() => {}); diff --git a/packages/react-router-dev/development-condition-enabled/false.d.mts b/packages/react-router-dev/development-condition-enabled/false.d.mts new file mode 100644 index 0000000000..01949d6f13 --- /dev/null +++ b/packages/react-router-dev/development-condition-enabled/false.d.mts @@ -0,0 +1,2 @@ +declare const developmentConditionEnabled: boolean; +export default developmentConditionEnabled; diff --git a/packages/react-router-dev/development-condition-enabled/false.mjs b/packages/react-router-dev/development-condition-enabled/false.mjs new file mode 100644 index 0000000000..fbcdabec25 --- /dev/null +++ b/packages/react-router-dev/development-condition-enabled/false.mjs @@ -0,0 +1,2 @@ +const developmentConditionEnabled = false; +export default developmentConditionEnabled; diff --git a/packages/react-router-dev/development-condition-enabled/true.d.mts b/packages/react-router-dev/development-condition-enabled/true.d.mts new file mode 100644 index 0000000000..01949d6f13 --- /dev/null +++ b/packages/react-router-dev/development-condition-enabled/true.d.mts @@ -0,0 +1,2 @@ +declare const developmentConditionEnabled: boolean; +export default developmentConditionEnabled; diff --git a/packages/react-router-dev/development-condition-enabled/true.mjs b/packages/react-router-dev/development-condition-enabled/true.mjs new file mode 100644 index 0000000000..8756ece7b9 --- /dev/null +++ b/packages/react-router-dev/development-condition-enabled/true.mjs @@ -0,0 +1,2 @@ +const developmentConditionEnabled = true; +export default developmentConditionEnabled; diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index b3e986e566..68460fe48f 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -40,6 +40,10 @@ "./package.json": "./package.json" }, "imports": { + "#development-condition-enabled": { + "development": "./development-condition-enabled/true.mjs", + "default": "./development-condition-enabled/false.mjs" + }, "#module-sync-enabled": { "module-sync": "./module-sync-enabled/true.mjs", "default": "./module-sync-enabled/false.cjs" @@ -70,6 +74,7 @@ "../../pnpm-workspace.yaml", "cli/**", "config/**", + "development-condition-enabled/**", "module-sync-enabled/**", "typegen/**", "vite/**", @@ -165,6 +170,7 @@ }, "files": [ "dist/", + "development-condition-enabled/", "module-sync-enabled/", "bin.cjs", "rsc-types.d.ts", diff --git a/packages/react-router-dev/restart-with-conditions.ts b/packages/react-router-dev/restart-with-conditions.ts new file mode 100644 index 0000000000..6df9014312 --- /dev/null +++ b/packages/react-router-dev/restart-with-conditions.ts @@ -0,0 +1,57 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import process from "node:process"; + +/** + * Restarts the current Node process, appending new flags to the + * existing NODE_OPTIONS. SIGINT/SIGTERM are always forwarded to the child. + */ +export function restartWithMergedOptions(nodeOptions: string): void { + if (process.env.REACT_ROUTER_DEV_RESTARTED === "true") { + throw new Error( + "restartWithMergedOptions() was called, but the process has already been restarted. This is likely a bug in @react-router/dev.", + ); + } + const mergedOptions = [process.env.NODE_OPTIONS, nodeOptions] + .filter(Boolean) + .join(" ") + .trim(); + + console.log(`[restart] Relaunching with NODE_OPTIONS: ${mergedOptions}`); + + const [cmd, ...args] = process.argv; + + const child: ChildProcess = spawn(cmd, args, { + env: { + ...process.env, + NODE_OPTIONS: mergedOptions, + REACT_ROUTER_DEV_RESTARTED: "true", + }, + stdio: "inherit", + }); + + const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; + let signalHandlers = signals.map((sig) => { + let handler = () => { + child.kill(sig); + }; + process.on(sig, handler); + return [sig, handler] as const; + }); + + child.on("exit", (code, signal) => { + for (let [sig, handler] of signalHandlers) { + process.off(sig, handler); + } + + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code ?? 0); + } + }); + + child.on("error", (err) => { + console.error("[restart] Failed to spawn child process:", err); + process.exit(1); + }); +} diff --git a/packages/react-router-dev/tsdown.config.ts b/packages/react-router-dev/tsdown.config.ts index 8232ce4de4..d0dfc52ba9 100644 --- a/packages/react-router-dev/tsdown.config.ts +++ b/packages/react-router-dev/tsdown.config.ts @@ -10,6 +10,7 @@ import pkg from "./package.json" with { type: "json" }; const entry = ["cli/index.ts", "config.ts", "routes.ts", "vite.ts"]; const neverBundle = [ + "#development-condition-enabled", "./static/refresh-utils.mjs", "./static/rsc-refresh-utils.mjs", /\.json$/,