diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a02a4f284..481c901a24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,128 +13,131 @@ We manage release notes in this file instead of the paginated Github Releases Pa
Table of Contents
- [React Router Releases](#react-router-releases)
- - [v7.12.0](#v7120)
- - [Security Notice](#security-notice)
+ - [v7.13.0](#v7130)
- [Minor Changes](#minor-changes)
- [Patch Changes](#patch-changes)
+ - [v7.12.0](#v7120)
+ - [Security Notice](#security-notice)
+ - [Minor Changes](#minor-changes-1)
+ - [Patch Changes](#patch-changes-1)
- [Unstable Changes](#unstable-changes)
- [v7.11.0](#v7110)
- [What's Changed](#whats-changed)
- [`vite preview` Support](#vite-preview-support)
- [Stabilized Client-side `onError`](#stabilized-client-side-onerror)
- [Call-site Revalidation Opt-out (unstable)](#call-site-revalidation-opt-out-unstable)
- - [Minor Changes](#minor-changes-1)
- - [Patch Changes](#patch-changes-1)
+ - [Minor Changes](#minor-changes-2)
+ - [Patch Changes](#patch-changes-2)
- [Unstable Changes](#unstable-changes-1)
- [v7.10.1](#v7101)
- - [Patch Changes](#patch-changes-2)
+ - [Patch Changes](#patch-changes-3)
- [v7.10.0](#v7100)
- [What's Changed](#whats-changed-1)
- [Stabilized `future.v8_splitRouteModules`](#stabilized-futurev8_splitroutemodules)
- [Stabilized `future.v8_viteEnvironmentApi`](#stabilized-futurev8_viteenvironmentapi)
- [Stabilized `fetcher.reset()`](#stabilized-fetcherreset)
- [Stabilized `DataStrategyMatch.shouldCallHandler()`](#stabilized-datastrategymatchshouldcallhandler)
- - [Minor Changes](#minor-changes-2)
- - [Patch Changes](#patch-changes-3)
+ - [Minor Changes](#minor-changes-3)
+ - [Patch Changes](#patch-changes-4)
- [Unstable Changes](#unstable-changes-2)
- [v7.9.6](#v796)
- [Security Notice](#security-notice-1)
- - [Patch Changes](#patch-changes-4)
+ - [Patch Changes](#patch-changes-5)
- [Unstable Changes](#unstable-changes-3)
- [v7.9.5](#v795)
- [What's Changed](#whats-changed-2)
- [Instrumentation (unstable)](#instrumentation-unstable)
- - [Patch Changes](#patch-changes-5)
+ - [Patch Changes](#patch-changes-6)
- [Unstable Changes](#unstable-changes-4)
- [v7.9.4](#v794)
- [Security Notice](#security-notice-2)
- [What's Changed](#whats-changed-3)
- [`useRoute()` (unstable)](#useroute-unstable)
- - [Patch Changes](#patch-changes-6)
+ - [Patch Changes](#patch-changes-7)
- [Unstable Changes](#unstable-changes-5)
- [v7.9.3](#v793)
- - [Patch Changes](#patch-changes-7)
+ - [Patch Changes](#patch-changes-8)
- [v7.9.2](#v792)
- [What's Changed](#whats-changed-4)
- [RSC Framework Mode (unstable)](#rsc-framework-mode-unstable)
- [Fetcher Reset (unstable)](#fetcher-reset-unstable)
- - [Patch Changes](#patch-changes-8)
+ - [Patch Changes](#patch-changes-9)
- [Unstable Changes](#unstable-changes-6)
- [v7.9.1](#v791)
- - [Patch Changes](#patch-changes-9)
+ - [Patch Changes](#patch-changes-10)
- [v7.9.0](#v790)
- [Security Notice](#security-notice-3)
- [What's Changed](#whats-changed-5)
- [Stable Middleware and Context APIs](#stable-middleware-and-context-apis)
- - [Minor Changes](#minor-changes-3)
- - [Patch Changes](#patch-changes-10)
+ - [Minor Changes](#minor-changes-4)
+ - [Patch Changes](#patch-changes-11)
- [Unstable Changes](#unstable-changes-7)
- [v7.8.2](#v782)
- - [Patch Changes](#patch-changes-11)
+ - [Patch Changes](#patch-changes-12)
- [Unstable Changes](#unstable-changes-8)
- [v7.8.1](#v781)
- - [Patch Changes](#patch-changes-12)
+ - [Patch Changes](#patch-changes-13)
- [Unstable Changes](#unstable-changes-9)
- [v7.8.0](#v780)
- [What's Changed](#whats-changed-6)
- [Consistently named `loaderData` values](#consistently-named-loaderdata-values)
- [Improvements/fixes to the middleware APIs (unstable)](#improvementsfixes-to-the-middleware-apis-unstable)
- - [Minor Changes](#minor-changes-4)
- - [Patch Changes](#patch-changes-13)
+ - [Minor Changes](#minor-changes-5)
+ - [Patch Changes](#patch-changes-14)
- [Unstable Changes](#unstable-changes-10)
- [Changes by Package](#changes-by-package)
- [v7.7.1](#v771)
- - [Patch Changes](#patch-changes-14)
+ - [Patch Changes](#patch-changes-15)
- [Unstable Changes](#unstable-changes-11)
- [v7.7.0](#v770)
- [What's Changed](#whats-changed-7)
- [Unstable RSC APIs](#unstable-rsc-apis)
- - [Minor Changes](#minor-changes-5)
- - [Patch Changes](#patch-changes-15)
+ - [Minor Changes](#minor-changes-6)
+ - [Patch Changes](#patch-changes-16)
- [Unstable Changes](#unstable-changes-12)
- [Changes by Package](#changes-by-package-1)
- [v7.6.3](#v763)
- - [Patch Changes](#patch-changes-16)
- - [v7.6.2](#v762)
- [Patch Changes](#patch-changes-17)
- - [v7.6.1](#v761)
+ - [v7.6.2](#v762)
- [Patch Changes](#patch-changes-18)
+ - [v7.6.1](#v761)
+ - [Patch Changes](#patch-changes-19)
- [Unstable Changes](#unstable-changes-13)
- [v7.6.0](#v760)
- [What's Changed](#whats-changed-8)
- [`routeDiscovery` Config Option](#routediscovery-config-option)
- [Automatic Types for Future Flags](#automatic-types-for-future-flags)
- - [Minor Changes](#minor-changes-6)
- - [Patch Changes](#patch-changes-19)
+ - [Minor Changes](#minor-changes-7)
+ - [Patch Changes](#patch-changes-20)
- [Unstable Changes](#unstable-changes-14)
- [Changes by Package](#changes-by-package-2)
- [v7.5.3](#v753)
- - [Patch Changes](#patch-changes-20)
+ - [Patch Changes](#patch-changes-21)
- [v7.5.2](#v752)
- [Security Notice](#security-notice-4)
- - [Patch Changes](#patch-changes-21)
- - [v7.5.1](#v751)
- [Patch Changes](#patch-changes-22)
+ - [v7.5.1](#v751)
+ - [Patch Changes](#patch-changes-23)
- [Unstable Changes](#unstable-changes-15)
- [v7.5.0](#v750)
- [What's Changed](#whats-changed-9)
- [`route.lazy` Object API](#routelazy-object-api)
- - [Minor Changes](#minor-changes-7)
- - [Patch Changes](#patch-changes-23)
+ - [Minor Changes](#minor-changes-8)
+ - [Patch Changes](#patch-changes-24)
- [Unstable Changes](#unstable-changes-16)
- [Changes by Package](#changes-by-package-3)
- [v7.4.1](#v741)
- [Security Notice](#security-notice-5)
- - [Patch Changes](#patch-changes-24)
+ - [Patch Changes](#patch-changes-25)
- [Unstable Changes](#unstable-changes-17)
- [v7.4.0](#v740)
- - [Minor Changes](#minor-changes-8)
- - [Patch Changes](#patch-changes-25)
+ - [Minor Changes](#minor-changes-9)
+ - [Patch Changes](#patch-changes-26)
- [Unstable Changes](#unstable-changes-18)
- [Changes by Package](#changes-by-package-4)
- [v7.3.0](#v730)
- - [Minor Changes](#minor-changes-9)
- - [Patch Changes](#patch-changes-26)
+ - [Minor Changes](#minor-changes-10)
+ - [Patch Changes](#patch-changes-27)
- [Unstable Changes](#unstable-changes-19)
- [Client-side `context` (unstable)](#client-side-context-unstable)
- [Middleware (unstable)](#middleware-unstable)
@@ -146,29 +149,29 @@ We manage release notes in this file instead of the paginated Github Releases Pa
- [Type-safe `href` utility](#type-safe-href-utility)
- [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback)
- [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode)
- - [Minor Changes](#minor-changes-10)
- - [Patch Changes](#patch-changes-27)
+ - [Minor Changes](#minor-changes-11)
+ - [Patch Changes](#patch-changes-28)
- [Unstable Changes](#unstable-changes-20)
- [Split Route Modules (unstable)](#split-route-modules-unstable)
- [Changes by Package](#changes-by-package-6)
- [v7.1.5](#v715)
- - [Patch Changes](#patch-changes-28)
- - [v7.1.4](#v714)
- [Patch Changes](#patch-changes-29)
- - [v7.1.3](#v713)
+ - [v7.1.4](#v714)
- [Patch Changes](#patch-changes-30)
- - [v7.1.2](#v712)
+ - [v7.1.3](#v713)
- [Patch Changes](#patch-changes-31)
- - [v7.1.1](#v711)
+ - [v7.1.2](#v712)
- [Patch Changes](#patch-changes-32)
- - [v7.1.0](#v710)
- - [Minor Changes](#minor-changes-11)
+ - [v7.1.1](#v711)
- [Patch Changes](#patch-changes-33)
+ - [v7.1.0](#v710)
+ - [Minor Changes](#minor-changes-12)
+ - [Patch Changes](#patch-changes-34)
- [Changes by Package](#changes-by-package-7)
- [v7.0.2](#v702)
- - [Patch Changes](#patch-changes-34)
- - [v7.0.1](#v701)
- [Patch Changes](#patch-changes-35)
+ - [v7.0.1](#v701)
+ - [Patch Changes](#patch-changes-36)
- [v7.0.0](#v700)
- [Breaking Changes](#breaking-changes)
- [Package Restructuring](#package-restructuring)
@@ -184,208 +187,208 @@ We manage release notes in this file instead of the paginated Github Releases Pa
- [Prerendering](#prerendering)
- [Major Changes (`react-router`)](#major-changes-react-router)
- [Major Changes (`@react-router/*`)](#major-changes-react-router-1)
- - [Minor Changes](#minor-changes-12)
- - [Patch Changes](#patch-changes-36)
+ - [Minor Changes](#minor-changes-13)
+ - [Patch Changes](#patch-changes-37)
- [Changes by Package](#changes-by-package-8)
- [React Router v6 Releases](#react-router-v6-releases)
- [v6.30.3](#v6303)
- [Security Notice](#security-notice-6)
- - [Patch Changes](#patch-changes-37)
+ - [Patch Changes](#patch-changes-38)
- [v6.30.2](#v6302)
- [Security Notice](#security-notice-7)
- - [Patch Changes](#patch-changes-38)
- - [v6.30.1](#v6301)
- [Patch Changes](#patch-changes-39)
- - [v6.30.0](#v6300)
- - [Minor Changes](#minor-changes-13)
+ - [v6.30.1](#v6301)
- [Patch Changes](#patch-changes-40)
- - [v6.29.0](#v6290)
+ - [v6.30.0](#v6300)
- [Minor Changes](#minor-changes-14)
- [Patch Changes](#patch-changes-41)
- - [v6.28.2](#v6282)
+ - [v6.29.0](#v6290)
+ - [Minor Changes](#minor-changes-15)
- [Patch Changes](#patch-changes-42)
- - [v6.28.1](#v6281)
+ - [v6.28.2](#v6282)
- [Patch Changes](#patch-changes-43)
+ - [v6.28.1](#v6281)
+ - [Patch Changes](#patch-changes-44)
- [v6.28.0](#v6280)
- [What's Changed](#whats-changed-11)
- - [Minor Changes](#minor-changes-15)
- - [Patch Changes](#patch-changes-44)
+ - [Minor Changes](#minor-changes-16)
+ - [Patch Changes](#patch-changes-45)
- [v6.27.0](#v6270)
- [What's Changed](#whats-changed-12)
- [Stabilized APIs](#stabilized-apis)
- - [Minor Changes](#minor-changes-16)
- - [Patch Changes](#patch-changes-45)
- - [v6.26.2](#v6262)
+ - [Minor Changes](#minor-changes-17)
- [Patch Changes](#patch-changes-46)
- - [v6.26.1](#v6261)
+ - [v6.26.2](#v6262)
- [Patch Changes](#patch-changes-47)
- - [v6.26.0](#v6260)
- - [Minor Changes](#minor-changes-17)
+ - [v6.26.1](#v6261)
- [Patch Changes](#patch-changes-48)
- - [v6.25.1](#v6251)
+ - [v6.26.0](#v6260)
+ - [Minor Changes](#minor-changes-18)
- [Patch Changes](#patch-changes-49)
+ - [v6.25.1](#v6251)
+ - [Patch Changes](#patch-changes-50)
- [v6.25.0](#v6250)
- [What's Changed](#whats-changed-13)
- [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation)
- - [Minor Changes](#minor-changes-18)
- - [Patch Changes](#patch-changes-50)
- - [v6.24.1](#v6241)
+ - [Minor Changes](#minor-changes-19)
- [Patch Changes](#patch-changes-51)
+ - [v6.24.1](#v6241)
+ - [Patch Changes](#patch-changes-52)
- [v6.24.0](#v6240)
- [What's Changed](#whats-changed-14)
- [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war)
- - [Minor Changes](#minor-changes-19)
- - [Patch Changes](#patch-changes-52)
- - [v6.23.1](#v6231)
+ - [Minor Changes](#minor-changes-20)
- [Patch Changes](#patch-changes-53)
+ - [v6.23.1](#v6231)
+ - [Patch Changes](#patch-changes-54)
- [v6.23.0](#v6230)
- [What's Changed](#whats-changed-15)
- [Data Strategy (unstable)](#data-strategy-unstable)
- [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable)
- - [Minor Changes](#minor-changes-20)
+ - [Minor Changes](#minor-changes-21)
- [v6.22.3](#v6223)
- - [Patch Changes](#patch-changes-54)
- - [v6.22.2](#v6222)
- [Patch Changes](#patch-changes-55)
- - [v6.22.1](#v6221)
+ - [v6.22.2](#v6222)
- [Patch Changes](#patch-changes-56)
+ - [v6.22.1](#v6221)
+ - [Patch Changes](#patch-changes-57)
- [v6.22.0](#v6220)
- [What's Changed](#whats-changed-16)
- [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag)
- - [Minor Changes](#minor-changes-21)
- - [Patch Changes](#patch-changes-57)
- - [v6.21.3](#v6213)
+ - [Minor Changes](#minor-changes-22)
- [Patch Changes](#patch-changes-58)
- - [v6.21.2](#v6212)
+ - [v6.21.3](#v6213)
- [Patch Changes](#patch-changes-59)
- - [v6.21.1](#v6211)
+ - [v6.21.2](#v6212)
- [Patch Changes](#patch-changes-60)
+ - [v6.21.1](#v6211)
+ - [Patch Changes](#patch-changes-61)
- [v6.21.0](#v6210)
- [What's Changed](#whats-changed-17)
- [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath)
- [Partial Hydration](#partial-hydration)
- - [Minor Changes](#minor-changes-22)
- - [Patch Changes](#patch-changes-61)
- - [v6.20.1](#v6201)
- - [Patch Changes](#patch-changes-62)
- - [v6.20.0](#v6200)
- [Minor Changes](#minor-changes-23)
+ - [Patch Changes](#patch-changes-62)
+ - [v6.20.1](#v6201)
- [Patch Changes](#patch-changes-63)
+ - [v6.20.0](#v6200)
+ - [Minor Changes](#minor-changes-24)
+ - [Patch Changes](#patch-changes-64)
- [v6.19.0](#v6190)
- [What's Changed](#whats-changed-18)
- [`unstable_flushSync` API](#unstable_flushsync-api)
- - [Minor Changes](#minor-changes-24)
- - [Patch Changes](#patch-changes-64)
+ - [Minor Changes](#minor-changes-25)
+ - [Patch Changes](#patch-changes-65)
- [v6.18.0](#v6180)
- [What's Changed](#whats-changed-19)
- [New Fetcher APIs](#new-fetcher-apis)
- [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist)
- - [Minor Changes](#minor-changes-25)
- - [Patch Changes](#patch-changes-65)
+ - [Minor Changes](#minor-changes-26)
+ - [Patch Changes](#patch-changes-66)
- [v6.17.0](#v6170)
- [What's Changed](#whats-changed-20)
- [View Transitions 🚀](#view-transitions-)
- - [Minor Changes](#minor-changes-26)
- - [Patch Changes](#patch-changes-66)
- - [v6.16.0](#v6160)
- [Minor Changes](#minor-changes-27)
- [Patch Changes](#patch-changes-67)
- - [v6.15.0](#v6150)
+ - [v6.16.0](#v6160)
- [Minor Changes](#minor-changes-28)
- [Patch Changes](#patch-changes-68)
- - [v6.14.2](#v6142)
+ - [v6.15.0](#v6150)
+ - [Minor Changes](#minor-changes-29)
- [Patch Changes](#patch-changes-69)
- - [v6.14.1](#v6141)
+ - [v6.14.2](#v6142)
- [Patch Changes](#patch-changes-70)
+ - [v6.14.1](#v6141)
+ - [Patch Changes](#patch-changes-71)
- [v6.14.0](#v6140)
- [What's Changed](#whats-changed-21)
- [JSON/Text Submissions](#jsontext-submissions)
- - [Minor Changes](#minor-changes-29)
- - [Patch Changes](#patch-changes-71)
+ - [Minor Changes](#minor-changes-30)
+ - [Patch Changes](#patch-changes-72)
- [v6.13.0](#v6130)
- [What's Changed](#whats-changed-22)
- [`future.v7_startTransition`](#futurev7_starttransition)
- - [Minor Changes](#minor-changes-30)
- - [Patch Changes](#patch-changes-72)
- - [v6.12.1](#v6121)
+ - [Minor Changes](#minor-changes-31)
- [Patch Changes](#patch-changes-73)
+ - [v6.12.1](#v6121)
+ - [Patch Changes](#patch-changes-74)
- [v6.12.0](#v6120)
- [What's Changed](#whats-changed-23)
- [`React.startTransition` support](#reactstarttransition-support)
- - [Minor Changes](#minor-changes-31)
- - [Patch Changes](#patch-changes-74)
- - [v6.11.2](#v6112)
+ - [Minor Changes](#minor-changes-32)
- [Patch Changes](#patch-changes-75)
- - [v6.11.1](#v6111)
+ - [v6.11.2](#v6112)
- [Patch Changes](#patch-changes-76)
- - [v6.11.0](#v6110)
- - [Minor Changes](#minor-changes-32)
+ - [v6.11.1](#v6111)
- [Patch Changes](#patch-changes-77)
+ - [v6.11.0](#v6110)
+ - [Minor Changes](#minor-changes-33)
+ - [Patch Changes](#patch-changes-78)
- [v6.10.0](#v6100)
- [What's Changed](#whats-changed-24)
- - [Minor Changes](#minor-changes-33)
+ - [Minor Changes](#minor-changes-34)
- [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod)
- - [Patch Changes](#patch-changes-78)
+ - [Patch Changes](#patch-changes-79)
- [v6.9.0](#v690)
- [What's Changed](#whats-changed-25)
- [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties)
- [Introducing Lazy Route Modules](#introducing-lazy-route-modules)
- - [Minor Changes](#minor-changes-34)
- - [Patch Changes](#patch-changes-79)
- - [v6.8.2](#v682)
+ - [Minor Changes](#minor-changes-35)
- [Patch Changes](#patch-changes-80)
- - [v6.8.1](#v681)
+ - [v6.8.2](#v682)
- [Patch Changes](#patch-changes-81)
- - [v6.8.0](#v680)
- - [Minor Changes](#minor-changes-35)
+ - [v6.8.1](#v681)
- [Patch Changes](#patch-changes-82)
- - [v6.7.0](#v670)
+ - [v6.8.0](#v680)
- [Minor Changes](#minor-changes-36)
- [Patch Changes](#patch-changes-83)
- - [v6.6.2](#v662)
+ - [v6.7.0](#v670)
+ - [Minor Changes](#minor-changes-37)
- [Patch Changes](#patch-changes-84)
- - [v6.6.1](#v661)
+ - [v6.6.2](#v662)
- [Patch Changes](#patch-changes-85)
+ - [v6.6.1](#v661)
+ - [Patch Changes](#patch-changes-86)
- [v6.6.0](#v660)
- [What's Changed](#whats-changed-26)
- - [Minor Changes](#minor-changes-37)
- - [Patch Changes](#patch-changes-86)
- - [v6.5.0](#v650)
- - [What's Changed](#whats-changed-27)
- [Minor Changes](#minor-changes-38)
- [Patch Changes](#patch-changes-87)
- - [v6.4.5](#v645)
+ - [v6.5.0](#v650)
+ - [What's Changed](#whats-changed-27)
+ - [Minor Changes](#minor-changes-39)
- [Patch Changes](#patch-changes-88)
- - [v6.4.4](#v644)
+ - [v6.4.5](#v645)
- [Patch Changes](#patch-changes-89)
- - [v6.4.3](#v643)
+ - [v6.4.4](#v644)
- [Patch Changes](#patch-changes-90)
- - [v6.4.2](#v642)
+ - [v6.4.3](#v643)
- [Patch Changes](#patch-changes-91)
- - [v6.4.1](#v641)
+ - [v6.4.2](#v642)
- [Patch Changes](#patch-changes-92)
+ - [v6.4.1](#v641)
+ - [Patch Changes](#patch-changes-93)
- [v6.4.0](#v640)
- [What's Changed](#whats-changed-28)
- [Remix Data APIs](#remix-data-apis)
- - [Patch Changes](#patch-changes-93)
+ - [Patch Changes](#patch-changes-94)
- [v6.3.0](#v630)
- - [Minor Changes](#minor-changes-39)
+ - [Minor Changes](#minor-changes-40)
- [v6.2.2](#v622)
- - [Patch Changes](#patch-changes-94)
- - [v6.2.1](#v621)
- [Patch Changes](#patch-changes-95)
- - [v6.2.0](#v620)
- - [Minor Changes](#minor-changes-40)
+ - [v6.2.1](#v621)
- [Patch Changes](#patch-changes-96)
- - [v6.1.1](#v611)
- - [Patch Changes](#patch-changes-97)
- - [v6.1.0](#v610)
+ - [v6.2.0](#v620)
- [Minor Changes](#minor-changes-41)
+ - [Patch Changes](#patch-changes-97)
+ - [v6.1.1](#v611)
- [Patch Changes](#patch-changes-98)
- - [v6.0.2](#v602)
+ - [v6.1.0](#v610)
+ - [Minor Changes](#minor-changes-42)
- [Patch Changes](#patch-changes-99)
- - [v6.0.1](#v601)
+ - [v6.0.2](#v602)
- [Patch Changes](#patch-changes-100)
+ - [v6.0.1](#v601)
+ - [Patch Changes](#patch-changes-101)
- [v6.0.0](#v600)
@@ -413,6 +416,25 @@ Date: YYYY-MM-DD
**Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y)
-->
+## v7.13.0
+
+Date: 2026-01-23
+
+### Minor Changes
+
+- `react-router` - Add `crossOrigin` prop to `Links` component ([#14687](https://github.com/remix-run/react-router/pull/14687))
+
+### Patch Changes
+
+- `react-router` - Fix double slash normalization for `useNavigate` paths with a colon ([#14718](https://github.com/remix-run/react-router/pull/14718))
+- `react-router` - Fix missing `nonce` on inline `criticalCss` ([#14691](https://github.com/remix-run/react-router/pull/14691))
+- `react-router` - Update failed origin checks to return a 400 status instead of a 500 ([#14737](https://github.com/remix-run/react-router/pull/14737))
+- `react-router` - Loosen `allowedActionOrigins` glob check so `**` matches all domains ([#14722](https://github.com/remix-run/react-router/pull/14722))
+- `@react-router/dev` - Bump `@remix-run/node-fetch-server` dep ([#14704](https://github.com/remix-run/react-router/pull/14704))
+- `@react-router/fs-routes` - Fix route file paths when routes directory is outside of the app directory ([#13937](https://github.com/remix-run/react-router/pull/13937))
+
+**Full Changelog**: [`v7.12.0...v7.13.0`](https://github.com/remix-run/react-router/compare/react-router@7.12.0...react-router@7.13.0)
+
## v7.12.0
Date: 2026-01-07
diff --git a/contributors.yml b/contributors.yml
index 5b7945121e..63bc528008 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -27,6 +27,7 @@
- amitdahan
- AmRo045
- amsal
+- AnandShiva
- Andarist
- andreasottosson-polestar
- andreiborza
diff --git a/docs/api/components/Links.md b/docs/api/components/Links.md
index b1125a41a3..fa48661c7e 100644
--- a/docs/api/components/Links.md
+++ b/docs/api/components/Links.md
@@ -45,7 +45,7 @@ export default function Root() {
## Signature
```tsx
-function Links({ nonce }: LinksProps): React.JSX.Element
+function Links({ nonce, crossOrigin }: LinksProps): React.JSX.Element
```
## Props
@@ -56,3 +56,9 @@ A [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_a
attribute to render on the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
element
+### crossOrigin
+
+A [`crossOrigin`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)
+attribute to render on the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
+element
+
diff --git a/docs/api/framework-conventions/react-router.config.ts.md b/docs/api/framework-conventions/react-router.config.ts.md
index ea5ef0f44a..e5d096bd9f 100644
--- a/docs/api/framework-conventions/react-router.config.ts.md
+++ b/docs/api/framework-conventions/react-router.config.ts.md
@@ -30,6 +30,45 @@ export default {
## Options
+### `allowedActionOrigins`
+
+An array of allowed origin hosts for action submissions to UI routes (does not apply to resource routes). Supports micromatch glob patterns (`*` to match one segment, `**` to match multiple).
+
+```tsx filename=react-router.config.ts
+export default {
+ allowedActionOrigins: [
+ "example.com",
+ "*.example.com", // sub.example.com
+ "**.example.com", // sub.domain.example.com
+ ],
+} satisfies Config;
+```
+
+If you need to set this value at runtime, you can do in by setting the value on the server build in your custom server. For example, when using `express`:
+
+```ts
+import express from "express";
+import { createRequestHandler } from "@react-router/express";
+import type { ServerBuild } from "react-router";
+
+export const app = express();
+
+async function getBuild() {
+ let build: ServerBuild = await import(
+ "virtual:react-router/server-build"
+ );
+ return {
+ ...build,
+ allowedActionOrigins:
+ process.env.NODE_ENV === "development"
+ ? undefined
+ : ["staging.example.com", "www.example.com"],
+ };
+}
+
+app.use(createRequestHandler({ build: getBuild }));
+```
+
### `appDirectory`
The path to the `app` directory, relative to the root directory. Defaults to `"app"`.
@@ -66,7 +105,11 @@ A function that is called after the full React Router build is complete.
```tsx filename=react-router.config.ts
export default {
- buildEnd: async ({ buildManifest, reactRouterConfig, viteConfig }) => {
+ buildEnd: async ({
+ buildManifest,
+ reactRouterConfig,
+ viteConfig,
+ }) => {
// Custom build logic here
console.log("Build completed!");
},
diff --git a/docs/api/rsc/matchRSCServerRequest.md b/docs/api/rsc/matchRSCServerRequest.md
index 69ff6e999c..2fbed41c7d 100644
--- a/docs/api/rsc/matchRSCServerRequest.md
+++ b/docs/api/rsc/matchRSCServerRequest.md
@@ -70,6 +70,7 @@ matchRSCServerRequest({
```tsx
async function matchRSCServerRequest({
+ allowedActionOrigins,
createTemporaryReferenceSet,
basename,
decodeReply,
@@ -82,6 +83,7 @@ async function matchRSCServerRequest({
routes,
generateResponse,
}: {
+ allowedActionOrigins?: string[];
createTemporaryReferenceSet: () => unknown;
basename?: string;
decodeReply?: DecodeReplyFunction;
@@ -107,6 +109,10 @@ async function matchRSCServerRequest({
## Params
+### opts.allowedActionOrigins
+
+Origin patterns that are allowed to execute actions.
+
### opts.basename
The basename to use when matching the request.
diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md
index c5e0b521dc..e17e0a2fc3 100644
--- a/packages/create-react-router/CHANGELOG.md
+++ b/packages/create-react-router/CHANGELOG.md
@@ -1,7 +1,13 @@
# `create-react-router`
+## 7.13.0
+
+_No changes_
+
## 7.12.0
+_No changes_
+
## 7.11.0
_No changes_
diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json
index 572fe4b3bc..072d9b6dc2 100644
--- a/packages/create-react-router/package.json
+++ b/packages/create-react-router/package.json
@@ -1,6 +1,6 @@
{
"name": "create-react-router",
- "version": "7.12.0",
+ "version": "7.13.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 e7c07e020c..9caa5fee32 100644
--- a/packages/react-router-architect/CHANGELOG.md
+++ b/packages/react-router-architect/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/architect`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+ - `@react-router/node@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json
index 2e8bb28aaa..5c2e1442e2 100644
--- a/packages/react-router-architect/package.json
+++ b/packages/react-router-architect/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/architect",
- "version": "7.12.0",
+ "version": "7.13.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 6c3ff8718e..1aa8abdc8b 100644
--- a/packages/react-router-cloudflare/CHANGELOG.md
+++ b/packages/react-router-cloudflare/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/cloudflare`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json
index ccaffa9548..987f081d7c 100644
--- a/packages/react-router-cloudflare/package.json
+++ b/packages/react-router-cloudflare/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/cloudflare",
- "version": "7.12.0",
+ "version": "7.13.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/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md
index 8e06400721..ac593222a2 100644
--- a/packages/react-router-dev/CHANGELOG.md
+++ b/packages/react-router-dev/CHANGELOG.md
@@ -1,5 +1,15 @@
# `@react-router/dev`
+## 7.13.0
+
+### Patch Changes
+
+- Bump @remix-run/node-fetch-server dep ([#14704](https://github.com/remix-run/react-router/pull/14704))
+- Updated dependencies:
+ - `react-router@7.13.0`
+ - `@react-router/node@7.13.0`
+ - `@react-router/serve@7.13.0`
+
## 7.12.0
### Minor Changes
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index 604985deaa..0f54b23466 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -213,8 +213,44 @@ export type ReactRouterConfig = {
ssr?: boolean;
/**
- * The allowed origins for actions / mutations. Does not apply to routes
- * without a component. micromatch glob patterns are supported.
+ * An array of allowed origin hosts for action submissions to UI routes (does not apply
+ * to resource routes). Supports micromatch glob patterns (`*` to match one segment,
+ * `**` to match multiple).
+ *
+ * ```tsx
+ * export default {
+ * allowedActionOrigins: [
+ * "example.com",
+ * "*.example.com", // sub.example.com
+ * "**.example.com", // sub.domain.example.com
+ * ],
+ * } satisfies Config;
+ * ```
+ *
+ * If you need to set this value at runtime, you can do in by setting the value
+ * on the server build in your custom server. For example, when using `express`:
+ *
+ * ```ts
+ * import express from "express";
+ * import { createRequestHandler } from "@react-router/express";
+ * import type { ServerBuild } from "react-router";
+ *
+ * export const app = express();
+ *
+ * async function getBuild() {
+ * let build: ServerBuild = await import(
+ * "virtual:react-router/server-build"
+ * );
+ * return {
+ * ...build,
+ * allowedActionOrigins:
+ * process.env.NODE_ENV === "development"
+ * ? undefined
+ * : ["staging.example.com", "www.example.com"],
+ * };
+ * }
+ *
+ * app.use(createRequestHandler({ build: getBuild }));
*/
allowedActionOrigins?: string[];
};
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index 2859aa85a9..84c4fd010c 100644
--- a/packages/react-router-dev/package.json
+++ b/packages/react-router-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/dev",
- "version": "7.12.0",
+ "version": "7.13.0",
"description": "Dev tools and CLI for React Router",
"homepage": "https://reactrouter.com",
"bugs": {
@@ -77,7 +77,7 @@
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.7",
"@react-router/node": "workspace:*",
- "@remix-run/node-fetch-server": "^0.9.0",
+ "@remix-run/node-fetch-server": "^0.13.0",
"arg": "^5.0.1",
"babel-dead-code-elimination": "^1.0.6",
"chokidar": "^4.0.0",
diff --git a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
index 2492f7c743..2db12f6e8a 100644
--- a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
+++ b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
@@ -1,4 +1,3 @@
-import { sendResponse } from "@remix-run/node-fetch-server";
import { createRequestHandler } from "react-router";
import {
type AppLoadContext,
@@ -121,6 +120,9 @@ export const cloudflareDevProxyVitePlugin = (
}
},
configureServer: async (viteDevServer) => {
+ // Async import here to allow ESM only module on Node 20.18.
+ // TODO(v8): Can move to a normal import when Node 20 support
+ const { sendResponse } = await import("@remix-run/node-fetch-server");
let context: Awaited>;
let getContext = async () => {
let { getPlatformProxy } = await importWrangler();
@@ -139,7 +141,7 @@ export const cloudflareDevProxyVitePlugin = (
)) as ServerBuild;
let handler = createRequestHandler(build, "development");
- let req = fromNodeRequest(nodeReq, nodeRes);
+ let req = await fromNodeRequest(nodeReq, nodeRes);
context ??= await getContext();
let loadContext = getLoadContext
? await getLoadContext({ request: req, context })
diff --git a/packages/react-router-dev/vite/node-adapter.ts b/packages/react-router-dev/vite/node-adapter.ts
index 5a71c9c212..403add36ef 100644
--- a/packages/react-router-dev/vite/node-adapter.ts
+++ b/packages/react-router-dev/vite/node-adapter.ts
@@ -1,8 +1,6 @@
import type { ServerResponse } from "node:http";
-import { createRequest } from "@remix-run/node-fetch-server";
import type * as Vite from "vite";
-
import invariant from "../invariant";
export type NodeRequestHandler = (
@@ -10,10 +8,10 @@ export type NodeRequestHandler = (
res: ServerResponse,
) => Promise;
-export function fromNodeRequest(
+export async function fromNodeRequest(
nodeReq: Vite.Connect.IncomingMessage,
nodeRes: ServerResponse,
-): Request {
+): Promise {
// Use `req.originalUrl` so React Router is aware of the full path
invariant(
nodeReq.originalUrl,
@@ -21,5 +19,8 @@ export function fromNodeRequest(
);
nodeReq.url = nodeReq.originalUrl;
+ // Async import here to allow ESM only module on Node 20.18.
+ // TODO(v8): Can move to a normal import when Node 20 support
+ const { createRequest } = await import("@remix-run/node-fetch-server");
return createRequest(nodeReq, nodeRes);
}
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index 62935c15bc..5ea6f1b1f6 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -15,7 +15,6 @@ import {
import * as path from "node:path";
import * as url from "node:url";
import * as babel from "@babel/core";
-import { sendResponse } from "@remix-run/node-fetch-server";
import {
unstable_setDevServerHooks as setDevServerHooks,
createRequestHandler,
@@ -1673,11 +1672,16 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
nodeReq,
nodeRes,
) => {
- let req = fromNodeRequest(nodeReq, nodeRes);
+ let req = await fromNodeRequest(nodeReq, nodeRes);
let res = await handler(
req,
await reactRouterDevLoadContext(req),
);
+ // Async import here to allow ESM only module on Node 20.18.
+ // TODO(v8): Can move to a normal import when Node 20 support
+ const { sendResponse } = await import(
+ "@remix-run/node-fetch-server"
+ );
await sendResponse(nodeRes, res);
};
await nodeHandler(req, res);
@@ -1717,11 +1721,17 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
nodeReq,
nodeRes,
) => {
- let req = fromNodeRequest(nodeReq, nodeRes);
+ let req = await fromNodeRequest(nodeReq, nodeRes);
let res = await handler(
req,
await reactRouterDevLoadContext(req),
);
+
+ // Async import here to allow ESM only module on Node 20.18.
+ // TODO(v8): Can move to a normal import when Node 20 support
+ const { sendResponse } = await import(
+ "@remix-run/node-fetch-server"
+ );
await sendResponse(nodeRes, res);
};
await nodeHandler(req, res);
diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md
index 9e0bf2bf30..81d6095714 100644
--- a/packages/react-router-dom/CHANGELOG.md
+++ b/packages/react-router-dom/CHANGELOG.md
@@ -1,5 +1,12 @@
# react-router-dom
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json
index 920164d623..1a09414bae 100644
--- a/packages/react-router-dom/package.json
+++ b/packages/react-router-dom/package.json
@@ -1,6 +1,6 @@
{
"name": "react-router-dom",
- "version": "7.12.0",
+ "version": "7.13.0",
"description": "Declarative routing for React web applications",
"keywords": [
"react",
diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md
index d7250df76a..51f4910283 100644
--- a/packages/react-router-express/CHANGELOG.md
+++ b/packages/react-router-express/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/express`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+ - `@react-router/node@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json
index 936a5fde4d..09630860f9 100644
--- a/packages/react-router-express/package.json
+++ b/packages/react-router-express/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/express",
- "version": "7.12.0",
+ "version": "7.13.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 31a6a7b2e0..d642f249aa 100644
--- a/packages/react-router-fs-routes/CHANGELOG.md
+++ b/packages/react-router-fs-routes/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/fs-routes`
+## 7.13.0
+
+### Patch Changes
+
+- Fix route file paths when routes directory is outside of the app directory ([#13937](https://github.com/remix-run/react-router/pull/13937))
+- Updated dependencies:
+ - `@react-router/dev@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-fs-routes/__tests__/flatRoutes-test.ts b/packages/react-router-fs-routes/__tests__/flatRoutes-test.ts
index 94a77f237b..964d20e27e 100644
--- a/packages/react-router-fs-routes/__tests__/flatRoutes-test.ts
+++ b/packages/react-router-fs-routes/__tests__/flatRoutes-test.ts
@@ -912,4 +912,24 @@ describe("flatRoutes", () => {
);
});
});
+
+ describe("generates route manifest entry files relative to the app directory", () => {
+ test("routes directory inside the app directory", () => {
+ let routeFile = path.posix.join(APP_DIR, "routes", "route.tsx");
+ let routeInfo = flatRoutesUniversal(APP_DIR, [routeFile]);
+ let routes = Object.values(routeInfo);
+
+ expect(routes).toHaveLength(1);
+ expect(routes[0].file).toBe("routes/route.tsx");
+ });
+
+ test("routes directory outside the app directory", () => {
+ let routeFile = path.posix.join(APP_DIR, "..", "routes", "route.tsx");
+ let routeInfo = flatRoutesUniversal(APP_DIR, [routeFile]);
+ let routes = Object.values(routeInfo);
+
+ expect(routes).toHaveLength(1);
+ expect(routes[0].file).toBe("../routes/route.tsx");
+ });
+ });
});
diff --git a/packages/react-router-fs-routes/flatRoutes.ts b/packages/react-router-fs-routes/flatRoutes.ts
index 8ab6c05f8f..ae13cfdd8a 100644
--- a/packages/react-router-fs-routes/flatRoutes.ts
+++ b/packages/react-router-fs-routes/flatRoutes.ts
@@ -134,6 +134,8 @@ export function flatRoutesUniversal(
let prefixLookup = new PrefixLookupTrie();
let uniqueRoutes = new Map();
let routeIdConflicts = new Map();
+ let normalizedApp = normalizeSlashes(appDirectory);
+ let appWithPrefix = path.posix.join(normalizedApp, prefix);
// id -> file
let routeIds = new Map();
@@ -142,9 +144,8 @@ export function flatRoutesUniversal(
let normalizedFile = normalizeSlashes(file);
let routeExt = path.extname(normalizedFile);
let routeDir = path.dirname(normalizedFile);
- let normalizedApp = normalizeSlashes(appDirectory);
let routeId =
- routeDir === path.posix.join(normalizedApp, prefix)
+ routeDir === appWithPrefix
? path.posix
.relative(normalizedApp, normalizedFile)
.slice(0, -routeExt.length)
@@ -174,7 +175,7 @@ export function flatRoutesUniversal(
let pathname = createRoutePath(segments, raw, index);
routeManifest[routeId] = {
- file: file.slice(appDirectory.length + 1),
+ file: path.posix.relative(normalizedApp, file),
id: routeId,
path: pathname,
};
diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json
index 74b41148f3..3633a96402 100644
--- a/packages/react-router-fs-routes/package.json
+++ b/packages/react-router-fs-routes/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/fs-routes",
- "version": "7.12.0",
+ "version": "7.13.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 1018e18450..ef50ac1399 100644
--- a/packages/react-router-node/CHANGELOG.md
+++ b/packages/react-router-node/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/node`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json
index 5ec3850006..51df995b12 100644
--- a/packages/react-router-node/package.json
+++ b/packages/react-router-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/node",
- "version": "7.12.0",
+ "version": "7.13.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 cf98ae6a22..73f6653d80 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`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@react-router/dev@7.13.0`
+
## 7.12.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 7155a102c7..b6ab5ebbd1 100644
--- a/packages/react-router-remix-routes-option-adapter/package.json
+++ b/packages/react-router-remix-routes-option-adapter/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/remix-routes-option-adapter",
- "version": "7.12.0",
+ "version": "7.13.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 bddf4f8296..847e0c00f5 100644
--- a/packages/react-router-serve/CHANGELOG.md
+++ b/packages/react-router-serve/CHANGELOG.md
@@ -1,5 +1,14 @@
# `@react-router/serve`
+## 7.13.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.13.0`
+ - `@react-router/node@7.13.0`
+ - `@react-router/express@7.13.0`
+
## 7.12.0
### Patch Changes
diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json
index 65b4089fe1..6f2d6a5db1 100644
--- a/packages/react-router-serve/package.json
+++ b/packages/react-router-serve/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-router/serve",
- "version": "7.12.0",
+ "version": "7.13.0",
"description": "Production application server for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md
index 3940384f29..29c08ea99b 100644
--- a/packages/react-router/CHANGELOG.md
+++ b/packages/react-router/CHANGELOG.md
@@ -1,5 +1,18 @@
# `react-router`
+## 7.13.0
+
+### Minor Changes
+
+- Add `crossOrigin` prop to `Links` component ([#14687](https://github.com/remix-run/react-router/pull/14687))
+
+### Patch Changes
+
+- Fix double slash normalization for useNavigate colon urls ([#14718](https://github.com/remix-run/react-router/pull/14718))
+- Update failed origin checks to return a 400 status instead of a 500 ([#14737](https://github.com/remix-run/react-router/pull/14737))
+- Bugfix #14666: Inline criticalCss is missing nonce ([#14691](https://github.com/remix-run/react-router/pull/14691))
+- Loosen `allowedActionOrigins` glob check so `**` matches all domains ([#14722](https://github.com/remix-run/react-router/pull/14722))
+
## 7.12.0
### Minor Changes
diff --git a/packages/react-router/__tests__/dom/ssr/components-test.tsx b/packages/react-router/__tests__/dom/ssr/components-test.tsx
index 194f159762..f13a6b561a 100644
--- a/packages/react-router/__tests__/dom/ssr/components-test.tsx
+++ b/packages/react-router/__tests__/dom/ssr/components-test.tsx
@@ -5,12 +5,21 @@ import * as React from "react";
import {
createMemoryRouter,
Link,
+ Links,
NavLink,
Outlet,
RouterProvider,
+ Scripts,
} from "../../../index";
import { HydratedRouter } from "../../../lib/dom-export/hydrated-router";
-import { FrameworkContext } from "../../../lib/dom/ssr/components";
+import {
+ FrameworkContext,
+ usePrefetchBehavior,
+} from "../../../lib/dom/ssr/components";
+import {
+ DataRouterContext,
+ DataRouterStateContext,
+} from "../../../lib/context";
import invariant from "../../../lib/dom/ssr/invariant";
import { ServerRouter } from "../../../lib/dom/ssr/server";
import "@testing-library/jest-dom";
@@ -283,3 +292,168 @@ describe("", () => {
expect(container.innerHTML).toMatch("Root
");
});
});
+
+describe("", () => {
+ it("renders critical css with nonce", () => {
+ let context = mockFrameworkContext({
+ criticalCss: ".critical { color: red; }",
+ });
+
+ let { container } = render(
+
+
+
+
+ ,
+ );
+
+ let style = container.querySelector("style");
+ expect(style).toHaveAttribute("data-react-router-critical-css");
+ expect(style).toHaveAttribute("nonce", "test-nonce");
+ expect(style).toHaveTextContent(".critical { color: red; }");
+ });
+
+ it("renders critical css object with nonce", () => {
+ let context = mockFrameworkContext({
+ criticalCss: { rel: "stylesheet", href: "/critical.css" },
+ });
+
+ let { container } = render(
+
+
+
+
+ ,
+ );
+
+ let link = container.querySelector("link[rel='stylesheet']");
+ expect(link).toHaveAttribute("data-react-router-critical-css");
+ expect(link).toHaveAttribute("href", "/critical.css");
+ expect(link).toHaveAttribute("nonce", "test-nonce");
+ });
+
+ it("propagates nonce to route links", () => {
+ let context = mockFrameworkContext({
+ routeModules: {
+ root: {
+ default: () => null,
+ links: () => [{ rel: "stylesheet", href: "/style.css" }],
+ },
+ },
+ manifest: {
+ routes: {
+ root: {
+ id: "root",
+ module: "root.js",
+ hasLoader: false,
+ hasAction: false,
+ hasErrorBoundary: false,
+ hasClientAction: false,
+ hasClientLoader: false,
+ hasClientMiddleware: false,
+ clientActionModule: undefined,
+ clientLoaderModule: undefined,
+ clientMiddlewareModule: undefined,
+ hydrateFallbackModule: undefined,
+ },
+ },
+ entry: { imports: [], module: "" },
+ url: "",
+ version: "",
+ },
+ });
+
+ let { container } = render(
+
+
+
+
+ ,
+ );
+
+ let link = container.querySelector("link[href='/style.css']");
+ expect(link).toHaveAttribute("nonce", "test-nonce");
+ });
+});
+
+describe("usePrefetchBehavior", () => {
+ function TestComponent({
+ prefetch,
+ }: {
+ prefetch: "intent" | "render" | "none" | "viewport";
+ }) {
+ let [shouldPrefetch, ref] = usePrefetchBehavior(prefetch, {});
+ return (
+
+ Link
+
+ );
+ }
+
+ it("handles prefetch='render'", () => {
+ let context = mockFrameworkContext({});
+
+ // Wrap in FrameworkContext because usePrefetchBehavior checks for it
+ let { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toHaveAttribute("data-prefetch", "true");
+ });
+
+ it("handles prefetch='viewport'", () => {
+ let context = mockFrameworkContext({});
+ let observeCallback: IntersectionObserverCallback;
+ let observeMock = jest.fn();
+ let disconnectMock = jest.fn();
+
+ window.IntersectionObserver = class {
+ constructor(cb: IntersectionObserverCallback) {
+ observeCallback = cb;
+ }
+ observe = observeMock;
+ unobserve = jest.fn();
+ disconnect = disconnectMock;
+ takeRecords = () => [];
+ root = null;
+ rootMargin = "";
+ thresholds = [];
+ };
+
+ let { container } = render(
+
+
+ ,
+ );
+
+ // Initial state
+ expect(container.firstChild).toHaveAttribute("data-prefetch", "false");
+ expect(observeMock).toHaveBeenCalled();
+
+ // Trigger intersection
+ act(() => {
+ observeCallback(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ new IntersectionObserver(() => {}),
+ );
+ });
+
+ expect(container.firstChild).toHaveAttribute("data-prefetch", "true");
+ });
+});
diff --git a/packages/react-router/__tests__/dom/ssr/links-test.tsx b/packages/react-router/__tests__/dom/ssr/links-test.tsx
new file mode 100644
index 0000000000..9c44f731b2
--- /dev/null
+++ b/packages/react-router/__tests__/dom/ssr/links-test.tsx
@@ -0,0 +1,173 @@
+import { render } from "@testing-library/react";
+import * as React from "react";
+
+import { Links, Outlet, createRoutesStub } from "../../../index";
+
+describe("", () => {
+ describe("crossOrigin", () => {
+ it("renders stylesheet links with crossOrigin attribute when provided", () => {
+ let RoutesStub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ links: () => [{ rel: "stylesheet", href: "/assets/styles.css" }],
+ Component() {
+ return (
+ <>
+
+
+ >
+ );
+ },
+ children: [
+ { id: "index", index: true, Component: () => Index
},
+ ],
+ },
+ ]);
+
+ let { container } = render();
+
+ let stylesheetLink = container.ownerDocument.querySelector(
+ 'link[rel="stylesheet"][href="/assets/styles.css"]',
+ );
+ expect(stylesheetLink).toBeTruthy();
+ expect(stylesheetLink?.getAttribute("crossorigin")).toBe("anonymous");
+ });
+
+ it("renders stylesheet links without crossOrigin when not provided", () => {
+ let RoutesStub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ links: () => [{ rel: "stylesheet", href: "/assets/styles.css" }],
+ Component() {
+ return (
+ <>
+
+
+ >
+ );
+ },
+ children: [
+ { id: "index", index: true, Component: () => Index
},
+ ],
+ },
+ ]);
+
+ let { container } = render();
+
+ let stylesheetLink = container.ownerDocument.querySelector(
+ 'link[rel="stylesheet"][href="/assets/styles.css"]',
+ );
+ expect(stylesheetLink).toBeTruthy();
+ expect(stylesheetLink?.hasAttribute("crossorigin")).toBe(false);
+ });
+
+ it("link descriptor crossOrigin overrides the component prop", () => {
+ let RoutesStub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ links: () => [
+ {
+ rel: "stylesheet",
+ href: "/assets/styles.css",
+ crossOrigin: "use-credentials",
+ },
+ ],
+ Component() {
+ return (
+ <>
+
+
+ >
+ );
+ },
+ children: [
+ { id: "index", index: true, Component: () => Index
},
+ ],
+ },
+ ]);
+
+ let { container } = render();
+
+ let stylesheetLink = container.ownerDocument.querySelector(
+ 'link[rel="stylesheet"][href="/assets/styles.css"]',
+ );
+ expect(stylesheetLink).toBeTruthy();
+ expect(stylesheetLink?.getAttribute("crossorigin")).toBe(
+ "use-credentials",
+ );
+ });
+
+ it("link descriptor crossOrigin works without the component prop", () => {
+ let RoutesStub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ links: () => [
+ {
+ rel: "stylesheet",
+ href: "/assets/styles.css",
+ crossOrigin: "anonymous",
+ },
+ ],
+ Component() {
+ return (
+ <>
+
+
+ >
+ );
+ },
+ children: [
+ { id: "index", index: true, Component: () => Index
},
+ ],
+ },
+ ]);
+
+ let { container } = render();
+
+ let stylesheetLink = container.ownerDocument.querySelector(
+ 'link[rel="stylesheet"][href="/assets/styles.css"]',
+ );
+ expect(stylesheetLink).toBeTruthy();
+ expect(stylesheetLink?.getAttribute("crossorigin")).toBe("anonymous");
+ });
+
+ it("link descriptor crossOrigin undefined does not override the component prop", () => {
+ let RoutesStub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ links: () => [
+ {
+ rel: "stylesheet",
+ href: "/assets/styles.css",
+ crossOrigin: undefined,
+ },
+ ],
+ Component() {
+ return (
+ <>
+
+
+ >
+ );
+ },
+ children: [
+ { id: "index", index: true, Component: () => Index
},
+ ],
+ },
+ ]);
+
+ let { container } = render();
+
+ let stylesheetLink = container.ownerDocument.querySelector(
+ 'link[rel="stylesheet"][href="/assets/styles.css"]',
+ );
+ expect(stylesheetLink).toBeTruthy();
+ expect(stylesheetLink?.getAttribute("crossorigin")).toBe("anonymous");
+ });
+ });
+});
diff --git a/packages/react-router/__tests__/resolvePath-test.tsx b/packages/react-router/__tests__/resolvePath-test.tsx
index 22719de070..6a28d83e41 100644
--- a/packages/react-router/__tests__/resolvePath-test.tsx
+++ b/packages/react-router/__tests__/resolvePath-test.tsx
@@ -1,22 +1,6 @@
import { resolvePath } from "react-router";
describe("resolvePath", () => {
- it("does not touch with protocol-less absolute paths", () => {
- expect(resolvePath("//google.com")).toMatchObject({
- pathname: "//google.com",
- });
-
- expect(resolvePath("//google.com/../../path")).toMatchObject({
- pathname: "//google.com/../../path",
- });
-
- expect(resolvePath("//google.com?q=query#hash")).toMatchObject({
- pathname: "//google.com",
- search: "?q=query",
- hash: "#hash",
- });
- });
-
it('resolves absolute paths irrespective of the "from" pathname', () => {
expect(resolvePath("/search", "/inbox")).toMatchObject({
pathname: "/search",
@@ -79,6 +63,32 @@ describe("resolvePath", () => {
spy.mockRestore();
});
+ it("handles relative paths with an embedded colon", () => {
+ expect(resolvePath("foo:bar", "/")).toMatchObject({
+ pathname: "/foo:bar",
+ });
+
+ expect(resolvePath("./foo:bar", "/")).toMatchObject({
+ pathname: "/foo:bar",
+ });
+
+ expect(resolvePath("../foo:bar", "/")).toMatchObject({
+ pathname: "/foo:bar",
+ });
+
+ expect(resolvePath("foo:bar", "/path")).toMatchObject({
+ pathname: "/path/foo:bar",
+ });
+
+ expect(resolvePath("./foo:bar", "/path")).toMatchObject({
+ pathname: "/path/foo:bar",
+ });
+
+ expect(resolvePath("../foo:bar", "/path")).toMatchObject({
+ pathname: "/foo:bar",
+ });
+ });
+
it('ignores trailing slashes on the "from" pathname when resolving relative paths', () => {
expect(resolvePath("../search", "/inbox/")).toMatchObject({
pathname: "/search",
diff --git a/packages/react-router/__tests__/server-runtime/actions-test.ts b/packages/react-router/__tests__/server-runtime/actions-test.ts
new file mode 100644
index 0000000000..e8dee06152
--- /dev/null
+++ b/packages/react-router/__tests__/server-runtime/actions-test.ts
@@ -0,0 +1,249 @@
+/**
+ * @jest-environment node
+ */
+
+import { throwIfPotentialCSRFAttack } from "../../lib/actions";
+
+describe("throwIfPotentialCSRFAttack", () => {
+ describe("when origin matches host", () => {
+ it("should not throw when origin matches host header", () => {
+ const headers = new Headers({
+ origin: "https://example.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should not throw when origin matches x-forwarded-host header", () => {
+ const headers = new Headers({
+ origin: "https://example.com",
+ "x-forwarded-host": "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should prefer x-forwarded-host over host header", () => {
+ const headers = new Headers({
+ origin: "https://example.com",
+ "x-forwarded-host": "example.com",
+ host: "different.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should use first value from comma-separated x-forwarded-host", () => {
+ const headers = new Headers({
+ origin: "https://example.com",
+ "x-forwarded-host": "example.com, other.com, another.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+ });
+
+ describe("when origin does not match host", () => {
+ it("should throw when origin does not match host header", () => {
+ const headers = new Headers({
+ origin: "https://untrusted.com",
+ host: "example.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should throw when origin does not match x-forwarded-host header", () => {
+ const headers = new Headers({
+ origin: "https://untrusted.com",
+ "x-forwarded-host": "example.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "x-forwarded-host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should throw when origin is present but host headers are missing", () => {
+ const headers = new Headers({
+ origin: "https://untrusted.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "`x-forwarded-host` or `host` headers are not provided",
+ );
+ });
+ });
+
+ describe("with allowed origins", () => {
+ it("should not throw when origin matches an allowed origin exactly", () => {
+ const headers = new Headers({
+ origin: "https://trusted.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["trusted.com"]),
+ ).not.toThrow();
+ });
+
+ it("should not throw when origin matches a wildcard pattern", () => {
+ const headers = new Headers({
+ origin: "https://sub.trusted.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["*.trusted.com"]),
+ ).not.toThrow();
+ });
+
+ it("should not throw when origin matches a multi-level wildcard pattern", () => {
+ const headers = new Headers({
+ origin: "https://sub.domain.trusted.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["**.trusted.com"]),
+ ).not.toThrow();
+ });
+
+ it("should throw when origin does not match any allowed origin", () => {
+ const headers = new Headers({
+ origin: "https://untrusted.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["trusted.com", "*.safe.com"]),
+ ).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should handle multiple allowed origins", () => {
+ const headers = new Headers({
+ origin: "https://partner2.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, [
+ "partner1.com",
+ "partner2.com",
+ "*.trusted.com",
+ ]),
+ ).not.toThrow();
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should not throw when origin is not present", () => {
+ const headers = new Headers({
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should throw when origin is null string without host", () => {
+ const headers = new Headers({
+ origin: "null",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "`x-forwarded-host` or `host` headers are not provided",
+ );
+ });
+
+ it("should handle origin with port number", () => {
+ const headers = new Headers({
+ origin: "https://example.com:8080",
+ host: "example.com:8080",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should throw when origin port differs from host port", () => {
+ const headers = new Headers({
+ origin: "https://example.com:8080",
+ host: "example.com:3000",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should handle x-forwarded-host with whitespace", () => {
+ const headers = new Headers({
+ origin: "https://example.com",
+ "x-forwarded-host": " example.com ",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should ignore empty string in allowed origins", () => {
+ const headers = new Headers({
+ origin: "https://different.com",
+ host: "example.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["", "other.com"]),
+ ).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should throw when origin is null string but has matching host", () => {
+ const headers = new Headers({
+ origin: "null",
+ host: "null",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, undefined),
+ ).not.toThrow();
+ });
+
+ it("should handle subdomain in origin vs base domain in host", () => {
+ const headers = new Headers({
+ origin: "https://api.example.com",
+ host: "example.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("should not throw when wildcard allows subdomain", () => {
+ const headers = new Headers({
+ origin: "https://api.example.com",
+ host: "main.com",
+ });
+ expect(() =>
+ throwIfPotentialCSRFAttack(headers, ["*.example.com"]),
+ ).not.toThrow();
+ });
+
+ it("should throw on * wildcard patterns because they only match one segment", () => {
+ const headers = new Headers({
+ origin: "https://different.com",
+ host: "example.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, ["*"])).toThrow(
+ "host header does not match `origin` header from a forwarded action request",
+ );
+ });
+
+ it("** should match anything", () => {
+ const headers = new Headers({
+ origin: "https://different.com",
+ host: "example.com",
+ });
+ expect(() => throwIfPotentialCSRFAttack(headers, ["**"])).not.toThrow();
+ });
+ });
+});
diff --git a/packages/react-router/lib/actions.ts b/packages/react-router/lib/actions.ts
index 0118cbc2fb..5d0aa53966 100644
--- a/packages/react-router/lib/actions.ts
+++ b/packages/react-router/lib/actions.ts
@@ -44,15 +44,6 @@ function matchWildcardDomain(domain: string, pattern: string) {
return false;
}
- // Prevent wildcards from matching entire domains (e.g. '**' or '*.com')
- // This ensures wildcards can only match subdomains, not the main domain
- if (
- patternParts.length === 1 &&
- (patternParts[0] === "*" || patternParts[0] === "**")
- ) {
- return false;
- }
-
while (patternParts.length) {
const patternPart = patternParts.pop();
const domainPart = domainParts.pop();
diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx
index 48fb544fb3..2e0f101511 100644
--- a/packages/react-router/lib/dom/ssr/components.tsx
+++ b/packages/react-router/lib/dom/ssr/components.tsx
@@ -227,6 +227,12 @@ export interface LinksProps {
* element
*/
nonce?: string | undefined;
+ /**
+ * A [`crossOrigin`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)
+ * attribute to render on the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
+ * element
+ */
+ crossOrigin?: "anonymous" | "use-credentials";
}
/**
@@ -254,10 +260,11 @@ export interface LinksProps {
* @mode framework
* @param props Props
* @param {LinksProps.nonce} props.nonce n/a
+ * @param {LinksProps.crossOrigin} props.crossOrigin n/a
* @returns A collection of React elements for [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
* tags
*/
-export function Links({ nonce }: LinksProps): React.JSX.Element {
+export function Links({ nonce, crossOrigin }: LinksProps): React.JSX.Element {
let { isSpaMode, manifest, routeModules, criticalCss } =
useFrameworkContext();
let { errors, matches: routerMatches } = useDataRouterStateContext();
@@ -274,6 +281,7 @@ export function Links({ nonce }: LinksProps): React.JSX.Element {
{typeof criticalCss === "string" ? (
) : null}
@@ -283,13 +291,24 @@ export function Links({ nonce }: LinksProps): React.JSX.Element {
rel="stylesheet"
href={criticalCss.href}
nonce={nonce}
+ crossOrigin={crossOrigin}
/>
) : null}
{keyedLinks.map(({ key, link }) =>
isPageLinkDescriptor(link) ? (
-
+
) : (
-
+
),
)}
>
@@ -487,7 +506,12 @@ function PrefetchPageLinksImpl({
{keyedPrefetchLinks.map(({ key, link }) => (
// these don't spread `linkProps` because they are full link descriptors
// already with their own props
-
+
))}
>
);
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index 4a4a79593e..41cc4be463 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -1603,23 +1603,11 @@ export function resolvePath(to: To, fromPathname = "/"): Path {
let pathname: string;
if (toPathname) {
- if (isAbsoluteUrl(toPathname)) {
- pathname = toPathname;
+ toPathname = toPathname.replace(/\/\/+/g, "/");
+ if (toPathname.startsWith("/")) {
+ pathname = resolvePathname(toPathname.substring(1), "/");
} else {
- if (toPathname.includes("//")) {
- let oldPathname = toPathname;
- toPathname = toPathname.replace(/\/\/+/g, "/");
- warning(
- false,
- `Pathnames cannot have embedded double slashes - normalizing ` +
- `${oldPathname} -> ${toPathname}`,
- );
- }
- if (toPathname.startsWith("/")) {
- pathname = resolvePathname(toPathname.substring(1), "/");
- } else {
- pathname = resolvePathname(toPathname, fromPathname);
- }
+ pathname = resolvePathname(toPathname, fromPathname);
}
} else {
pathname = fromPathname;
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index 14d1c8b4e5..ea1df0ddad 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -483,12 +483,17 @@ async function handleDocumentRequest(
) {
try {
if (request.method === "POST") {
- throwIfPotentialCSRFAttack(
- request.headers,
- Array.isArray(build.allowedActionOrigins)
- ? build.allowedActionOrigins
- : [],
- );
+ try {
+ throwIfPotentialCSRFAttack(
+ request.headers,
+ Array.isArray(build.allowedActionOrigins)
+ ? build.allowedActionOrigins
+ : [],
+ );
+ } catch (e) {
+ handleError(e);
+ return new Response("Bad Request", { status: 400 });
+ }
}
let result = await staticHandler.query(request, {
requestContext: loadContext,
diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts
index 944afa948b..9785c5176e 100644
--- a/packages/react-router/lib/server-runtime/single-fetch.ts
+++ b/packages/react-router/lib/server-runtime/single-fetch.ts
@@ -43,12 +43,16 @@ export async function singleFetchAction(
handleError: (err: unknown) => void,
): Promise {
try {
- throwIfPotentialCSRFAttack(
- request.headers,
- Array.isArray(build.allowedActionOrigins)
- ? build.allowedActionOrigins
- : [],
- );
+ try {
+ throwIfPotentialCSRFAttack(
+ request.headers,
+ Array.isArray(build.allowedActionOrigins)
+ ? build.allowedActionOrigins
+ : [],
+ );
+ } catch (e) {
+ return handleQueryError(new Error("Bad Request"), 400);
+ }
let handlerRequest = new Request(handlerUrl, {
method: request.method,
@@ -85,13 +89,13 @@ export async function singleFetchAction(
return isResponse(result) ? result : staticContextToResponse(result);
}
- function handleQueryError(error: unknown) {
+ function handleQueryError(error: unknown, status = 500) {
handleError(error);
// These should only be internal remix errors, no need to deal with responseStubs
return generateSingleFetchResponse(request, build, serverMode, {
result: { error },
headers: new Headers(),
- status: 500,
+ status,
});
}
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 78ce38ef90..edfe9a3599 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -1,6 +1,6 @@
{
"name": "react-router",
- "version": "7.12.0",
+ "version": "7.13.0",
"description": "Declarative routing for React",
"keywords": [
"react",
diff --git a/playground/data/index.html b/playground/data/index.html
new file mode 100644
index 0000000000..7e2cc201cf
--- /dev/null
+++ b/playground/data/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React Router (data mode)
+
+
+
+
+
+
diff --git a/playground/data/package.json b/playground/data/package.json
new file mode 100644
index 0000000000..d8a264ec88
--- /dev/null
+++ b/playground/data/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@playground/data",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "catalog:",
+ "react-dom": "catalog:",
+ "react-router": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "^4",
+ "typescript": "catalog:",
+ "vite": "6.4.1"
+ }
+}
diff --git a/playground/data/src/main.tsx b/playground/data/src/main.tsx
new file mode 100644
index 0000000000..7dd9a9387c
--- /dev/null
+++ b/playground/data/src/main.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import * as ReactClient from "react-dom/client";
+import { createBrowserRouter, useLoaderData } from "react-router";
+import { RouterProvider } from "react-router/dom";
+
+const router = createBrowserRouter([
+ {
+ id: "index",
+ path: "/",
+ loader() {
+ return { message: "Hello React Router!" };
+ },
+ Component() {
+ let data = useLoaderData();
+ return {data.message}
;
+ },
+ },
+]);
+
+ReactClient.createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/playground/data/tsconfig.json b/playground/data/tsconfig.json
new file mode 100644
index 0000000000..ee47d3b35f
--- /dev/null
+++ b/playground/data/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "jsx": "react-jsx",
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": true,
+ "esModuleInterop": true,
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ }
+}
diff --git a/playground/data/vite.config.ts b/playground/data/vite.config.ts
new file mode 100644
index 0000000000..0e43ae8def
--- /dev/null
+++ b/playground/data/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9a9419e03b..a4ef96b1bc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -147,7 +147,7 @@ importers:
version: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks:
specifier: next
- version: 7.1.0-canary-b061b597-20251212(eslint@8.57.0)
+ version: 7.1.0-canary-d6cae440-20260106(eslint@8.57.0)
fast-glob:
specifier: 3.2.11
version: 3.2.11
@@ -1027,8 +1027,8 @@ importers:
specifier: workspace:*
version: link:../react-router-node
'@remix-run/node-fetch-server':
- specifier: ^0.9.0
- version: 0.9.0
+ specifier: ^0.13.0
+ version: 0.13.0
arg:
specifier: ^5.0.1
version: 5.0.2
@@ -1324,6 +1324,34 @@ importers:
specifier: 'catalog:'
version: 0.14.9
+ playground/data:
+ dependencies:
+ react:
+ specifier: 'catalog:'
+ version: 19.2.3
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.3(react@19.2.3)
+ react-router:
+ specifier: workspace:*
+ version: link:../../packages/react-router
+ devDependencies:
+ '@types/react':
+ specifier: ^18.0.27
+ version: 18.2.18
+ '@types/react-dom':
+ specifier: ^18.0.10
+ version: 18.2.7
+ '@vitejs/plugin-react':
+ specifier: ^4
+ version: 4.5.2(vite@6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0))
+ typescript:
+ specifier: 'catalog:'
+ version: 5.4.5
+ vite:
+ specifier: 6.4.1
+ version: 6.4.1(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.0)
+
playground/framework:
dependencies:
'@react-router/node':
@@ -3567,8 +3595,8 @@ packages:
'@remix-run/node-fetch-server@0.12.0':
resolution: {integrity: sha512-oeg8w8aJJSuq1fCx85jCkcgTfI6On7sKwWVSO4/OW5AvTBuosAIwnuBd/LYeU/I7lYPOTW2NXhUfyfpyeexs4w==}
- '@remix-run/node-fetch-server@0.9.0':
- resolution: {integrity: sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==}
+ '@remix-run/node-fetch-server@0.13.0':
+ resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==}
'@remix-run/web-blob@3.1.0':
resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==}
@@ -5587,8 +5615,8 @@ packages:
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
- eslint-plugin-react-hooks@7.1.0-canary-b061b597-20251212:
- resolution: {integrity: sha512-tSQTLvkBVbN4rZZZjGIDeUVFgNrY8/hqU6mteRetyCuLYCP92r0x8hetyshU1jInpMQxeJitpOox8A2uMAIrrg==}
+ eslint-plugin-react-hooks@7.1.0-canary-d6cae440-20260106:
+ resolution: {integrity: sha512-uIHYvMstCMzczNT658m/AvxiHZA77RIyU8vZpyixi8YfZXiPLIaKZEhqt8ce3OLwnaqISYoyPtkpxPtk6Z5Ydw==}
engines: {node: '>=18'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@@ -9064,10 +9092,12 @@ packages:
whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -11121,7 +11151,7 @@ snapshots:
'@remix-run/node-fetch-server@0.12.0': {}
- '@remix-run/node-fetch-server@0.9.0': {}
+ '@remix-run/node-fetch-server@0.13.0': {}
'@remix-run/web-blob@3.1.0':
dependencies:
@@ -13605,7 +13635,7 @@ snapshots:
dependencies:
eslint: 8.57.0
- eslint-plugin-react-hooks@7.1.0-canary-b061b597-20251212(eslint@8.57.0):
+ eslint-plugin-react-hooks@7.1.0-canary-d6cae440-20260106(eslint@8.57.0):
dependencies:
'@babel/core': 7.27.7
'@babel/parser': 7.27.7