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" ? (