`);
expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0));
+
+ // Hydrates out-of-order streamed content and becomes interactive
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/deferred-script-unresolved", true);
+ await page.waitForSelector(`main #${RESOLVED_DEFERRED_ID}`);
+ await page.waitForSelector(
+ `main #count-${RESOLVED_DEFERRED_ID}:has-text("0")`,
+ );
+ await page.locator(`main #increment-${RESOLVED_DEFERRED_ID}`).click();
+ await new Promise((r) => setTimeout(r, 100));
+ expect(
+ await page.locator(`main #count-${RESOLVED_DEFERRED_ID}`).innerText(),
+ ).toBe("1");
});
test("rejected promises render in initial payload", async () => {
@@ -639,8 +676,9 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
- expect(deferredHTML).toContain(FALLBACK_ID);
- expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0));
+ expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0));
+ expect(criticalHTML).not.toContain(FALLBACK_ID);
+ expect(deferredHTML).not.toContain(FALLBACK_ID);
});
test("slow to reject promises render in subsequent payload", async () => {
@@ -652,8 +690,8 @@ test.describe("non-aborted", () => {
expect(status).toBe(200);
expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0));
expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0));
+ expect(criticalHTML).toContain(`
`);
expect(criticalHTML).not.toContain(ERROR_ID);
- expect(deferredHTML).toContain(`
`);
expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0));
});
diff --git a/integration/link-test.ts b/integration/link-test.ts
index 8cdfa79863..c811ba1339 100644
--- a/integration/link-test.ts
+++ b/integration/link-test.ts
@@ -623,12 +623,9 @@ test.describe("route module link export", () => {
// Scripts:
// RR: window.__reactRouterContext
// RR: window.__reactRouterManifest/window.__reactRouterRouteModules
- // React: requestAnimationFrame(function(){$RT=performance.now()});
// RR: window.__reactRouterContext.streamController.enqueue()
- // React: $RC=function(b,c,e){...
// RR: window.__reactRouterContext.streamController.close();
- // React: $RC("B:1","S:1")
- expect(scripts.length).toEqual(7);
+ expect(scripts.length).toEqual(4);
expect(await scripts[0].innerText()).toContain(
"__reactRouterContext",
diff --git a/integration/vite-plugin-cloudflare-test.ts b/integration/vite-plugin-cloudflare-test.ts
index a8ceb5754a..6b6adfdf44 100644
--- a/integration/vite-plugin-cloudflare-test.ts
+++ b/integration/vite-plugin-cloudflare-test.ts
@@ -2,7 +2,13 @@ import { expect } from "@playwright/test";
import dedent from "dedent";
import getPort from "get-port";
-import { type Files, test, viteConfig } from "./helpers/vite.js";
+import {
+ type Files,
+ test,
+ viteConfig,
+ createProject,
+ build,
+} from "./helpers/vite.js";
const tsx = dedent;
const css = dedent;
@@ -177,4 +183,15 @@ test.describe("vite-plugin-cloudflare", () => {
"20px",
);
});
+
+ test("builds project with default server entry", async () => {
+ const files = defineFiles();
+ const cwd = await createProject(
+ await files({ port: 0 }),
+ "vite-plugin-cloudflare-template",
+ );
+ const buildResult = build({ cwd });
+
+ expect(buildResult.status).toBe(0);
+ });
});
diff --git a/packages/react-router-dev/.changes/minor.web-streams-entry.md b/packages/react-router-dev/.changes/minor.web-streams-entry.md
new file mode 100644
index 0000000000..6bf0db252e
--- /dev/null
+++ b/packages/react-router-dev/.changes/minor.web-streams-entry.md
@@ -0,0 +1,11 @@
+Change the default `entry.server.tsx` to use React's `renderToReadableStream` which is now available in React Router v8's Node 22 baseline
+
+- Framework mode apps no longer require a custom `entry.server.tsx` file to run in non-Node runtimes (i.e., Cloudflare)
+- This should not have any functional changes for your app
+ - You may see a small performance boost because of the reduced conversions between node streams and web streams
+ - You may eliminate initial fallback flickers for promises resolved prior to render
+ - Our testing showed that `renderToPipeableStream` would render the fallback and stream an immediate chunk with the resolved value
+ - `renderToReadableStream` skips the fallback and renders the resolved value in the critical HTML
+- If you have your own `entry.server.tsx` using `renderToReadableStream`, you may be able to remove it from your app if the logic matches the [default implementation](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/defaults/entry.server.tsx)
+- If you wish to continue using `renderToPipeableStream`, you can add your own `entry.server.tsx` file based on the previous [node implementation](https://github.com/remix-run/react-router/blob/react-router%408.0.0/packages/react-router-dev/config/defaults/entry.server.node.tsx)
+- You can also remove `@react-router/node` from your dependencies if you also have `@react-router/serve` or `@react-router/express`, since we no longer need the dependency to determine server entry compatibility across runtimes
diff --git a/packages/react-router-dev/cli/commands.ts b/packages/react-router-dev/cli/commands.ts
index 481a2cc886..21b0c921fc 100644
--- a/packages/react-router-dev/cli/commands.ts
+++ b/packages/react-router-dev/cli/commands.ts
@@ -3,7 +3,6 @@ import { readFile, writeFile, copyFile } from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
import exitHook from "exit-hook";
-import { readPackageJSON } from "pkg-types";
import colors from "picocolors";
// Workaround for "ERR_REQUIRE_CYCLE_MODULE" in Node 22.10.0+
import "react-router";
@@ -170,14 +169,6 @@ export async function generateEntry(
await copyFile(defaultEntry, outputFile);
} else {
- let pkgJson = await readPackageJSON(rootDirectory);
- let deps = pkgJson.dependencies ?? {};
-
- if (!deps["@react-router/node"]) {
- console.error(colors.red(`No default server entry detected.`));
- return;
- }
-
let defaultEntryClient = path.resolve(
defaultsDirectory,
"entry.client.tsx",
@@ -185,7 +176,7 @@ export async function generateEntry(
let defaultEntryServer = path.resolve(
defaultsDirectory,
- `entry.server.node.tsx`,
+ `entry.server.tsx`,
);
let isServerEntry = entry === "entry.server";
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index edce424a69..6c5d3a2ddd 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -1045,12 +1045,6 @@ export async function resolveEntryFiles({
let pkgJson = await readPackageJSON(packageJsonDirectory);
let deps = pkgJson.dependencies ?? {};
- if (!deps["@react-router/node"]) {
- throw new Error(
- `Could not determine server runtime. Please install @react-router/node, or provide a custom entry.server.tsx/jsx file in your app directory.`,
- );
- }
-
if (!deps["isbot"]) {
console.log(
"adding `isbot@5` to your package.json, you should commit this change",
@@ -1070,7 +1064,7 @@ export async function resolveEntryFiles({
});
}
- entryServerFile = `entry.server.node.tsx`;
+ entryServerFile = `entry.server.tsx`;
}
let entryClientFilePath = userEntryClientFile
diff --git a/packages/react-router-dev/config/defaults/entry.server.node.tsx b/packages/react-router-dev/config/defaults/entry.server.node.tsx
deleted file mode 100644
index 34d8af2ad3..0000000000
--- a/packages/react-router-dev/config/defaults/entry.server.node.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { PassThrough } from "node:stream";
-
-import type { EntryContext, RouterContextProvider } from "react-router";
-import { createReadableStreamFromReadable } from "@react-router/node";
-import { ServerRouter } from "react-router";
-import { isbot } from "isbot";
-import type { RenderToPipeableStreamOptions } from "react-dom/server";
-import { renderToPipeableStream } from "react-dom/server";
-
-export const streamTimeout = 5_000;
-
-export default function handleRequest(
- request: Request,
- responseStatusCode: number,
- responseHeaders: Headers,
- routerContext: EntryContext,
- loadContext: RouterContextProvider,
-) {
- // https://httpwg.org/specs/rfc9110.html#HEAD
- if (request.method.toUpperCase() === "HEAD") {
- return new Response(null, {
- status: responseStatusCode,
- headers: responseHeaders,
- });
- }
-
- return new Promise((resolve, reject) => {
- let shellRendered = false;
- let userAgent = request.headers.get("user-agent");
-
- // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
- // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
- let readyOption: keyof RenderToPipeableStreamOptions =
- (userAgent && isbot(userAgent)) || routerContext.isSpaMode
- ? "onAllReady"
- : "onShellReady";
-
- // Abort the rendering stream after the `streamTimeout` so it has time to
- // flush down the rejected boundaries
- let timeoutId: ReturnType | undefined = setTimeout(
- () => abort(),
- streamTimeout + 1000,
- );
-
- const { pipe, abort } = renderToPipeableStream(
- ,
- {
- [readyOption]() {
- shellRendered = true;
- const body = new PassThrough({
- final(callback) {
- // Clear the timeout to prevent retaining the closure and memory leak
- clearTimeout(timeoutId);
- timeoutId = undefined;
- callback();
- },
- });
- const stream = createReadableStreamFromReadable(body);
-
- responseHeaders.set("Content-Type", "text/html");
-
- pipe(body);
-
- resolve(
- new Response(stream, {
- headers: responseHeaders,
- status: responseStatusCode,
- }),
- );
- },
- onShellError(error: unknown) {
- reject(error);
- },
- onError(error: unknown) {
- responseStatusCode = 500;
- // Log streaming rendering errors from inside the shell. Don't log
- // errors encountered during initial shell rendering since they'll
- // reject and get logged in handleDocumentRequest.
- if (shellRendered) {
- console.error(error);
- }
- },
- },
- );
- });
-}
diff --git a/integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx b/packages/react-router-dev/config/defaults/entry.server.tsx
similarity index 79%
rename from integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx
rename to packages/react-router-dev/config/defaults/entry.server.tsx
index 6ac910a0f8..b8e9fcd328 100644
--- a/integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx
+++ b/packages/react-router-dev/config/defaults/entry.server.tsx
@@ -3,6 +3,8 @@ import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";
+export const streamTimeout = 5_000;
+
export default async function handleRequest(
request: Request,
responseStatusCode: number,
@@ -10,15 +12,24 @@ export default async function handleRequest(
routerContext: EntryContext,
_loadContext: RouterContextProvider,
) {
+ // https://httpwg.org/specs/rfc9110.html#HEAD
+ if (request.method.toUpperCase() === "HEAD") {
+ return new Response(null, {
+ status: responseStatusCode,
+ headers: responseHeaders,
+ });
+ }
+
let shellRendered = false;
- const userAgent = request.headers.get("user-agent");
+ let userAgent = request.headers.get("user-agent");
const body = await renderToReadableStream(
,
{
+ signal: AbortSignal.timeout(streamTimeout + 1000),
onError(error: unknown) {
responseStatusCode = 500;
- // Log streaming rendering errors from inside the shell. Don't log
+ // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
diff --git a/packages/react-router/.changes/patch.fix-optional-static-segment-params.md b/packages/react-router/.changes/patch.fix-optional-static-segment-params.md
new file mode 100644
index 0000000000..3a93e1fe09
--- /dev/null
+++ b/packages/react-router/.changes/patch.fix-optional-static-segment-params.md
@@ -0,0 +1,4 @@
+Fix incorrect dynamic param extraction when optional static segments are present
+
+- When a route path contains optional static segments (e.g. `/school?/user/:id`), the internal regex's incorrectly shifted parameter indices resulting in incorrect parameter extraction
+- Consecutive optional static segments (e.g. `/one?/two?`) were only partially handled
diff --git a/packages/react-router/__tests__/matchPath-test.tsx b/packages/react-router/__tests__/matchPath-test.tsx
index 1d0b9ab7ad..78dff2b609 100644
--- a/packages/react-router/__tests__/matchPath-test.tsx
+++ b/packages/react-router/__tests__/matchPath-test.tsx
@@ -406,6 +406,36 @@ describe("matchPath optional static segments", () => {
});
});
+ it("correctly extracts dynamic params when optional static segment is present", () => {
+ const match = matchPath("/school?/user/:id", "/school/user/123");
+ expect(match?.params).toMatchObject({ id: "123" });
+ });
+
+ it("correctly extracts dynamic params when optional static segment is absent", () => {
+ const match = matchPath("/school?/user/:id", "/user/123");
+ expect(match?.params).toMatchObject({ id: "123" });
+ });
+
+ it("should match consecutive optional static segments when none are provided", () => {
+ expect(matchPath("/one?/two?", "/")).toMatchObject({ pathname: "/" });
+ expect(matchPath("/one?/two?", "/one")).toMatchObject({ pathname: "/one" });
+ expect(matchPath("/one?/two?", "/two")).toMatchObject({ pathname: "/two" });
+ expect(matchPath("/one?/two?", "/one/two")).toMatchObject({
+ pathname: "/one/two",
+ });
+ });
+
+ it("correctly extracts params after consecutive optional static segments", () => {
+ const match1 = matchPath("/one?/two?/:three?", "/one/two/tres");
+ expect(match1?.params).toMatchObject({ three: "tres" });
+
+ const match2 = matchPath("/one?/two?/:three?", "/one/two");
+ expect(match2?.params).toMatchObject({ three: undefined });
+
+ const match3 = matchPath("/one?/two?/:three?", "/tres");
+ expect(match3?.params).toMatchObject({ three: "tres" });
+ });
+
it("does not trigger from question marks in the middle of the optional static segment", () => {
let match = matchPath("/school?abc/user/:id", "/abc/user/123");
expect(match).toBe(null);
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index fb593eba98..b0e297a7ff 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -1533,6 +1533,29 @@ export function useRevalidator(): {
* parent/child routes or the route [`handle`](../../start/framework/route-module#handle)
* property
*
+ * Pairing the route `handle` with `useMatches` gets very powerful since you can put
+ * whatever you want on a route handle and have access to `useMatches` anywhere.
+ * Please see the [handle](../../how-to/using-handle) documentation for an example
+ * of breadcrumbs via `useMatches`/`handle`.
+ *
+ * ```tsx
+ * import { useMatches } from "react-router";
+ *
+ * function SomeComponent() {
+ * const matches = useMatches();
+ * // matches[i].id // route id
+ * // matches[i].pathname // the portion of the URL the route matched
+ * // matches[i].params // the parsed params from the URL
+ * // matches[i].loaderData // the data from the loader
+ * // matches[i].handle // the route handle with any app specific data
+ * }
+ * ```
+ *
+ * useMatches only works with a data router like `createBrowserRouter`,
+ * since they know the full route tree up front and can provide all of the current
+ * matches. Additionally, `useMatches` will not match down into any descendant route
+ * trees since the router isn't aware of the descendant routes.
+ *
* @public
* @category Hooks
* @mode framework
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index 7c7c5fc81c..b44e6daf02 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -1706,7 +1706,7 @@ export function compilePath(
return "/([^\\/]+)";
},
) // Dynamic segment
- .replace(/\/([\w-]+)\?(\/|$)/g, "(/$1)?$2"); // Optional static segment
+ .replace(/\/([\w-]+)\?(?=\/|$|\()/g, "(?:/$1)?"); // Optional static segment (non-capturing)
if (path.endsWith("*")) {
params.push({ paramName: "*" });
diff --git a/playground/vite-plugin-cloudflare/app/entry.server.tsx b/playground/vite-plugin-cloudflare/app/entry.server.tsx
deleted file mode 100644
index 6ac910a0f8..0000000000
--- a/playground/vite-plugin-cloudflare/app/entry.server.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { EntryContext, RouterContextProvider } from "react-router";
-import { ServerRouter } from "react-router";
-import { isbot } from "isbot";
-import { renderToReadableStream } from "react-dom/server";
-
-export default async function handleRequest(
- request: Request,
- responseStatusCode: number,
- responseHeaders: Headers,
- routerContext: EntryContext,
- _loadContext: RouterContextProvider,
-) {
- let shellRendered = false;
- const userAgent = request.headers.get("user-agent");
-
- const body = await renderToReadableStream(
- ,
- {
- onError(error: unknown) {
- responseStatusCode = 500;
- // Log streaming rendering errors from inside the shell. Don't log
- // errors encountered during initial shell rendering since they'll
- // reject and get logged in handleDocumentRequest.
- if (shellRendered) {
- console.error(error);
- }
- },
- },
- );
- shellRendered = true;
-
- // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
- // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
- if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
- await body.allReady;
- }
-
- responseHeaders.set("Content-Type", "text/html");
- return new Response(body, {
- headers: responseHeaders,
- status: responseStatusCode,
- });
-}
diff --git a/scripts/pr.ts b/scripts/pr.ts
index fd544e6706..4b21f38ebc 100644
--- a/scripts/pr.ts
+++ b/scripts/pr.ts
@@ -216,6 +216,8 @@ async function runActions(resultPath: string) {
return;
}
+ console.log(actions);
+
for (let action of actions) {
switch (action.type) {
case "upsert-sticky-comment": {
@@ -232,22 +234,22 @@ async function runActions(resultPath: string) {
console.log("Creating sticky comment");
await createPrComment(prNumber, action.body);
}
- return;
+ break;
}
case "create-comment": {
console.log("Creating comment");
await createPrComment(prNumber, action.body);
- return;
+ break;
}
case "remove-label": {
console.log(`Removing label '${action.label}'`);
await removePrLabel(prNumber, action.label);
- return;
+ break;
}
case "close-pr": {
console.log(`Closing PR ${prNumber}`);
await closePr(prNumber);
- return;
+ break;
}
}
}