diff --git a/.agents/skills/prepare-release-notes/SKILL.md b/.agents/skills/prepare-release-notes/SKILL.md
new file mode 100644
index 0000000000..8abb9a81d1
--- /dev/null
+++ b/.agents/skills/prepare-release-notes/SKILL.md
@@ -0,0 +1,98 @@
+---
+name: prepare-release-notes
+description: Prepare React Router release notes before running the changes/versioning scripts. Use when asked to review, polish, normalize, or prepare pending change files under packages/*/.changes, remove semantic commit prefixes from release bullets, enforce imperative tense, decide whether a manual scripts/changes/whats-changed.md section is warranted, or draft long-form release notes for new features, stable future flags, or unstable flags.
+---
+
+# Prepare Release Notes
+
+Polish pending React Router change files and add manual release notes only when the release needs narrative context beyond the generated change lists.
+
+## Workflow
+
+1. Inspect local state:
+
+```sh
+git status --short
+find packages -path '*/.changes/*.md' -not -name README.md -not -name .gitkeep -print | sort
+```
+
+2. Read every pending change file. Do not edit generated changelogs or released notes directly.
+
+3. Normalize each change file:
+ - Remove `feat:`, `feat(...)`, `fix:`, and `fix(...)` semantic-commit prefixes from prose
+ - Use present or imperative tense for the first line and top-level release bullets: prefer `Add`, `Fix`, `Remove`, `Support`, `Stabilize`, `Preserve`, `Update`, `Avoid`, `Prevent`, `Throw`, `Warn`, `Expose`
+ - Nested detail bullets can stay explanatory when they expand on the parent bullet; do not rewrite them solely to force present or imperative tense
+ - Remove terminal sentence periods from bullet items because release generation appends PR/commit links after the first bullet line
+ - Remove terminal sentence periods from nested bullet items too, unless the punctuation is part of code, a URL, an abbreviation, a version number, or another token where removing it would be wrong
+ - If one bullet contains multiple sentences, split it into a shorter parent bullet plus nested bullet items
+ - Keep the first line concise and user-facing; use nested bullets for details or migration notes
+
+4. Review whether `scripts/changes/whats-changed.md` is needed:
+ - Read `CHANGELOG.md` examples or `references/whats-changed.md` when uncertain
+ - Add `scripts/changes/whats-changed.md` only for features, future flag stabilizations, unstable flags, migration guidance, breaking bug fixes, or complex behavior that needs long-form text or examples
+ - Do not add it for ordinary bug fixes, dependency cleanup, internal refactors, or release bullets that are already clear
+ - If adding it, write the body only; the release script adds `### What's Changed` when missing
+
+5. Validate:
+
+```sh
+pnpm changes:validate
+pnpm changes:preview
+```
+
+Use `changes:preview` to inspect the generated root release notes and confirm the PR/commit link placement, section ordering, and any manual What's Changed placement. If dependencies are missing or the command is too expensive for the context, state what was skipped.
+
+## Change File Style
+
+Single-line entries should read well with an auto-appended PR link:
+
+```markdown
+Fix `href()` to stringify and URL-encode param values like `generatePath()`
+```
+
+Use nested bullets for additional sentences:
+
+```markdown
+Fix route ranking for dynamic parameters with static extension suffixes
+
+- Identify `/:name.xml` as a dynamic segment instead of a static segment
+- Preserve static route priority for paths like `/sitemap.xml`
+```
+
+Avoid semantic commit prefixes:
+
+```markdown
+Add support for nub as a package manager
+```
+
+not:
+
+```markdown
+feat: add support for nub as a package manager.
+```
+
+## What's Changed Guidance
+
+Use `scripts/changes/whats-changed.md` for release-level narrative, not package-specific bullets. Good candidates include:
+
+- A new user-facing API or feature that benefits from example code
+- Stabilization or renaming of unstable APIs/flags, especially when adopters must migrate
+- A stable future flag that changes behavior and needs adoption guidance
+- A breaking bug fix or adapter/runtime behavior change that may require deployment checks
+- A cluster of related changes whose combined effect matters more than the individual bullet list
+
+Keep the tone direct and practical. Prefer headings under the generated `### What's Changed` section:
+
+````markdown
+#### Feature Name
+
+Explain what changed, who it affects, and how to adopt it.
+
+```ts
+// Optional short example
+```
+````
+
+Do not duplicate every bullet from Minor/Patch/Unstable Changes. Let generated change files carry ordinary PR-level details.
+
+See `references/whats-changed.md` for examples distilled from the existing changelog.
diff --git a/.agents/skills/prepare-release-notes/agents/openai.yaml b/.agents/skills/prepare-release-notes/agents/openai.yaml
new file mode 100644
index 0000000000..9a5cf0ece1
--- /dev/null
+++ b/.agents/skills/prepare-release-notes/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Prepare Release Notes"
+ short_description: "Polish React Router release notes"
+ default_prompt: "Use $prepare-release-notes to review pending React Router change files and draft any needed What's Changed notes."
diff --git a/.agents/skills/prepare-release-notes/references/whats-changed.md b/.agents/skills/prepare-release-notes/references/whats-changed.md
new file mode 100644
index 0000000000..69b40c722d
--- /dev/null
+++ b/.agents/skills/prepare-release-notes/references/whats-changed.md
@@ -0,0 +1,35 @@
+# What's Changed Reference
+
+Load this when deciding whether a React Router release needs `scripts/changes/whats-changed.md`.
+
+## Include A Manual Section For
+
+- Major releases with baseline changes, removed packages, removed deprecated APIs, new runtime requirements, or migration guidance
+- Stabilizations and flag renames where adopters of unstable APIs need explicit before/after guidance
+- New APIs or flags that deserve a narrative introduction or usage example
+- Cross-cutting performance or behavior work where several bullets form one user-facing story
+- Breaking bug fixes, deployment-sensitive fixes, or adapter/runtime behavior changes where users may need to test or update config
+
+## Usually Do Not Include One For
+
+- Straightforward bug fixes
+- Dependency removals or upgrades where the bullet is sufficient
+- Internal refactors with no public API change
+- A minor feature that is fully understandable from one concise bullet and nested detail bullets
+- Releases where the only content is a normal patch list
+
+## Existing Changelog Patterns
+
+- `v8.1.0`: Uses headings for agent skill installation and observability metadata, including an instrumentation code example. The generated minor bullets still carry the per-package details.
+- `v8.0.0`: Uses long-form migration notes for a major release: baseline support, adopted future flags, removed packages/APIs, and behavior changes.
+- `v7.18.0`: Explains a CSRF check fix that may be a breaking bug fix for reverse-proxy deployments and tells users what to test.
+- `v7.15.0`: Groups several unstable-to-stable API renames and route matching optimizations into narrative sections before listing individual minor bullets.
+- `v7.15.1`: Uses a "What's New" style section for an unstable hook with a code example. For new release notes, prefer the current `What's Changed` file path; the generated heading will be `### What's Changed`.
+
+## Drafting Tips
+
+- Use `####` headings inside `scripts/changes/whats-changed.md`; the release script wraps the file in `### What's Changed` if needed
+- Mention applicable React Router modes when the distinction matters: Declarative, Data, Framework, RSC Data, or RSC Framework
+- Keep examples short and directly tied to adoption
+- Avoid repeating the package prefix and PR-link details already provided by generated change sections
+- If unsure, run `pnpm changes:preview` with and without the manual section and keep it only if the generated notes are meaningfully clearer with the narrative
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c4875750f..de2fa1fe14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@ We manage release notes in this file instead of the paginated Github Releases Pa
Table of Contents
- [React Router Releases](#react-router-releases)
+ - [v8.2.0](#v820)
+ - [Web Streams Default Server Entry](#web-streams-default-server-entry)
- [v8.1.0](#v810)
- [Agent Skills Installation via `create-react-router`](#agent-skills-installation-via-create-react-router)
- [Observability Metadata](#observability-metadata)
@@ -107,6 +109,65 @@ We manage release notes in this file instead of the paginated Github Releases Pa
+## v8.2.0
+
+Date: 2026-07-08
+
+### What's Changed
+
+#### Web Streams Default Server Entry
+
+Non-Node runtime Framework Mode apps no longer need a custom `entry.server.tsx` file using React's `renderToReadableStream` API. Apps with `@react-router/{node,express,serve}` dependencies will continue to default to `renderToPipeableStream`, while non-Node apps default to `renderToReadableStream`.
+
+Because Web Streams are stable in Node 22+, Node apps can also opt-into the Web Streams default entry with the new `future.unstable_enableNodeReadableStream` flag:
+
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
+
+export default {
+ future: {
+ unstable_enableNodeReadableStream: true,
+ },
+} satisfies Config;
+```
+
+This flag has no effect if you have a custom `entry.server.tsx` keep using their custom entry file. It only applies to the default entry used if one doesn't exist.
+
+Node apps opting-into the Web Streams API _might_ even see a small performance boost because React Router already uses Web Streams internally, so this avoids additional conversions between Web/Node streams. If you see perf changes one way or another upon adopting this flag, please let us know!
+
+### Minor Changes
+
+- `@react-router/dev` - Add a Web Streams default server entry for non-Node Framework mode apps ([#15290](https://github.com/remix-run/react-router/pull/15290))
+ - Apps using `@react-router/node`, `@react-router/express`, or `@react-router/serve` continue to use the `renderToPipeableStream` default server entry
+ - Apps without those Node server adapter dependencies use a `renderToReadableStream` default server entry
+ - Non-Node apps with their own `entry.server.tsx` may be able to remove it in favor of the default if it is not doing anything custom
+- `@react-router/dev` - Detect `nub` as a supported package manager when installing framework dependencies ([#15276](https://github.com/remix-run/react-router/pull/15276))
+- `create-react-router` - Detect `nub` as a supported package manager when creating new projects ([#15276](https://github.com/remix-run/react-router/pull/15276))
+
+### Patch Changes
+
+- `react-router` - Fix `href()` to properly stringify and URL-encode param values, matching `generatePath()` ([#15277](https://github.com/remix-run/react-router/pull/15277))
+ - splat params preserve path separators while encoding each segment individually
+- `react-router` - Fix dynamic param extraction for routes with optional static segments ([#15200](https://github.com/remix-run/react-router/pull/15200))
+ - 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
+- `react-router` - Preserve navigation blocker state through a revalidation ([#15246](https://github.com/remix-run/react-router/pull/15246))
+- `react-router` - Fix route ranking for dynamic parameters with static extension suffixes ([#15273](https://github.com/remix-run/react-router/pull/15273))
+ - These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment
+ - This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order
+ - These are now correctly identified as dynamic parameter segments and scored correctly
+- `react-router` - Use ReactFormState types instead of unknown ([#15263](https://github.com/remix-run/react-router/pull/15263))
+- `@react-router/dev` - Detect user `rolldownOptions` config in Vite 8+ ([#15278](https://github.com/remix-run/react-router/pull/15278))
+
+### Unstable Changes
+
+⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_
+
+- `@react-router/dev` - Add the [`future.unstable_enableNodeReadableStream`](https://reactrouter.com/upgrading/future#futureunstable_enablenodereadablestream) flag to opt Node Framework mode apps into using `renderToReadableStream` instead of `renderToPipeableStream` ([#15290](https://github.com/remix-run/react-router/pull/15290))
+ - This flag has no effect if you have your own `entry.server.tsx`
+
+**Full Changelog**: [`v8.1.0...v8.2.0`](https://github.com/remix-run/react-router/compare/react-router@8.1.0...react-router@8.2.0)
+
## v8.1.0
Date: 2026-06-29
diff --git a/docs/upgrading/future.md b/docs/upgrading/future.md
index 65182ed69c..b54361e751 100644
--- a/docs/upgrading/future.md
+++ b/docs/upgrading/future.md
@@ -46,6 +46,39 @@ _No known planned breaking changes yet_
We document some [unstable] flags here as a reference for folks contributing to the project via beta testing, but they are not generally recommended for production use and may have breaking changes in patch or minor releases - adopt with caution!
+### `future.unstable_enableNodeReadableStream`
+
+[MODES: framework]
+
+
+
+
+**Background**
+
+Now that the Web Streams API is [stable](https://nodejs.org/docs/latest-v22.x/api/webstreams.html) in Node 22+, it's viable for React Router to use React's [`renderToReadableStream`](https://react.dev/reference/react-dom/server/renderToReadableStream) in the server entry.
+
+When no `entry.server.tsx` file is present, React Router defaults to [`renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream) when a Node runtime is detected, and `renderToReadableStream` otherwise.
+
+With this flag enabled, React Router will default to `renderToReadableStream` on all runtimes, including Node. You can continue to use `renderToPipeableStream` via a custom `entry.server.tsx` file if needed.
+
+Enabling this flag might even provide slight performance gains because we are already using Web Streams internally, so this flag removes some unnecessary transforms between Web and Node streams.
+
+👉 **Enable the Flag**
+
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
+
+export default {
+ future: {
+ unstable_enableNodeReadableStream: true,
+ },
+} satisfies Config;
+```
+
+**Update your Code**
+
+No code changes are required. If your app has a custom `entry.server.tsx`, this flag will not change your runtime behavior.
+
### `future.unstable_optimizeDeps`
[MODES: framework]
diff --git a/integration/cli-test.ts b/integration/cli-test.ts
index 471e1b4184..6d0d2c5e10 100644
--- a/integration/cli-test.ts
+++ b/integration/cli-test.ts
@@ -4,6 +4,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
+ readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -15,7 +16,7 @@ import { expect, test } from "@playwright/test";
import dedent from "dedent";
import semver from "semver";
-import { createProject } from "./helpers/vite";
+import { build, createProject, reactRouterConfig } from "./helpers/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDirectory = path.resolve(__dirname, "..");
@@ -198,6 +199,51 @@ test.describe("cli", () => {
expect(existsSync(entryServerFile)).toBeTruthy();
expect(existsSync(entryClientFile)).toBeTruthy();
+ expect(readFileSync(entryServerFile, "utf-8")).toContain(
+ "renderToPipeableStream",
+ );
+ });
+
+ test("generates a web server entry for non-Node projects", async () => {
+ const cwd = await createProject();
+ let packageJsonPath = path.join(cwd, "package.json");
+ let pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
+ delete pkg.dependencies["@react-router/express"];
+ delete pkg.dependencies["@react-router/node"];
+ delete pkg.dependencies["@react-router/serve"];
+ writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2));
+
+ let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
+
+ expect(existsSync(entryServerFile)).toBeFalsy();
+
+ run(["reveal", "entry.server"], { cwd });
+
+ expect(existsSync(entryServerFile)).toBeTruthy();
+ expect(readFileSync(entryServerFile, "utf-8")).toContain(
+ "renderToReadableStream",
+ );
+ });
+
+ test("generates a web server entry for Node projects with the readable stream future flag", async () => {
+ const cwd = await createProject({
+ "react-router.config.ts": reactRouterConfig({
+ future: {
+ unstable_enableNodeReadableStream: true,
+ },
+ }),
+ });
+
+ let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
+
+ expect(existsSync(entryServerFile)).toBeFalsy();
+
+ run(["reveal", "entry.server"], { cwd });
+
+ expect(existsSync(entryServerFile)).toBeTruthy();
+ expect(readFileSync(entryServerFile, "utf-8")).toContain(
+ "renderToReadableStream",
+ );
});
test("rsc generates entry.{ssr,rsc,client}.tsx in the app directory", async () => {
@@ -250,4 +296,18 @@ test.describe("cli", () => {
expect(existsSync(entryClientFile)).toBeTruthy();
});
});
+
+ test("builds a Node project with the readable stream future flag and default server entry", async () => {
+ const cwd = await createProject({
+ "react-router.config.ts": reactRouterConfig({
+ future: {
+ unstable_enableNodeReadableStream: true,
+ },
+ }),
+ });
+
+ const buildResult = build({ cwd });
+
+ expect(buildResult.status).toBe(0);
+ });
});
diff --git a/integration/vite-plugin-cloudflare-test.ts b/integration/vite-plugin-cloudflare-test.ts
index a8ceb5754a..31fee594fc 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 {
+ build,
+ createProject,
+ type Files,
+ test,
+ viteConfig,
+} from "./helpers/vite.js";
const tsx = dedent;
const css = dedent;
@@ -177,4 +183,16 @@ 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/integration/vite-presets-test.ts b/integration/vite-presets-test.ts
index 9fbd0a1473..ece2db31d7 100644
--- a/integration/vite-presets-test.ts
+++ b/integration/vite-presets-test.ts
@@ -246,6 +246,7 @@ test.describe("Vite / presets", async () => {
// Ensure future flags from presets are properly merged
expect(buildEndArgsMeta.futureFlags).toEqual({
+ unstable_enableNodeReadableStream: false,
unstable_optimizeDeps: true,
});
expect(buildEndArgsMeta.splitRouteModules).toBe(true);
diff --git a/packages/create-react-router/.changes/minor.detect-nub-package-manager.md b/packages/create-react-router/.changes/minor.detect-nub-package-manager.md
deleted file mode 100644
index 57fc984e5d..0000000000
--- a/packages/create-react-router/.changes/minor.detect-nub-package-manager.md
+++ /dev/null
@@ -1 +0,0 @@
-Detect nub as a supported package manager when creating new projects
diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md
index 9a3996c9e4..ac095d86de 100644
--- a/packages/create-react-router/CHANGELOG.md
+++ b/packages/create-react-router/CHANGELOG.md
@@ -1,5 +1,11 @@
# `create-react-router`
+## v8.2.0
+
+### Minor Changes
+
+- Detect `nub` as a supported package manager when creating new projects ([#15276](https://github.com/remix-run/react-router/pull/15276))
+
## v8.1.0
### Minor Changes
diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json
index a282395c79..ca37d076e5 100644
--- a/packages/create-react-router/package.json
+++ b/packages/create-react-router/package.json
@@ -1,7 +1,7 @@
{
"name": "create-react-router",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Create a new React Router app",
"homepage": "https://reactrouter.com",
"bugs": {
diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md
index ebedd043d6..9801e8cae5 100644
--- a/packages/react-router-architect/CHANGELOG.md
+++ b/packages/react-router-architect/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/architect`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+ - [`@react-router/node@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json
index d21551ec8a..0cc770f96b 100644
--- a/packages/react-router-architect/package.json
+++ b/packages/react-router-architect/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/architect",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Architect server request handler for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md
index 1e06837081..40637211dc 100644
--- a/packages/react-router-cloudflare/CHANGELOG.md
+++ b/packages/react-router-cloudflare/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/cloudflare`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json
index 5eeddace80..44b10eb9c3 100644
--- a/packages/react-router-cloudflare/package.json
+++ b/packages/react-router-cloudflare/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/cloudflare",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Cloudflare platform abstractions for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-dev/.changes/minor.detect-nub-package-manager.md b/packages/react-router-dev/.changes/minor.detect-nub-package-manager.md
deleted file mode 100644
index ad0907e34b..0000000000
--- a/packages/react-router-dev/.changes/minor.detect-nub-package-manager.md
+++ /dev/null
@@ -1 +0,0 @@
-Detect nub as a supported package manager when installing framework dependencies
diff --git a/packages/react-router-dev/.changes/patch.properly-detect-user-rolldownoptions-config-vite.md b/packages/react-router-dev/.changes/patch.properly-detect-user-rolldownoptions-config-vite.md
deleted file mode 100644
index 9c429f9aa2..0000000000
--- a/packages/react-router-dev/.changes/patch.properly-detect-user-rolldownoptions-config-vite.md
+++ /dev/null
@@ -1 +0,0 @@
-Properly detect user `rolldownOptions` config in Vite 8+
diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md
index 83213fd938..f2032f1de7 100644
--- a/packages/react-router-dev/CHANGELOG.md
+++ b/packages/react-router-dev/CHANGELOG.md
@@ -1,5 +1,30 @@
# `@react-router/dev`
+## v8.2.0
+
+### Minor Changes
+
+- Add a Web Streams default server entry for non-Node Framework mode apps ([#15290](https://github.com/remix-run/react-router/pull/15290))
+ - Apps using `@react-router/node`, `@react-router/express`, or `@react-router/serve` continue to use the `renderToPipeableStream` default server entry
+ - Apps without those Node server adapter dependencies use a `renderToReadableStream` default server entry
+ - Non-Node apps with their own `entry.server.tsx` may be able to remove it in favor of the default if it is not doing anything custom
+- Detect `nub` as a supported package manager when installing framework dependencies ([#15276](https://github.com/remix-run/react-router/pull/15276))
+
+### Patch Changes
+
+- Detect user `rolldownOptions` config in Vite 8+ ([#15278](https://github.com/remix-run/react-router/pull/15278))
+
+### Unstable Changes
+
+⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_
+
+- Add the [`future.unstable_enableNodeReadableStream`](https://reactrouter.com/upgrading/future#futureunstable_enablenodereadablestream) flag to opt Node Framework mode apps into using `renderToReadableStream` instead of `renderToPipeableStream` ([#15290](https://github.com/remix-run/react-router/pull/15290))
+ - This flag has no effect if you have your own `entry.server.tsx`
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+ - [`@react-router/node@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@8.2.0)
+ - [`@react-router/serve@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/serve@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-dev/cli/commands.ts b/packages/react-router-dev/cli/commands.ts
index 481a2cc886..e83556b168 100644
--- a/packages/react-router-dev/cli/commands.ts
+++ b/packages/react-router-dev/cli/commands.ts
@@ -10,7 +10,7 @@ import "react-router";
import type { ViteDevOptions } from "../vite/dev";
import type { ViteBuildOptions } from "../vite/build";
-import { loadConfig } from "../config/config";
+import { hasNodeDependency, loadConfig } from "../config/config";
import { formatRoutes } from "../config/format";
import type { RoutesFormat } from "../config/format";
import { transpile as convertFileToJS } from "./useJavascript";
@@ -171,12 +171,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,
@@ -185,7 +179,10 @@ export async function generateEntry(
let defaultEntryServer = path.resolve(
defaultsDirectory,
- `entry.server.node.tsx`,
+ hasNodeDependency(pkgJson.dependencies) &&
+ !configResult.value.future.unstable_enableNodeReadableStream
+ ? `entry.server.node.tsx`
+ : `entry.server.web.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..4773c09b2c 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -8,7 +8,12 @@ import chokidar, {
type FSWatcher,
type EmitArgs as ChokidarEmitArgs,
} from "chokidar";
-import { readPackageJSON, sortPackage, updatePackage } from "pkg-types";
+import {
+ readPackageJSON,
+ sortPackage,
+ updatePackage,
+ type PackageJson,
+} from "pkg-types";
import colors from "picocolors";
import pick from "lodash/pick.js";
import omit from "lodash/omit.js";
@@ -89,6 +94,7 @@ type ServerModuleFormat = "esm" | "cjs";
type ValidateConfigFunction = (config: ReactRouterConfig) => string | void;
interface FutureConfig {
+ unstable_enableNodeReadableStream: boolean;
unstable_optimizeDeps: boolean;
}
@@ -738,6 +744,8 @@ async function resolveConfig({
}
let future: FutureConfig = {
+ unstable_enableNodeReadableStream:
+ userAndPresetConfigs.future?.unstable_enableNodeReadableStream ?? false,
unstable_optimizeDeps:
userAndPresetConfigs.future?.unstable_optimizeDeps ?? false,
};
@@ -1045,12 +1053,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 +1072,11 @@ export async function resolveEntryFiles({
});
}
- entryServerFile = `entry.server.node.tsx`;
+ entryServerFile =
+ hasNodeDependency(deps) &&
+ !reactRouterConfig.future.unstable_enableNodeReadableStream
+ ? `entry.server.node.tsx`
+ : `entry.server.web.tsx`;
}
let entryClientFilePath = userEntryClientFile
@@ -1127,6 +1133,19 @@ function omitRoutes(
const entryExts = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"];
+export function hasNodeDependency(deps: PackageJson["dependencies"]) {
+ // Match the server condition check in the Vite plugin: missing dependencies
+ // imply Node for backwards compatibility.
+ return (
+ !deps ||
+ Boolean(
+ deps["@react-router/node"] ||
+ deps["@react-router/express"] ||
+ deps["@react-router/serve"],
+ )
+ );
+}
+
function isEntryFile(entryBasename: string, filename: string) {
return entryExts.some((ext) => filename === `${entryBasename}${ext}`);
}
diff --git a/integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx b/packages/react-router-dev/config/defaults/entry.server.web.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.web.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.web.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-dev/package.json b/packages/react-router-dev/package.json
index 381d4e83f3..b3e986e566 100644
--- a/packages/react-router-dev/package.json
+++ b/packages/react-router-dev/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/dev",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Dev tools and CLI for React Router",
"keywords": [
"react",
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index 4c785aee35..ac89e62bc9 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -84,6 +84,7 @@ import {
type BuildManifest,
type ConfigLoader,
createConfigLoader,
+ hasNodeDependency,
resolveEntryFiles,
configRouteToBranchRoute,
type PrerenderPaths,
@@ -3577,11 +3578,7 @@ export async function getEnvironmentOptionsResolvers(
let defaultExternalConditions = vite.defaultExternalConditions ?? ["node"];
// If we couldn't find the package.json, we assume node for backwards compatibility
- let isNode =
- !pkgJson.dependencies ||
- pkgJson.dependencies["@react-router/node"] ||
- pkgJson.dependencies["@react-router/express"] ||
- pkgJson.dependencies["@react-router/serve"];
+ let isNode = hasNodeDependency(pkgJson.dependencies);
if (!isNode) {
maybeDefaultServerConditions = maybeDefaultServerConditions.filter(
diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md
index ef65397247..9140efdf0b 100644
--- a/packages/react-router-express/CHANGELOG.md
+++ b/packages/react-router-express/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/express`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+ - [`@react-router/node@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json
index 34c8fd1db7..92d36d8ece 100644
--- a/packages/react-router-express/package.json
+++ b/packages/react-router-express/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/express",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Express server request handler for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md
index 9fa7fbbb79..c5a54a047d 100644
--- a/packages/react-router-fs-routes/CHANGELOG.md
+++ b/packages/react-router-fs-routes/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/fs-routes`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`@react-router/dev@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json
index b4b63af1b4..d220813406 100644
--- a/packages/react-router-fs-routes/package.json
+++ b/packages/react-router-fs-routes/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/fs-routes",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "File system routing conventions for React Router, for use within routes.ts",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md
index baa5cbdb92..efe82bbc88 100644
--- a/packages/react-router-node/CHANGELOG.md
+++ b/packages/react-router-node/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/node`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json
index bb824bcd1c..27f09bfe2a 100644
--- a/packages/react-router-node/package.json
+++ b/packages/react-router-node/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/node",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Node.js platform abstractions for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md
index 917d97f27a..f1dcea567c 100644
--- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md
+++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/remix-config-routes-adapter`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`@react-router/dev@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json
index 444d452930..bad639636c 100644
--- a/packages/react-router-remix-routes-option-adapter/package.json
+++ b/packages/react-router-remix-routes-option-adapter/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/remix-routes-option-adapter",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Adapter for Remix's \"routes\" config option, for use within routes.ts",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md
index 7fbde4acd1..446ea33f1e 100644
--- a/packages/react-router-serve/CHANGELOG.md
+++ b/packages/react-router-serve/CHANGELOG.md
@@ -1,5 +1,14 @@
# `@react-router/serve`
+## v8.2.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - [`react-router@8.2.0`](https://github.com/remix-run/react-router/releases/tag/react-router@8.2.0)
+ - [`@react-router/express@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/express@8.2.0)
+ - [`@react-router/node@8.2.0`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@8.2.0)
+
## v8.1.0
### Patch Changes
diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json
index 49c3e8732e..e4bb702106 100644
--- a/packages/react-router-serve/package.json
+++ b/packages/react-router-serve/package.json
@@ -1,7 +1,7 @@
{
"name": "@react-router/serve",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Production application server for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router/.changes/patch.encode-href-params.md b/packages/react-router/.changes/patch.encode-href-params.md
deleted file mode 100644
index c810872df0..0000000000
--- a/packages/react-router/.changes/patch.encode-href-params.md
+++ /dev/null
@@ -1,3 +0,0 @@
-Fix `href()` to properly stringify and URL-encode param values, matching `generatePath()`
-
-- splat params preserve path separators while encoding each segment individually
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
deleted file mode 100644
index 3a93e1fe09..0000000000
--- a/packages/react-router/.changes/patch.fix-optional-static-segment-params.md
+++ /dev/null
@@ -1,4 +0,0 @@
-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/.changes/patch.preserve-navigation-blocker-state-through-revalidation.md b/packages/react-router/.changes/patch.preserve-navigation-blocker-state-through-revalidation.md
deleted file mode 100644
index 634adafd2d..0000000000
--- a/packages/react-router/.changes/patch.preserve-navigation-blocker-state-through-revalidation.md
+++ /dev/null
@@ -1 +0,0 @@
-Preserve navigation blocker state through a revalidation
diff --git a/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md b/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md
deleted file mode 100644
index 0132c8269e..0000000000
--- a/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md
+++ /dev/null
@@ -1,5 +0,0 @@
-Fix route ranking/scoring bug with dynamic parameters containing static extension suffixes (i.e., `/:name.xml`)
-
-- These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment
-- This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order
-- These are now correctly identified as dynamic parameter segments and scored correctly
diff --git a/packages/react-router/.changes/patch.rsc-form-state.md b/packages/react-router/.changes/patch.rsc-form-state.md
deleted file mode 100644
index 1bf83a8517..0000000000
--- a/packages/react-router/.changes/patch.rsc-form-state.md
+++ /dev/null
@@ -1 +0,0 @@
-Update to use ReactFormState types instead of unknown.
diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md
index 14c58666f2..2883614256 100644
--- a/packages/react-router/CHANGELOG.md
+++ b/packages/react-router/CHANGELOG.md
@@ -1,5 +1,21 @@
# `react-router`
+## v8.2.0
+
+### Patch Changes
+
+- Fix `href()` to properly stringify and URL-encode param values, matching `generatePath()` ([#15277](https://github.com/remix-run/react-router/pull/15277))
+ - splat params preserve path separators while encoding each segment individually
+- Fix dynamic param extraction for routes with optional static segments ([#15200](https://github.com/remix-run/react-router/pull/15200))
+ - 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
+- Preserve navigation blocker state through a revalidation ([#15246](https://github.com/remix-run/react-router/pull/15246))
+- Fix route ranking for dynamic parameters with static extension suffixes ([#15273](https://github.com/remix-run/react-router/pull/15273))
+ - These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment
+ - This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order
+ - These are now correctly identified as dynamic parameter segments and scored correctly
+- Use ReactFormState types instead of unknown ([#15263](https://github.com/remix-run/react-router/pull/15263))
+
## v8.1.0
### Minor Changes
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 9fd6c022e1..db9c66f667 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -1,7 +1,7 @@
{
"name": "react-router",
"type": "module",
- "version": "8.1.0",
+ "version": "8.2.0",
"description": "Declarative routing for React",
"keywords": [
"react",
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,
- });
-}