diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb493de5b..59d7402d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,121 +13,125 @@ 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) + - [Minor Changes](#minor-changes) + - [Patch Changes](#patch-changes) + - [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) - - [Patch Changes](#patch-changes) - - [Unstable Changes](#unstable-changes) - - [v7.10.1](#v7101) + - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-1) + - [Unstable Changes](#unstable-changes-1) + - [v7.10.1](#v7101) + - [Patch Changes](#patch-changes-2) - [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-1) - - [Patch Changes](#patch-changes-2) - - [Unstable Changes](#unstable-changes-1) - - [v7.9.6](#v796) + - [Minor Changes](#minor-changes-2) - [Patch Changes](#patch-changes-3) - [Unstable Changes](#unstable-changes-2) + - [v7.9.6](#v796) + - [Patch Changes](#patch-changes-4) + - [Unstable Changes](#unstable-changes-3) - [v7.9.5](#v795) - [What's Changed](#whats-changed-2) - [Instrumentation (unstable)](#instrumentation-unstable) - - [Patch Changes](#patch-changes-4) - - [Unstable Changes](#unstable-changes-3) + - [Patch Changes](#patch-changes-5) + - [Unstable Changes](#unstable-changes-4) - [v7.9.4](#v794) - [What's Changed](#whats-changed-3) - [`useRoute()` (unstable)](#useroute-unstable) - - [Patch Changes](#patch-changes-5) - - [Unstable Changes](#unstable-changes-4) - - [v7.9.3](#v793) - [Patch Changes](#patch-changes-6) + - [Unstable Changes](#unstable-changes-5) + - [v7.9.3](#v793) + - [Patch Changes](#patch-changes-7) - [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-7) - - [Unstable Changes](#unstable-changes-5) - - [v7.9.1](#v791) - [Patch Changes](#patch-changes-8) + - [Unstable Changes](#unstable-changes-6) + - [v7.9.1](#v791) + - [Patch Changes](#patch-changes-9) - [v7.9.0](#v790) - [What's Changed](#whats-changed-5) - [Stable Middleware and Context APIs](#stable-middleware-and-context-apis) - - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-9) - - [Unstable Changes](#unstable-changes-6) - - [v7.8.2](#v782) + - [Minor Changes](#minor-changes-3) - [Patch Changes](#patch-changes-10) - [Unstable Changes](#unstable-changes-7) - - [v7.8.1](#v781) + - [v7.8.2](#v782) - [Patch Changes](#patch-changes-11) - [Unstable Changes](#unstable-changes-8) + - [v7.8.1](#v781) + - [Patch Changes](#patch-changes-12) + - [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-3) - - [Patch Changes](#patch-changes-12) - - [Unstable Changes](#unstable-changes-9) - - [Changes by Package](#changes-by-package) - - [v7.7.1](#v771) + - [Minor Changes](#minor-changes-4) - [Patch Changes](#patch-changes-13) - [Unstable Changes](#unstable-changes-10) + - [Changes by Package](#changes-by-package) + - [v7.7.1](#v771) + - [Patch Changes](#patch-changes-14) + - [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-4) - - [Patch Changes](#patch-changes-14) - - [Unstable Changes](#unstable-changes-11) + - [Minor Changes](#minor-changes-5) + - [Patch Changes](#patch-changes-15) + - [Unstable Changes](#unstable-changes-12) - [Changes by Package](#changes-by-package-1) - [v7.6.3](#v763) - - [Patch Changes](#patch-changes-15) - - [v7.6.2](#v762) - [Patch Changes](#patch-changes-16) - - [v7.6.1](#v761) + - [v7.6.2](#v762) - [Patch Changes](#patch-changes-17) - - [Unstable Changes](#unstable-changes-12) + - [v7.6.1](#v761) + - [Patch Changes](#patch-changes-18) + - [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-5) - - [Patch Changes](#patch-changes-18) - - [Unstable Changes](#unstable-changes-13) + - [Minor Changes](#minor-changes-6) + - [Patch Changes](#patch-changes-19) + - [Unstable Changes](#unstable-changes-14) - [Changes by Package](#changes-by-package-2) - [v7.5.3](#v753) - - [Patch Changes](#patch-changes-19) + - [Patch Changes](#patch-changes-20) - [v7.5.2](#v752) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-20) - - [v7.5.1](#v751) - [Patch Changes](#patch-changes-21) - - [Unstable Changes](#unstable-changes-14) + - [v7.5.1](#v751) + - [Patch Changes](#patch-changes-22) + - [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-6) - - [Patch Changes](#patch-changes-22) - - [Unstable Changes](#unstable-changes-15) + - [Minor Changes](#minor-changes-7) + - [Patch Changes](#patch-changes-23) + - [Unstable Changes](#unstable-changes-16) - [Changes by Package](#changes-by-package-3) - [v7.4.1](#v741) - [Security Notice](#security-notice-1) - - [Patch Changes](#patch-changes-23) - - [Unstable Changes](#unstable-changes-16) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-7) - [Patch Changes](#patch-changes-24) - [Unstable Changes](#unstable-changes-17) - - [Changes by Package](#changes-by-package-4) - - [v7.3.0](#v730) + - [v7.4.0](#v740) - [Minor Changes](#minor-changes-8) - [Patch Changes](#patch-changes-25) - [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) + - [Unstable Changes](#unstable-changes-19) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) @@ -138,29 +142,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-9) - - [Patch Changes](#patch-changes-26) - - [Unstable Changes](#unstable-changes-19) + - [Minor Changes](#minor-changes-10) + - [Patch Changes](#patch-changes-27) + - [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-27) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-28) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-29) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-30) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-31) - - [v7.1.0](#v710) - - [Minor Changes](#minor-changes-10) + - [v7.1.1](#v711) - [Patch Changes](#patch-changes-32) + - [v7.1.0](#v710) + - [Minor Changes](#minor-changes-11) + - [Patch Changes](#patch-changes-33) - [Changes by Package](#changes-by-package-7) - [v7.0.2](#v702) - - [Patch Changes](#patch-changes-33) - - [v7.0.1](#v701) - [Patch Changes](#patch-changes-34) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-35) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -176,204 +180,206 @@ 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-11) - - [Patch Changes](#patch-changes-35) + - [Minor Changes](#minor-changes-12) + - [Patch Changes](#patch-changes-36) - [Changes by Package](#changes-by-package-8) - [React Router v6 Releases](#react-router-v6-releases) + - [v6.30.3](#v6303) + - [Patch Changes](#patch-changes-37) - [v6.30.2](#v6302) - - [Patch Changes](#patch-changes-36) + - [Patch Changes](#patch-changes-38) - [v6.30.1](#v6301) - - [Patch Changes](#patch-changes-37) + - [Patch Changes](#patch-changes-39) - [v6.30.0](#v6300) - - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-38) - - [v6.29.0](#v6290) - [Minor Changes](#minor-changes-13) - - [Patch Changes](#patch-changes-39) - - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-40) - - [v6.28.1](#v6281) + - [v6.29.0](#v6290) + - [Minor Changes](#minor-changes-14) - [Patch Changes](#patch-changes-41) + - [v6.28.2](#v6282) + - [Patch Changes](#patch-changes-42) + - [v6.28.1](#v6281) + - [Patch Changes](#patch-changes-43) - [v6.28.0](#v6280) - [What's Changed](#whats-changed-11) - - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-42) + - [Minor Changes](#minor-changes-15) + - [Patch Changes](#patch-changes-44) - [v6.27.0](#v6270) - [What's Changed](#whats-changed-12) - [Stabilized APIs](#stabilized-apis) - - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-43) + - [Minor Changes](#minor-changes-16) + - [Patch Changes](#patch-changes-45) - [v6.26.2](#v6262) - - [Patch Changes](#patch-changes-44) + - [Patch Changes](#patch-changes-46) - [v6.26.1](#v6261) - - [Patch Changes](#patch-changes-45) + - [Patch Changes](#patch-changes-47) - [v6.26.0](#v6260) - - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-46) + - [Minor Changes](#minor-changes-17) + - [Patch Changes](#patch-changes-48) - [v6.25.1](#v6251) - - [Patch Changes](#patch-changes-47) + - [Patch Changes](#patch-changes-49) - [v6.25.0](#v6250) - [What's Changed](#whats-changed-13) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - - [Minor Changes](#minor-changes-17) - - [Patch Changes](#patch-changes-48) + - [Minor Changes](#minor-changes-18) + - [Patch Changes](#patch-changes-50) - [v6.24.1](#v6241) - - [Patch Changes](#patch-changes-49) + - [Patch Changes](#patch-changes-51) - [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-18) - - [Patch Changes](#patch-changes-50) + - [Minor Changes](#minor-changes-19) + - [Patch Changes](#patch-changes-52) - [v6.23.1](#v6231) - - [Patch Changes](#patch-changes-51) + - [Patch Changes](#patch-changes-53) - [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-19) + - [Minor Changes](#minor-changes-20) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-52) + - [Patch Changes](#patch-changes-54) - [v6.22.2](#v6222) - - [Patch Changes](#patch-changes-53) + - [Patch Changes](#patch-changes-55) - [v6.22.1](#v6221) - - [Patch Changes](#patch-changes-54) + - [Patch Changes](#patch-changes-56) - [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-20) - - [Patch Changes](#patch-changes-55) + - [Minor Changes](#minor-changes-21) + - [Patch Changes](#patch-changes-57) - [v6.21.3](#v6213) - - [Patch Changes](#patch-changes-56) + - [Patch Changes](#patch-changes-58) - [v6.21.2](#v6212) - - [Patch Changes](#patch-changes-57) + - [Patch Changes](#patch-changes-59) - [v6.21.1](#v6211) - - [Patch Changes](#patch-changes-58) + - [Patch Changes](#patch-changes-60) - [v6.21.0](#v6210) - [What's Changed](#whats-changed-17) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - - [Minor Changes](#minor-changes-21) - - [Patch Changes](#patch-changes-59) - - [v6.20.1](#v6201) - - [Patch Changes](#patch-changes-60) - - [v6.20.0](#v6200) - [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-63) - [v6.19.0](#v6190) - [What's Changed](#whats-changed-18) - [`unstable_flushSync` API](#unstable_flushsync-api) - - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-62) + - [Minor Changes](#minor-changes-24) + - [Patch Changes](#patch-changes-64) - [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-24) - - [Patch Changes](#patch-changes-63) + - [Minor Changes](#minor-changes-25) + - [Patch Changes](#patch-changes-65) - [v6.17.0](#v6170) - [What's Changed](#whats-changed-20) - [View Transitions 🚀](#view-transitions-) - - [Minor Changes](#minor-changes-25) - - [Patch Changes](#patch-changes-64) - - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-26) - - [Patch Changes](#patch-changes-65) - - [v6.15.0](#v6150) - - [Minor Changes](#minor-changes-27) - [Patch Changes](#patch-changes-66) - - [v6.14.2](#v6142) + - [v6.16.0](#v6160) + - [Minor Changes](#minor-changes-27) - [Patch Changes](#patch-changes-67) - - [v6.14.1](#v6141) + - [v6.15.0](#v6150) + - [Minor Changes](#minor-changes-28) - [Patch Changes](#patch-changes-68) + - [v6.14.2](#v6142) + - [Patch Changes](#patch-changes-69) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-70) - [v6.14.0](#v6140) - [What's Changed](#whats-changed-21) - [JSON/Text Submissions](#jsontext-submissions) - - [Minor Changes](#minor-changes-28) - - [Patch Changes](#patch-changes-69) + - [Minor Changes](#minor-changes-29) + - [Patch Changes](#patch-changes-71) - [v6.13.0](#v6130) - [What's Changed](#whats-changed-22) - [`future.v7_startTransition`](#futurev7_starttransition) - - [Minor Changes](#minor-changes-29) - - [Patch Changes](#patch-changes-70) + - [Minor Changes](#minor-changes-30) + - [Patch Changes](#patch-changes-72) - [v6.12.1](#v6121) - - [Patch Changes](#patch-changes-71) + - [Patch Changes](#patch-changes-73) - [v6.12.0](#v6120) - [What's Changed](#whats-changed-23) - [`React.startTransition` support](#reactstarttransition-support) - - [Minor Changes](#minor-changes-30) - - [Patch Changes](#patch-changes-72) + - [Minor Changes](#minor-changes-31) + - [Patch Changes](#patch-changes-74) - [v6.11.2](#v6112) - - [Patch Changes](#patch-changes-73) + - [Patch Changes](#patch-changes-75) - [v6.11.1](#v6111) - - [Patch Changes](#patch-changes-74) + - [Patch Changes](#patch-changes-76) - [v6.11.0](#v6110) - - [Minor Changes](#minor-changes-31) - - [Patch Changes](#patch-changes-75) + - [Minor Changes](#minor-changes-32) + - [Patch Changes](#patch-changes-77) - [v6.10.0](#v6100) - [What's Changed](#whats-changed-24) - - [Minor Changes](#minor-changes-32) + - [Minor Changes](#minor-changes-33) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-76) + - [Patch Changes](#patch-changes-78) - [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-33) - - [Patch Changes](#patch-changes-77) + - [Minor Changes](#minor-changes-34) + - [Patch Changes](#patch-changes-79) - [v6.8.2](#v682) - - [Patch Changes](#patch-changes-78) + - [Patch Changes](#patch-changes-80) - [v6.8.1](#v681) - - [Patch Changes](#patch-changes-79) + - [Patch Changes](#patch-changes-81) - [v6.8.0](#v680) - - [Minor Changes](#minor-changes-34) - - [Patch Changes](#patch-changes-80) - - [v6.7.0](#v670) - [Minor Changes](#minor-changes-35) - - [Patch Changes](#patch-changes-81) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-82) - - [v6.6.1](#v661) + - [v6.7.0](#v670) + - [Minor Changes](#minor-changes-36) - [Patch Changes](#patch-changes-83) + - [v6.6.2](#v662) + - [Patch Changes](#patch-changes-84) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-85) - [v6.6.0](#v660) - [What's Changed](#whats-changed-26) - - [Minor Changes](#minor-changes-36) - - [Patch Changes](#patch-changes-84) + - [Minor Changes](#minor-changes-37) + - [Patch Changes](#patch-changes-86) - [v6.5.0](#v650) - [What's Changed](#whats-changed-27) - - [Minor Changes](#minor-changes-37) - - [Patch Changes](#patch-changes-85) + - [Minor Changes](#minor-changes-38) + - [Patch Changes](#patch-changes-87) - [v6.4.5](#v645) - - [Patch Changes](#patch-changes-86) + - [Patch Changes](#patch-changes-88) - [v6.4.4](#v644) - - [Patch Changes](#patch-changes-87) + - [Patch Changes](#patch-changes-89) - [v6.4.3](#v643) - - [Patch Changes](#patch-changes-88) + - [Patch Changes](#patch-changes-90) - [v6.4.2](#v642) - - [Patch Changes](#patch-changes-89) + - [Patch Changes](#patch-changes-91) - [v6.4.1](#v641) - - [Patch Changes](#patch-changes-90) + - [Patch Changes](#patch-changes-92) - [v6.4.0](#v640) - [What's Changed](#whats-changed-28) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-91) - - [v6.3.0](#v630) - - [Minor Changes](#minor-changes-38) - - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-92) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-93) - - [v6.2.0](#v620) + - [v6.3.0](#v630) - [Minor Changes](#minor-changes-39) + - [v6.2.2](#v622) - [Patch Changes](#patch-changes-94) - - [v6.1.1](#v611) + - [v6.2.1](#v621) - [Patch Changes](#patch-changes-95) - - [v6.1.0](#v610) + - [v6.2.0](#v620) - [Minor Changes](#minor-changes-40) - [Patch Changes](#patch-changes-96) - - [v6.0.2](#v602) + - [v6.1.1](#v611) - [Patch Changes](#patch-changes-97) - - [v6.0.1](#v601) + - [v6.1.0](#v610) + - [Minor Changes](#minor-changes-41) - [Patch Changes](#patch-changes-98) + - [v6.0.2](#v602) + - [Patch Changes](#patch-changes-99) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-100) - [v6.0.0](#v600) @@ -401,6 +407,61 @@ 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.12.0 + +Date: 2026-01-07 + +### Minor Changes + +- `react-router` - Add additional layer of CSRF protection by rejecting submissions to UI routes from external origins ([#14708](https://github.com/remix-run/react-router/pull/14708)) + - If you need to permit access to specific external origins, there is a new `allowedActionOrigins` config field in `react-router.config.ts` where you can specify external origins + +### Patch Changes + +- `react-router` - Fix `generatePath` when used with suffixed params (i.e., `/books/:id.json`) ([#14269](https://github.com/remix-run/react-router/pull/14269)) +- `react-router` - Escape HTML in scroll restoration keys ([#14705](https://github.com/remix-run/react-router/pull/14705)) +- `react-router` - Validate redirect locations ([#14706](https://github.com/remix-run/react-router/pull/14706)) +- `@react-router/dev` - Fix `Maximum call stack size exceeded` errors when HMR is triggered against code with cyclic imports ([#14522](https://github.com/remix-run/react-router/pull/14522)) +- `@react-router/dev` - Skip SSR middleware in `vite preview` server for SPA mode ([#14673](https://github.com/remix-run/react-router/pull/14673)) + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - Preserve `clientLoader.hydrate=true` when using `` ([#14674](https://github.com/remix-run/react-router/pull/14674)) +- `react-router` - Pass `` value through to the underlying `importmap` `script` tag when using `future.unstable_subResourceIntegrity` ([#14675](https://github.com/remix-run/react-router/pull/14675)) +- `react-router` - Export `UNSAFE_createMemoryHistory` and `UNSAFE_createHashHistory` alongside `UNSAFE_createBrowserHistory` for consistency ([#14663](https://github.com/remix-run/react-router/pull/14663)) + - These are not intended to be used for new apps but intended to help apps using `unstable_HistoryRouter` migrate from v6->v7 so they can adopt the newer APIs +- `@react-router/dev` - Add a new `future.unstable_trailingSlashAwareDataRequests` flag to provide consistent behavior of `request.pathname` inside `middleware`, `loader`, and `action` functions on document and data requests when a trailing slash is present in the browser URL. ([#14644](https://github.com/remix-run/react-router/pull/14644)) + - Currently, your HTTP and `request` pathnames would be as follows for `/a/b/c` and `/a/b/c/` + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname`** | + | ------------ | ----------------- | ----------------------- | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname`** | + | ------------- | ----------------- | ----------------------- | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | + + - With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname`** | + | ------------ | ----------------- | ----------------------- | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname`** | + | ------------- | ------------------ | ----------------------- | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + + - This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic + - Enabling this flag also changes the format of client side `.data` requests from `/_root.data` to `/_.data` when navigating to `/` to align with the new format - This does not impact the `request` pathname which is still `/` in all cases + +**Full Changelog**: [`v7.11.0...v7.12.0`](https://github.com/remix-run/react-router/compare/react-router@7.11.0...react-router@7.12.0) + ## v7.11.0 Date: 2025-12-17 @@ -2899,6 +2960,16 @@ async function fakeGetSlugsFromCms() { # React Router v6 Releases +## v6.30.3 + +Date: 2026-01-07 + +### Patch Changes + +- Validate redirect locations ([#14707](https://github.com/remix-run/react-router/pull/14707)) + +**Full Changelog**: [`v6.30.2...v6.30.3`](https://github.com/remix-run/react-router/compare/react-router@6.30.2...react-router@6.30.3) + ## v6.30.2 Date: 2025-11-13 diff --git a/contributors.yml b/contributors.yml index 5f4b31241c..38278eb0c9 100644 --- a/contributors.yml +++ b/contributors.yml @@ -29,6 +29,7 @@ - amsal - Andarist - andreasottosson-polestar +- andreiborza - andreiduca - antonmontrezor - appden @@ -55,6 +56,7 @@ - bilalk711 - bjohn465 - black5box +- BlankParticle - bmsuseluda - bobziroll - bravo-kernel @@ -105,6 +107,7 @@ - dgrijuela - DigitalNaut - DimaAmega +- dimmageiras - dmitrytarassov - dogxii - dokeet @@ -338,6 +341,7 @@ - renyu-io - reyronald - RFCreate +- richardkall - richardscarrott - rifaidev - rimian diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index a3938ea43c..bceee0d01c 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -149,7 +149,7 @@ test.describe("Client Data", () => { templateName, files: { "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": js` import { Form, Outlet, Scripts } from "react-router" diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 9ad644d43b..16c6051780 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -23,42 +23,23 @@ const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.resolve(__dirname, "../.."); const TMP_DIR = path.join(root, ".tmp/integration"); -export const reactRouterConfig = ({ - ssr, - basename, - prerender, - appDirectory, - v8_middleware, - v8_splitRouteModules, - v8_viteEnvironmentApi, - routeDiscovery, -}: { - ssr?: boolean; - basename?: string; - prerender?: boolean | string[]; - appDirectory?: string; - v8_middleware?: boolean; - v8_splitRouteModules?: NonNullable["v8_splitRouteModules"]; - v8_viteEnvironmentApi?: boolean; - routeDiscovery?: Config["routeDiscovery"]; -}) => { - let config: Config = { - ssr, - basename, - prerender, - appDirectory, - routeDiscovery, - future: { - v8_middleware, - v8_splitRouteModules, - v8_viteEnvironmentApi, - }, - }; +export const reactRouterConfig = ( + // Don't support function configs due to JSON.stringify() + config: Omit, "buildEnd" | "presets" | "serverBundles">, +) => { + if ( + typeof config.prerender === "function" || + (typeof config.prerender === "object" && + !Array.isArray(config.prerender) && + typeof config.prerender.paths === "function") + ) { + throw new Error("reactRouterConfig() does not support prerender functions"); + } return dedent` import type { Config } from "@react-router/dev/config"; - export default ${JSON.stringify(config)} satisfies Config; + export default ${JSON.stringify(config, null, 2)} satisfies Config; `; }; diff --git a/integration/middleware-test.ts b/integration/middleware-test.ts index e1ab85a48a..3f25f6201f 100644 --- a/integration/middleware-test.ts +++ b/integration/middleware-test.ts @@ -40,7 +40,7 @@ test.describe("Middleware", () => { // ...existing code... "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_middleware: true, + future: { v8_middleware: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -368,8 +368,7 @@ test.describe("Middleware", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_middleware: true, - v8_splitRouteModules: true, + future: { v8_middleware: true, v8_splitRouteModules: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -466,7 +465,7 @@ test.describe("Middleware", () => { fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, + future: { v8_middleware: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -773,7 +772,7 @@ test.describe("Middleware", () => { let fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, + future: { v8_middleware: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -895,7 +894,7 @@ test.describe("Middleware", () => { let fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, + future: { v8_middleware: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -1060,8 +1059,7 @@ test.describe("Middleware", () => { fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - v8_splitRouteModules: true, + future: { v8_middleware: true, v8_splitRouteModules: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -1156,7 +1154,9 @@ test.describe("Middleware", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { - "react-router.config.ts": reactRouterConfig({ v8_middleware: true }), + "react-router.config.ts": reactRouterConfig({ + future: { v8_middleware: true }, + }), "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -1983,7 +1983,7 @@ test.describe("Middleware", () => { { files: { "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, + future: { v8_middleware: true }, }), "vite.config.ts": js` import { defineConfig } from "vite"; diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index b4db12bc26..81c4c2e52d 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -4476,4 +4476,193 @@ test.describe("single-fetch", () => { await page.waitForSelector("h1"); expect(await app.getHtml("h1")).toMatch("It worked!"); }); + + test("always uses /{path}.data without future.unstable_trailingSlashAwareDataRequests flag", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +
+

Home

+ Go to About (with trailing slash) + Go to About (without trailing slash) +
+ ); + } + `, + "app/routes/about.tsx": js` + import { Link, useLoaderData } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return { + pathname: url.pathname, + hasTrailingSlash: url.pathname.endsWith("/"), + }; + } + + export default function About() { + let { pathname, hasTrailingSlash } = useLoaderData(); + return ( +
+

About

+

{pathname}

+

{String(hasTrailingSlash)}

+ Go back home +
+ ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load without trailing slash + await app.goto("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + + // Client-side navigation without trailing slash + await app.goto("/"); + await app.clickLink("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Document load with trailing slash + await app.goto("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + + // Client-side navigation with trailing slash + await app.goto("/"); + await app.clickLink("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Client-side navigation back to / + await app.clickLink("/"); + await page.waitForSelector("h1:has-text('Home')"); + expect(requests).toEqual(["/_root.data"]); + requests = []; + }); + + test("uses {path}.data or {path}/_.data depending on trailing slash with future.unstable_trailingSlashAwareDataRequests flag", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + future: { + unstable_trailingSlashAwareDataRequests: true, + }, + }), + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +
+

Home

+ Go to About (with trailing slash) + Go to About (without trailing slash) +
+ ); + } + `, + "app/routes/about.tsx": js` + import { Link, useLoaderData } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return { + pathname: url.pathname, + hasTrailingSlash: url.pathname.endsWith("/"), + }; + } + + export default function About() { + let { pathname, hasTrailingSlash } = useLoaderData(); + return ( +
+

About

+

{pathname}

+

{String(hasTrailingSlash)}

+ Go back home +
+ ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load without trailing slash + await app.goto("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + + // Client-side navigation without trailing slash + await app.goto("/"); + await app.clickLink("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Document load with trailing slash + await app.goto("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + + // Client-side navigation with trailing slash + await app.goto("/"); + await app.clickLink("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + expect(requests).toEqual(["/about/_.data"]); + requests = []; + + // Client-side navigation back to / + await app.clickLink("/"); + await page.waitForSelector("h1:has-text('Home')"); + expect(requests).toEqual(["/_.data"]); + requests = []; + }); }); diff --git a/integration/split-route-modules-test.ts b/integration/split-route-modules-test.ts index dbb6f77629..f1fa353ad0 100644 --- a/integration/split-route-modules-test.ts +++ b/integration/split-route-modules-test.ts @@ -259,7 +259,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), ...files, @@ -366,7 +366,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), ...files, @@ -457,7 +457,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), // Make unsplittable routes valid so the build can pass @@ -477,7 +477,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), "app/root.tsx": js` @@ -505,7 +505,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), "app/root.tsx": js` @@ -534,7 +534,7 @@ test.describe("Split route modules", async () => { port = await getPort(); cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.js": await viteConfig.basic({ port }), ...files, diff --git a/integration/sri-test.ts b/integration/sri-test.ts new file mode 100644 index 0000000000..7e45bad97a --- /dev/null +++ b/integration/sri-test.ts @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture, Fixture } from "./helpers/create-fixture.js"; +import { reactRouterConfig } from "./helpers/vite.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("CSub-Resource Integrity", () => { + test.use({ javaScriptEnabled: false }); + + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + future: { unstable_subResourceIntegrity: true }, + }), + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test("includes an importmap", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + let json = await page.locator('script[type="importmap"]').innerText(); + let importMap = JSON.parse(json); + expect(Object.keys(importMap.integrity).length).toBeGreaterThan(0); + for (let key in importMap.integrity) { + if (key.includes("manifest")) continue; + let value = importMap.integrity[key]; + expect(value).toMatch(/^sha384-/); + + let linkEl = page.locator(`link[rel="modulepreload"][href="${key}"]`); + expect(await linkEl.getAttribute("href")).toBe(key); + expect(await linkEl.getAttribute("integrity")).toBe(value); + + let scriptEl = page.locator(`script[type="module"]`); + expect(await scriptEl.innerText()).toContain(key); + } + }); + + test("includes a nonce on the importmap script", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect( + await page.locator('script[type="importmap"]').getAttribute("nonce"), + ).toBe("test-nonce-123"); + }); +}); diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts index 01664a296a..d9532f1e71 100644 --- a/integration/typegen-test.ts +++ b/integration/typegen-test.ts @@ -4,6 +4,7 @@ import tsx from "dedent"; import * as Path from "pathe"; import { test } from "./helpers/fixtures"; +import { reactRouterConfig } from "./helpers/vite"; const viteConfig = ({ rsc }: { rsc: boolean }) => { const reactRouterImportSpecifier = rsc @@ -311,11 +312,9 @@ test.describe("typegen", () => { test("custom app dir", async ({ cwd, edit, $ }) => { await edit({ - "react-router.config.ts": tsx` - export default { - appDirectory: "src/myapp", - } - `, + "react-router.config.ts": reactRouterConfig({ + appDirectory: "src/myapp", + }), "app/routes/products.$id.tsx": tsx` import type { Expect, Equal } from "../expect-type" import type { Route } from "./+types/products.$id" @@ -716,9 +715,9 @@ test.describe("typegen", () => { return { client: "client" } } - export function ServerComponent({ - loaderData, - actionData + export function ServerComponent({ + loaderData, + actionData }: Route.ComponentProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -732,9 +731,9 @@ test.describe("typegen", () => { ) } - export function ErrorBoundary({ - loaderData, - actionData + export function ErrorBoundary({ + loaderData, + actionData }: Route.ErrorBoundaryProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -748,9 +747,9 @@ test.describe("typegen", () => { ) } - export function HydrateFallback({ - loaderData, - actionData + export function HydrateFallback({ + loaderData, + actionData }: Route.HydrateFallbackProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -805,9 +804,9 @@ test.describe("typegen", () => { // This export is not used in standard Framework Mode. This is just // to test that the typegen is unaffected by this export outside of // RSC Framework Mode. - export function ServerComponent({ - loaderData, - actionData + export function ServerComponent({ + loaderData, + actionData }: Route.ComponentProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -821,9 +820,9 @@ test.describe("typegen", () => { ) } - export function ErrorBoundary({ - loaderData, - actionData + export function ErrorBoundary({ + loaderData, + actionData }: Route.ErrorBoundaryProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -837,9 +836,9 @@ test.describe("typegen", () => { ) } - export function HydrateFallback({ - loaderData, - actionData + export function HydrateFallback({ + loaderData, + actionData }: Route.HydrateFallbackProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -888,9 +887,9 @@ test.describe("typegen", () => { return { client: "client" } } - export default function ClientComponent({ - loaderData, - actionData + export default function ClientComponent({ + loaderData, + actionData }: Route.ComponentProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -904,9 +903,9 @@ test.describe("typegen", () => { ) } - export function ErrorBoundary({ - loaderData, - actionData + export function ErrorBoundary({ + loaderData, + actionData }: Route.ErrorBoundaryProps) { type TestLoaderData = Expect> type TestActionData = Expect> @@ -920,9 +919,9 @@ test.describe("typegen", () => { ) } - export function HydrateFallback({ - loaderData, - actionData + export function HydrateFallback({ + loaderData, + actionData }: Route.HydrateFallbackProps) { type TestLoaderData = Expect> type TestActionData = Expect> diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 0e934202de..2103652bb8 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -38,7 +38,7 @@ test.describe("Build", () => { ENV_VAR_FROM_DOTENV_FILE=true `, "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi, + future: { v8_viteEnvironmentApi }, }), "vite.config.ts": js` import { defineConfig } from "vite"; diff --git a/integration/vite-cloudflare-test.ts b/integration/vite-cloudflare-test.ts index e80df136af..e07f3c898c 100644 --- a/integration/vite-cloudflare-test.ts +++ b/integration/vite-cloudflare-test.ts @@ -10,7 +10,9 @@ function getFiles({ v8_viteEnvironmentApi: boolean; }): Files { return async ({ port }) => ({ - "react-router.config.ts": reactRouterConfig({ v8_viteEnvironmentApi }), + "react-router.config.ts": reactRouterConfig({ + future: { v8_viteEnvironmentApi }, + }), "vite.config.ts": ` import { reactRouter } from "@react-router/dev/vite"; import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 0be48cb2ef..e3a415cd71 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -216,7 +216,9 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: templateName !== "vite-5-template", + future: { + v8_viteEnvironmentApi: templateName !== "vite-5-template", + }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -258,7 +260,9 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: templateName !== "vite-5-template", + future: { + v8_viteEnvironmentApi: templateName !== "vite-5-template", + }, basename: base, }), "vite.config.ts": await viteConfig.basic({ @@ -301,7 +305,9 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: templateName !== "vite-5-template", + future: { + v8_viteEnvironmentApi: templateName !== "vite-5-template", + }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -343,7 +349,9 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: templateName !== "vite-5-template", + future: { + v8_viteEnvironmentApi: templateName !== "vite-5-template", + }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -414,7 +422,9 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: templateName !== "vite-5-template", + future: { + v8_viteEnvironmentApi: templateName !== "vite-5-template", + }, }), "vite.config.ts": await viteConfig.basic({ port, diff --git a/integration/vite-dev-test.ts b/integration/vite-dev-test.ts index 8b46824375..570d3b7253 100644 --- a/integration/vite-dev-test.ts +++ b/integration/vite-dev-test.ts @@ -37,7 +37,7 @@ test.describe("Vite dev", () => { test.describe(`template: ${templateName} viteEnvironmentApi: ${v8_viteEnvironmentApi}`, () => { const files: Files = async ({ port }) => ({ "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi, + future: { v8_viteEnvironmentApi }, }), "vite.config.ts": await viteConfig.basic({ port, diff --git a/integration/vite-plugin-order-validation-test.ts b/integration/vite-plugin-order-validation-test.ts index 767a5caca0..a3121503ca 100644 --- a/integration/vite-plugin-order-validation-test.ts +++ b/integration/vite-plugin-order-validation-test.ts @@ -43,7 +43,7 @@ test.describe("Vite plugin order validation", () => { }); `, "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), }, "rsc-vite-framework", @@ -72,7 +72,7 @@ test.describe("Vite plugin order validation", () => { }); `, "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), }, "rsc-vite-framework", diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 2cd0c95bbe..db0b0cbaec 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -583,14 +583,12 @@ test.describe("Prerendering", () => { prerender: true, files: { ...files, - "react-router.config.ts": js` - export default { - prerender: { - paths: ['/', '/about'], - unstable_concurrency: 2, - }, - } - `, + "react-router.config.ts": reactRouterConfig({ + prerender: { + paths: ["/", "/about"], + unstable_concurrency: 2, + }, + }), "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 00b83a1b5a..502902d703 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -238,6 +238,7 @@ test.describe("Vite / presets", async () => { "serverBundles", "serverModuleFormat", "ssr", + "allowedActionOrigins", "unstable_routeConfig", ]); @@ -245,6 +246,7 @@ test.describe("Vite / presets", async () => { expect(buildEndArgsMeta.futureFlags).toEqual({ unstable_optimizeDeps: true, unstable_subResourceIntegrity: false, + unstable_trailingSlashAwareDataRequests: false, v8_middleware: true, v8_splitRouteModules: false, v8_viteEnvironmentApi: false, diff --git a/integration/vite-preview-test.ts b/integration/vite-preview-test.ts index 65eb1611fe..719713336c 100644 --- a/integration/vite-preview-test.ts +++ b/integration/vite-preview-test.ts @@ -14,7 +14,7 @@ test.describe("Vite preview", () => { test("serves built app with vite preview", async ({ vitePreview, page }) => { const files: Files = async ({ port }) => ({ "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -97,7 +97,7 @@ test.describe("Vite preview", () => { test("handles navigation between routes", async ({ vitePreview, page }) => { const files: Files = async ({ port }) => ({ "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -173,7 +173,7 @@ test.describe("Vite preview", () => { test("handles loader data correctly", async ({ vitePreview, page }) => { const files: Files = async ({ port }) => ({ "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -247,7 +247,7 @@ test.describe("Vite preview", () => { }) => { const files: Files = async ({ port }) => ({ "react-router.config.ts": reactRouterConfig({ - v8_viteEnvironmentApi: true, + future: { v8_viteEnvironmentApi: true }, }), "vite.config.ts": await viteConfig.basic({ port, @@ -314,4 +314,74 @@ test.describe("Vite preview", () => { "Product 123", ); }); + + test("serves SPA mode app with vite preview", async ({ + vitePreview, + page, + }) => { + const files: Files = async ({ port }) => ({ + "react-router.config.ts": reactRouterConfig({ + ssr: false, + future: { v8_viteEnvironmentApi: true }, + }), + "vite.config.ts": await viteConfig.basic({ + port, + templateName: "vite-6-template", + }), + "app/root.tsx": tsx` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+

SPA Mode

+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": tsx` + export default function IndexRoute() { + return ( +
+

Index

+

SPA Mode Enabled

+
+ ); + } + `, + "app/routes/about.tsx": tsx` + export default function AboutRoute() { + return ( +
+

About

+

About page in SPA mode

+
+ ); + } + `, + }); + + const { port } = await vitePreview(files, "vite-6-template"); + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + + // Ensure no errors on page load (this would fail without the fix) + expect(page.errors).toEqual([]); + + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + await expect(page.locator("#index [data-spa-mode]")).toHaveText( + "SPA Mode Enabled", + ); + }); }); diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index b8049565c4..74b4a3c8cf 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -24,7 +24,7 @@ test.describe("SPA Mode", () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/routes/invalid-exports.tsx": String.raw` // Invalid exports @@ -51,7 +51,7 @@ test.describe("SPA Mode", () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": String.raw` // Invalid exports @@ -83,7 +83,7 @@ test.describe("SPA Mode", () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/routes/invalid-exports.tsx": String.raw` // Invalid exports @@ -108,7 +108,7 @@ test.describe("SPA Mode", () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/entry.server.tsx": js` import { ServerRouter } from "react-router"; @@ -176,7 +176,7 @@ test.describe("SPA Mode", () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": String.raw` export function HydrateFallback() { @@ -199,7 +199,7 @@ test.describe("SPA Mode", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": js` import { Outlet, Scripts } from "react-router"; @@ -238,7 +238,7 @@ test.describe("SPA Mode", () => { let fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": js` import { Outlet, Scripts } from "react-router"; @@ -272,7 +272,7 @@ test.describe("SPA Mode", () => { "react-router.config.ts": reactRouterConfig({ basename: "/base/", ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": js` import { Outlet, Scripts } from "react-router"; @@ -345,7 +345,7 @@ test.describe("SPA Mode", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/index.html": String.raw` @@ -529,7 +529,7 @@ test.describe("SPA Mode", () => { // file below appDirectory: "src", ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "src/routes.ts": js` import { type RouteConfig } from "@react-router/dev/routes"; @@ -611,7 +611,7 @@ test.describe("SPA Mode", () => { // file below appDirectory: "src", ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "src/routes.ts": js` import { type RouteConfig } from "@react-router/dev/routes"; @@ -677,7 +677,7 @@ test.describe("SPA Mode", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/routes/_index.tsx": js` import { Link } from 'react-router'; @@ -732,7 +732,7 @@ test.describe("SPA Mode", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "app/root.tsx": js` import { @@ -805,7 +805,7 @@ test.describe("SPA Mode", () => { files: { "react-router.config.ts": reactRouterConfig({ ssr: false, - v8_splitRouteModules, + future: { v8_splitRouteModules }, }), "vite.config.ts": js` import { defineConfig } from "vite"; diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 0037a4ef7c..c5e0b521dc 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,7 @@ # `create-react-router` +## 7.12.0 + ## 7.11.0 _No changes_ diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 287ec6d9c4..572fe4b3bc 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.11.0", + "version": "7.12.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 0d7909cbe9..e7c07e020c 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + - `@react-router/node@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index fc0d12d18a..2e8bb28aaa 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.11.0", + "version": "7.12.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 4e3dab77ba..6c3ff8718e 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index fab63c9307..ccaffa9548 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.11.0", + "version": "7.12.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 b9f7dd23f5..8e06400721 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,52 @@ # `@react-router/dev` +## 7.12.0 + +### Minor Changes + +- Add additional layer of CSRF protection by rejecting submissions to UI routes from external origins. If you need to permit access to specific external origins, you can specify them in the `react-router.config.ts` config `allowedActionOrigins` field. ([#14708](https://github.com/remix-run/react-router/pull/14708)) + +### Patch Changes + +- Fix `Maximum call stack size exceeded` errors when HMR is triggered against code with cyclic imports ([#14522](https://github.com/remix-run/react-router/pull/14522)) + +- fix(vite): Skip SSR middleware in preview server for SPA mode ([#14673](https://github.com/remix-run/react-router/pull/14673)) + +- \[UNSTABLE] Add a new `future.unstable_trailingSlashAwareDataRequests` flag to provide consistent behavior of `request.pathname` inside `middleware`, `loader`, and `action` functions on document and data requests when a trailing slash is present in the browser URL. ([#14644](https://github.com/remix-run/react-router/pull/14644)) + + Currently, your HTTP and `request` pathnames would be as follows for `/a/b/c` and `/a/b/c/` + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | + | ------------ | ----------------- | ------------------------ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | + | ------------- | ----------------- | ------------------------ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | + + With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | + | ------------ | ----------------- | ------------------------ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | + | ------------- | ------------------ | ------------------------ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + + This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic. + + Enabling this flag also changes the format of client side `.data` requests from `/_root.data` to `/_.data` when navigating to `/` to align with the new format. This does not impact the `request` pathname which is still `/` in all cases. + +- Updated dependencies: + - `react-router@7.12.0` + - `@react-router/node@7.12.0` + - `@react-router/serve@7.12.0` + ## 7.11.0 ### Minor Changes diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 2211d71ab1..604985deaa 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -87,6 +87,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; unstable_subResourceIntegrity: boolean; + unstable_trailingSlashAwareDataRequests: boolean; /** * Enable route middleware */ @@ -210,6 +211,12 @@ export type ReactRouterConfig = { * SPA without server-rendering. Default's to `true`. */ ssr?: boolean; + + /** + * The allowed origins for actions / mutations. Does not apply to routes + * without a component. micromatch glob patterns are supported. + */ + allowedActionOrigins?: string[]; }; export type ResolvedReactRouterConfig = Readonly<{ @@ -276,6 +283,11 @@ export type ResolvedReactRouterConfig = Readonly<{ * SPA without server-rendering. Default's to `true`. */ ssr: boolean; + /** + * The allowed origins for actions / mutations. Does not apply to routes + * without a component. micromatch glob patterns are supported. + */ + allowedActionOrigins: string[] | false; /** * The resolved array of route config entries exported from `routes.ts` */ @@ -634,6 +646,9 @@ async function resolveConfig({ userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, + unstable_trailingSlashAwareDataRequests: + userAndPresetConfigs.future?.unstable_trailingSlashAwareDataRequests ?? + false, v8_middleware: userAndPresetConfigs.future?.v8_middleware ?? false, v8_splitRouteModules: userAndPresetConfigs.future?.v8_splitRouteModules ?? false, @@ -641,6 +656,8 @@ async function resolveConfig({ userAndPresetConfigs.future?.v8_viteEnvironmentApi ?? false, }; + let allowedActionOrigins = userAndPresetConfigs.allowedActionOrigins ?? false; + let reactRouterConfig: ResolvedReactRouterConfig = deepFreeze({ appDirectory, basename, @@ -654,6 +671,7 @@ async function resolveConfig({ serverBundles, serverModuleFormat, ssr, + allowedActionOrigins, unstable_routeConfig: routeConfig, } satisfies ResolvedReactRouterConfig); diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index de524fe450..2859aa85a9 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.11.0", + "version": "7.12.0", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com", "bugs": { diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index 72b5b46fb1..a18cd5f198 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -48,6 +48,7 @@ export function generateServerBuild(ctx: Context): VirtualFile { export const routeDiscovery: ServerBuild["routeDiscovery"]; export const routes: ServerBuild["routes"]; export const ssr: ServerBuild["ssr"]; + export const allowedActionOrigins: ServerBuild["allowedActionOrigins"]; export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"]; } `; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6a77987977..62935c15bc 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -871,7 +871,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } ` : "" - }`; + } + export const allowedActionOrigins = ${JSON.stringify(ctx.reactRouterConfig.allowedActionOrigins)}; + `; }; let loadViteManifest = async (directory: string) => { @@ -1688,6 +1690,11 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { }, configurePreviewServer(previewServer) { return () => { + // Skip SSR handling in SPA mode + if (!ctx.reactRouterConfig.ssr) { + return; + } + // Handle SSR requests in preview mode using the built server bundle previewServer.middlewares.use(async (req, res, next) => { try { @@ -2445,10 +2452,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { return; } - let clientModules = uniqueNodes( - modules.flatMap((mod) => - getParentClientNodes(server.environments.client.moduleGraph, mod), - ), + let clientModules = modules.flatMap((mod) => + getParentClientNodes(server.environments.client.moduleGraph, mod), ); for (let clientModule of clientModules) { @@ -2464,10 +2469,15 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { function getParentClientNodes( clientModuleGraph: Vite.EnvironmentModuleGraph, module: Vite.EnvironmentModuleNode, + seenNodes: Set = new Set(), ): Vite.EnvironmentModuleNode[] { if (!module.id) { return []; } + if (seenNodes.has(module.url)) { + return []; + } + seenNodes.add(module.url); let clientModule = clientModuleGraph.getModuleById(module.id); if (clientModule) { @@ -2475,25 +2485,10 @@ function getParentClientNodes( } return [...module.importers].flatMap((importer) => - getParentClientNodes(clientModuleGraph, importer), + getParentClientNodes(clientModuleGraph, importer, seenNodes), ); } -function uniqueNodes( - nodes: Vite.EnvironmentModuleNode[], -): Vite.EnvironmentModuleNode[] { - let nodeUrls = new Set(); - let unique: Vite.EnvironmentModuleNode[] = []; - for (let node of nodes) { - if (nodeUrls.has(node.url)) { - continue; - } - nodeUrls.add(node.url); - unique.push(node); - } - return unique; -} - function addRefreshWrapper( reactRouterConfig: ResolvedReactRouterConfig, code: string, @@ -2895,11 +2890,22 @@ async function prerenderData( viteConfig: Vite.ResolvedConfig, requestInit?: RequestInit, ) { - let normalizedPath = `${reactRouterConfig.basename}${ - prerenderPath === "/" - ? "/_root.data" - : `${prerenderPath.replace(/\/$/, "")}.data` - }`.replace(/\/\/+/g, "/"); + let dataRequestPath: string; + if (reactRouterConfig.future.unstable_trailingSlashAwareDataRequests) { + if (prerenderPath.endsWith("/")) { + dataRequestPath = `${prerenderPath}_.data`; + } else { + dataRequestPath = `${prerenderPath}.data`; + } + } else { + if (prerenderPath === "/") { + dataRequestPath = "/_root.data"; + } else { + dataRequestPath = `${prerenderPath.replace(/\/$/, "")}.data`; + } + } + let normalizedPath = + `${reactRouterConfig.basename}${dataRequestPath}`.replace(/\/\/+/g, "/"); let url = new URL(`http://localhost${normalizedPath}`); if (onlyRoutes?.length) { url.searchParams.set("_routes", onlyRoutes.join(",")); diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 28cb85d501..9e0bf2bf30 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 665afc85f3..920164d623 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.11.0", + "version": "7.12.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 7d278c4dcb..d7250df76a 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + - `@react-router/node@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index bd814bbd18..936a5fde4d 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.11.0", + "version": "7.12.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 79aa56218a..31a6a7b2e0 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 06199badb9..74b41148f3 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.11.0", + "version": "7.12.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 a0015b86dc..1018e18450 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 896575b42a..5ec3850006 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.11.0", + "version": "7.12.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 f1d5bc3644..cf98ae6a22 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.12.0 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.12.0` + ## 7.11.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 564b8afc17..7155a102c7 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.11.0", + "version": "7.12.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 20c0cbbb48..bddf4f8296 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.12.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.12.0` + - `@react-router/node@7.12.0` + - `@react-router/express@7.12.0` + ## 7.11.0 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 7900da4cd4..65b4089fe1 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.11.0", + "version": "7.12.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 950b634a69..3940384f29 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,55 @@ # `react-router` +## 7.12.0 + +### Minor Changes + +- Add additional layer of CSRF protection by rejecting submissions to UI routes from external origins. If you need to permit access to specific external origins, you can specify them in the `react-router.config.ts` config `allowedActionOrigins` field. ([#14708](https://github.com/remix-run/react-router/pull/14708)) + +### Patch Changes + +- Fix `generatePath` when used with suffixed params (i.e., "/books/:id.json") ([#14269](https://github.com/remix-run/react-router/pull/14269)) + +- Export `UNSAFE_createMemoryHistory` and `UNSAFE_createHashHistory` alongside `UNSAFE_createBrowserHistory` for consistency. These are not intended to be used for new apps but intended to help apps usiong `unstable_HistoryRouter` migrate from v6->v7 so they can adopt the newer APIs. ([#14663](https://github.com/remix-run/react-router/pull/14663)) + +- Escape HTML in scroll restoration keys ([#14705](https://github.com/remix-run/react-router/pull/14705)) + +- Validate redirect locations ([#14706](https://github.com/remix-run/react-router/pull/14706)) + +- \[UNSTABLE] Pass `` value through to the underlying `importmap` `script` tag when using `future.unstable_subResourceIntegrity` ([#14675](https://github.com/remix-run/react-router/pull/14675)) + +- \[UNSTABLE] Add a new `future.unstable_trailingSlashAwareDataRequests` flag to provide consistent behavior of `request.pathname` inside `middleware`, `loader`, and `action` functions on document and data requests when a trailing slash is present in the browser URL. ([#14644](https://github.com/remix-run/react-router/pull/14644)) + + Currently, your HTTP and `request` pathnames would be as follows for `/a/b/c` and `/a/b/c/` + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | + | ------------ | ----------------- | ------------------------ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | + | ------------- | ----------------- | ------------------------ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | + + With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: + + | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | + | ------------ | ----------------- | ------------------------ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + + | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | + | ------------- | ------------------ | ------------------------ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + + This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic. + + Enabling this flag also changes the format of client side `.data` requests from `/_root.data` to `/_.data` when navigating to `/` to align with the new format. This does not impact the `request` pathname which is still `/` in all cases. + +- Preserve `clientLoader.hydrate=true` when using `` ([#14674](https://github.com/remix-run/react-router/pull/14674)) + ## 7.11.0 ### Minor Changes diff --git a/packages/react-router/__tests__/generatePath-test.tsx b/packages/react-router/__tests__/generatePath-test.tsx index c4fbf35b7b..3bf3a18433 100644 --- a/packages/react-router/__tests__/generatePath-test.tsx +++ b/packages/react-router/__tests__/generatePath-test.tsx @@ -191,4 +191,25 @@ describe("generatePath", () => { consoleWarn.mockRestore(); }); + + describe("with params followed by static text", () => { + it("interpolates params with file extensions", () => { + expect(generatePath("/books/:id.json", { id: "42" })).toBe( + "/books/42.json", + ); + expect(generatePath("/api/:resource.xml", { resource: "users" })).toBe( + "/api/users.xml", + ); + expect(generatePath("/:lang.html", { lang: "en" })).toBe("/en.html"); + }); + + it("handles multiple extensions", () => { + expect(generatePath("/files/:name.tar.gz", { name: "archive" })).toBe( + "/files/archive.tar.gz", + ); + expect(generatePath("/:file.min.js", { file: "app" })).toBe( + "/app.min.js", + ); + }); + }); }); diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 54518a8562..52bab8a3b0 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,6 +1,7 @@ import { createMemoryRouter } from "../../lib/components"; +import { createMemoryHistory } from "../../lib/router/history"; import type { StaticHandlerContext } from "../../lib/router/router"; -import { createStaticHandler } from "../../lib/router/router"; +import { createRouter, createStaticHandler } from "../../lib/router/router"; import { ErrorResponseImpl, data, @@ -112,6 +113,56 @@ describe("instrumentation", () => { }); }); + it("preserves hydrate=true on client side loaders", async () => { + let dfd = createDeferred(); + let spy = jest.fn(); + function loader() { + dfd.resolve(); + return "INDEX*"; + } + loader.hydrate = true; + let router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "index", + index: true, + loader, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }); + + expect(router.state.initialized).toBe(false); + expect(router.state.loaderData).toEqual({ index: "INDEX" }); + + router.initialize(); + await dfd.promise; + await tick(); + + expect(router.state.initialized).toBe(true); + expect(router.state.loaderData).toEqual({ index: "INDEX*" }); + expect(spy).toHaveBeenCalledWith("start"); + expect(spy).toHaveBeenCalledWith("end"); + }); + it("allows instrumentation of actions", async () => { let spy = jest.fn(); let t = setup({ diff --git a/packages/react-router/__tests__/useParams-test.tsx b/packages/react-router/__tests__/useParams-test.tsx index 5413cee245..d4fcec0938 100644 --- a/packages/react-router/__tests__/useParams-test.tsx +++ b/packages/react-router/__tests__/useParams-test.tsx @@ -1,6 +1,14 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; -import { MemoryRouter, Outlet, Routes, Route, useParams } from "react-router"; +import { + MemoryRouter, + Outlet, + Routes, + Route, + useParams, + useLocation, + generatePath, +} from "react-router"; function ShowParams() { return
{JSON.stringify(useParams())}
; @@ -209,4 +217,72 @@ describe("useParams", () => { `); }); }); + + test("maintains compatibility with generatePath", () => { + let tests = [ + { + path: "/books/42", + url: "/books/42", + params: {}, + }, + { + path: "/books/:id", + url: "/books/42", + params: { id: "42" }, + }, + { + path: "/books/:id.json", + url: "/books/42.json", + params: { id: "42" }, + }, + { + path: "/books/:id/comments", + url: "/books/42/comments", + params: { id: "42" }, + }, + { + path: "/books/:id.json/comments", + url: "/books/42.json/comments", + params: { id: "42" }, + }, + ]; + + function ShowParamsAndPath({ path }: { path: string }) { + return ( + <> +

{JSON.stringify(useParams())}

+

{useLocation().pathname}

+

{generatePath(path, useParams())}

+ + ); + } + + for (let { path, url, params } of tests) { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + + // eslint-disable-next-line jest/no-interpolation-in-snapshots + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ ${JSON.stringify(params)} +

, +

+ ${url} +

, +

+ ${url} +

, + ] + `); + } + }); }); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 249baffc01..17ac647117 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -349,7 +349,9 @@ export type { /** @internal */ export { + createMemoryHistory as UNSAFE_createMemoryHistory, createBrowserHistory as UNSAFE_createBrowserHistory, + createHashHistory as UNSAFE_createHashHistory, invariant as UNSAFE_invariant, } from "./lib/router/history"; diff --git a/packages/react-router/lib/actions.ts b/packages/react-router/lib/actions.ts new file mode 100644 index 0000000000..0118cbc2fb --- /dev/null +++ b/packages/react-router/lib/actions.ts @@ -0,0 +1,122 @@ +export function throwIfPotentialCSRFAttack( + headers: Headers, + allowedActionOrigins: string[] | undefined, +) { + let originHeader = headers.get("origin"); + let originDomain = + typeof originHeader === "string" && originHeader !== "null" + ? new URL(originHeader).host + : originHeader; + let host = parseHostHeader(headers); + + if (originDomain && (!host || originDomain !== host.value)) { + if (!isAllowedOrigin(originDomain, allowedActionOrigins)) { + if (host) { + // This seems to be an CSRF attack. We should not proceed with the action. + throw new Error( + `${host.type} header does not match \`origin\` header from a forwarded ` + + `action request. Aborting the action.`, + ); + } else { + // This is an attack. We should not proceed with the action. + throw new Error( + "`x-forwarded-host` or `host` headers are not provided. One of these " + + "is needed to compare the `origin` header from a forwarded action " + + "request. Aborting the action.", + ); + } + } + } +} + +// Implementation of micromatch by Next.js https://github.com/vercel/next.js/blob/ea927b583d24f42e538001bf13370e38c91d17bf/packages/next/src/server/app-render/csrf-protection.ts#L6 +function matchWildcardDomain(domain: string, pattern: string) { + const domainParts = domain.split("."); + const patternParts = pattern.split("."); + + if (patternParts.length < 1) { + // pattern is empty and therefore invalid to match against + return false; + } + + if (domainParts.length < patternParts.length) { + // domain has too few segments and thus cannot match + 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(); + + switch (patternPart) { + case "": { + // invalid pattern. pattern segments must be non empty + return false; + } + case "*": { + // wildcard matches anything so we continue if the domain part is non-empty + if (domainPart) { + continue; + } else { + return false; + } + } + case "**": { + // if this is not the last item in the pattern the pattern is invalid + if (patternParts.length > 0) { + return false; + } + // recursive wildcard matches anything so we terminate here if the domain part is non empty + return domainPart !== undefined; + } + case undefined: + default: { + if (domainPart !== patternPart) { + return false; + } + } + } + } + + // We exhausted the pattern. If we also exhausted the domain we have a match + return domainParts.length === 0; +} + +function isAllowedOrigin( + originDomain: string, + allowedActionOrigins: string[] | undefined = [], +) { + return allowedActionOrigins.some( + (allowedOrigin) => + allowedOrigin && + (allowedOrigin === originDomain || + matchWildcardDomain(originDomain, allowedOrigin)), + ); +} + +function parseHostHeader(headers: Headers) { + let forwardedHostHeader = headers.get("x-forwarded-host"); + let forwardedHostValue = forwardedHostHeader?.split(",")[0]?.trim(); + let hostHeader = headers.get("host"); + + return forwardedHostValue + ? { + type: "x-forwarded-host", + value: forwardedHostValue, + } + : hostHeader + ? { + type: "host", + value: hostHeader, + } + : undefined; +} diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 19c2c1c691..e2dc320796 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -186,6 +186,7 @@ function createHydratedRouter({ ssrInfo.routeModules, ssrInfo.context.ssr, ssrInfo.context.basename, + ssrInfo.context.future.unstable_trailingSlashAwareDataRequests, ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 4c8a514d37..39bc64675f 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -97,6 +97,7 @@ import { } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; import type { unstable_ClientInstrumentation } from "../router/instrumentation"; +import { escapeHtml } from "./ssr/markup"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff @@ -2033,9 +2034,9 @@ export function ScrollRestoration({ {...props} suppressHydrationWarning dangerouslySetInnerHTML={{ - __html: `(${restoreScroll})(${JSON.stringify( - storageKey || SCROLL_RESTORATION_STORAGE_KEY, - )}, ${JSON.stringify(ssrKey)})`, + __html: `(${restoreScroll})(${escapeHtml( + JSON.stringify(storageKey || SCROLL_RESTORATION_STORAGE_KEY), + )}, ${escapeHtml(JSON.stringify(ssrKey))})`, }} /> ); diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index 7653755af3..b054950bc2 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -35,6 +35,7 @@ import { ViewTransitionContext, } from "../context"; import { useRoutesImpl } from "../hooks"; +import { escapeHtml } from "./ssr/markup"; /** * @category Types @@ -187,7 +188,7 @@ export function StaticRouterProvider({ // up parsing on the client. Dual-stringify is needed to ensure all quotes // are properly escaped in the resulting string. See: // https://v8.dev/blog/cost-of-javascript-2019#json - let json = htmlEscape(JSON.stringify(JSON.stringify(data))); + let json = escapeHtml(JSON.stringify(JSON.stringify(data))); hydrateScript = `window.__staticRouterHydrationData = JSON.parse(${json});`; } @@ -520,19 +521,3 @@ function encodeLocation(to: To): Path { } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; - -// This utility is based on https://github.com/zertosh/htmlescape -// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE -const ESCAPE_LOOKUP: { [match: string]: string } = { - "&": "\\u0026", - ">": "\\u003e", - "<": "\\u003c", - "\u2028": "\\u2028", - "\u2029": "\\u2029", -}; - -const ESCAPE_REGEX = /[&><\u2028\u2029]/g; - -function htmlEscape(str: string): string { - return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); -} diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 57edd93007..48fb544fb3 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -371,7 +371,7 @@ function PrefetchPageLinksImpl({ matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { manifest, routeModules } = useFrameworkContext(); + let { future, manifest, routeModules } = useFrameworkContext(); let { basename } = useDataRouterContext(); let { loaderData, matches } = useDataRouterStateContext(); @@ -435,7 +435,12 @@ function PrefetchPageLinksImpl({ return []; } - let url = singleFetchUrl(page, basename, "data"); + let url = singleFetchUrl( + page, + basename, + future.unstable_trailingSlashAwareDataRequests, + "data", + ); // When one or more routes have opted out, we add a _routes param to // limit the loaders to those that have a server loader and did not // opt out @@ -452,6 +457,7 @@ function PrefetchPageLinksImpl({ return [url.pathname + url.search]; }, [ basename, + future.unstable_trailingSlashAwareDataRequests, loaderData, location, manifest, @@ -901,6 +907,7 @@ import(${JSON.stringify(manifest.entry.module)});`; <> {typeof manifest.sri === "object" ? (