Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion integration/cli-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawnSync } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import {
copyFileSync,
existsSync,
Expand All @@ -13,6 +13,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 { createProject } from "./helpers/vite";
Expand All @@ -21,6 +22,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",
Expand All @@ -29,6 +31,121 @@ const reactRouterPackageBin = path.join(
const run = (command: string[], options: Parameters<typeof spawnSync>[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 || "<empty>"}`,
`stderr:\n${stderr || "<empty>"}`,
].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 };
Expand Down Expand Up @@ -161,6 +278,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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Restart `react-router dev` with `--conditions=development` when not enabled
-
20 changes: 13 additions & 7 deletions packages/react-router-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { loadConfig } from "../config/config";
Expand All @@ -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);

Expand Down Expand Up @@ -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(() => {});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const developmentConditionEnabled: boolean;
export default developmentConditionEnabled;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const developmentConditionEnabled = false;
export default developmentConditionEnabled;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const developmentConditionEnabled: boolean;
export default developmentConditionEnabled;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const developmentConditionEnabled = true;
export default developmentConditionEnabled;
6 changes: 6 additions & 0 deletions packages/react-router-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -70,6 +74,7 @@
"../../pnpm-workspace.yaml",
"cli/**",
"config/**",
"development-condition-enabled/**",
"module-sync-enabled/**",
"typegen/**",
"vite/**",
Expand Down Expand Up @@ -165,6 +170,7 @@
},
"files": [
"dist/",
"development-condition-enabled/",
"module-sync-enabled/",
"bin.cjs",
"rsc-types.d.ts",
Expand Down
57 changes: 57 additions & 0 deletions packages/react-router-dev/restart-with-conditions.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
1 change: 1 addition & 0 deletions packages/react-router-dev/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down
Loading