diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2a1a00b55..80cfee7fa8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,122 +13,127 @@ We manage release notes in this file instead of the paginated Github Releases Pa
Table of Contents
- [React Router Releases](#react-router-releases)
- - [v7.9.3](#v793)
+ - [v7.9.4](#v794)
+ - [What's Changed](#whats-changed)
+ - [`useRoute()` (unstable)](#useroute-unstable)
- [Patch Changes](#patch-changes)
+ - [Unstable Changes](#unstable-changes)
+ - [v7.9.3](#v793)
+ - [Patch Changes](#patch-changes-1)
- [v7.9.2](#v792)
- - [What's Changed](#whats-changed)
+ - [What's Changed](#whats-changed-1)
- [RSC Framework Mode (unstable)](#rsc-framework-mode-unstable)
- [Fetcher Reset (unstable)](#fetcher-reset-unstable)
- - [Patch Changes](#patch-changes-1)
- - [Unstable Changes](#unstable-changes)
- - [v7.9.1](#v791)
- [Patch Changes](#patch-changes-2)
+ - [Unstable Changes](#unstable-changes-1)
+ - [v7.9.1](#v791)
+ - [Patch Changes](#patch-changes-3)
- [v7.9.0](#v790)
- - [What's Changed](#whats-changed-1)
+ - [What's Changed](#whats-changed-2)
- [Stable Middleware and Context APIs](#stable-middleware-and-context-apis)
- [Minor Changes](#minor-changes)
- - [Patch Changes](#patch-changes-3)
- - [Unstable Changes](#unstable-changes-1)
- - [v7.8.2](#v782)
- [Patch Changes](#patch-changes-4)
- [Unstable Changes](#unstable-changes-2)
- - [v7.8.1](#v781)
+ - [v7.8.2](#v782)
- [Patch Changes](#patch-changes-5)
- [Unstable Changes](#unstable-changes-3)
+ - [v7.8.1](#v781)
+ - [Patch Changes](#patch-changes-6)
+ - [Unstable Changes](#unstable-changes-4)
- [v7.8.0](#v780)
- - [What's Changed](#whats-changed-2)
+ - [What's Changed](#whats-changed-3)
- [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-1)
- - [Patch Changes](#patch-changes-6)
- - [Unstable Changes](#unstable-changes-4)
- - [Changes by Package](#changes-by-package)
- - [v7.7.1](#v771)
- [Patch Changes](#patch-changes-7)
- [Unstable Changes](#unstable-changes-5)
+ - [Changes by Package](#changes-by-package)
+ - [v7.7.1](#v771)
+ - [Patch Changes](#patch-changes-8)
+ - [Unstable Changes](#unstable-changes-6)
- [v7.7.0](#v770)
- - [What's Changed](#whats-changed-3)
+ - [What's Changed](#whats-changed-4)
- [Unstable RSC APIs](#unstable-rsc-apis)
- [Minor Changes](#minor-changes-2)
- - [Patch Changes](#patch-changes-8)
- - [Unstable Changes](#unstable-changes-6)
+ - [Patch Changes](#patch-changes-9)
+ - [Unstable Changes](#unstable-changes-7)
- [Changes by Package](#changes-by-package-1)
- [v7.6.3](#v763)
- - [Patch Changes](#patch-changes-9)
- - [v7.6.2](#v762)
- [Patch Changes](#patch-changes-10)
- - [v7.6.1](#v761)
+ - [v7.6.2](#v762)
- [Patch Changes](#patch-changes-11)
- - [Unstable Changes](#unstable-changes-7)
+ - [v7.6.1](#v761)
+ - [Patch Changes](#patch-changes-12)
+ - [Unstable Changes](#unstable-changes-8)
- [v7.6.0](#v760)
- - [What's Changed](#whats-changed-4)
+ - [What's Changed](#whats-changed-5)
- [`routeDiscovery` Config Option](#routediscovery-config-option)
- [Automatic Types for Future Flags](#automatic-types-for-future-flags)
- [Minor Changes](#minor-changes-3)
- - [Patch Changes](#patch-changes-12)
- - [Unstable Changes](#unstable-changes-8)
+ - [Patch Changes](#patch-changes-13)
+ - [Unstable Changes](#unstable-changes-9)
- [Changes by Package](#changes-by-package-2)
- [v7.5.3](#v753)
- - [Patch Changes](#patch-changes-13)
+ - [Patch Changes](#patch-changes-14)
- [v7.5.2](#v752)
- [Security Notice](#security-notice)
- - [Patch Changes](#patch-changes-14)
- - [v7.5.1](#v751)
- [Patch Changes](#patch-changes-15)
- - [Unstable Changes](#unstable-changes-9)
+ - [v7.5.1](#v751)
+ - [Patch Changes](#patch-changes-16)
+ - [Unstable Changes](#unstable-changes-10)
- [v7.5.0](#v750)
- - [What's Changed](#whats-changed-5)
+ - [What's Changed](#whats-changed-6)
- [`route.lazy` Object API](#routelazy-object-api)
- [Minor Changes](#minor-changes-4)
- - [Patch Changes](#patch-changes-16)
- - [Unstable Changes](#unstable-changes-10)
+ - [Patch Changes](#patch-changes-17)
+ - [Unstable Changes](#unstable-changes-11)
- [Changes by Package](#changes-by-package-3)
- [v7.4.1](#v741)
- [Security Notice](#security-notice-1)
- - [Patch Changes](#patch-changes-17)
- - [Unstable Changes](#unstable-changes-11)
- - [v7.4.0](#v740)
- - [Minor Changes](#minor-changes-5)
- [Patch Changes](#patch-changes-18)
- [Unstable Changes](#unstable-changes-12)
+ - [v7.4.0](#v740)
+ - [Minor Changes](#minor-changes-5)
+ - [Patch Changes](#patch-changes-19)
+ - [Unstable Changes](#unstable-changes-13)
- [Changes by Package](#changes-by-package-4)
- [v7.3.0](#v730)
- [Minor Changes](#minor-changes-6)
- - [Patch Changes](#patch-changes-19)
- - [Unstable Changes](#unstable-changes-13)
+ - [Patch Changes](#patch-changes-20)
+ - [Unstable Changes](#unstable-changes-14)
- [Client-side `context` (unstable)](#client-side-context-unstable)
- [Middleware (unstable)](#middleware-unstable)
- [Middleware `context` parameter](#middleware-context-parameter)
- [`unstable_SerializesTo`](#unstable_serializesto)
- [Changes by Package](#changes-by-package-5)
- [v7.2.0](#v720)
- - [What's Changed](#whats-changed-6)
+ - [What's Changed](#whats-changed-7)
- [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-7)
- - [Patch Changes](#patch-changes-20)
- - [Unstable Changes](#unstable-changes-14)
+ - [Patch Changes](#patch-changes-21)
+ - [Unstable Changes](#unstable-changes-15)
- [Split Route Modules (unstable)](#split-route-modules-unstable)
- [Changes by Package](#changes-by-package-6)
- [v7.1.5](#v715)
- - [Patch Changes](#patch-changes-21)
- - [v7.1.4](#v714)
- [Patch Changes](#patch-changes-22)
- - [v7.1.3](#v713)
+ - [v7.1.4](#v714)
- [Patch Changes](#patch-changes-23)
- - [v7.1.2](#v712)
+ - [v7.1.3](#v713)
- [Patch Changes](#patch-changes-24)
- - [v7.1.1](#v711)
+ - [v7.1.2](#v712)
- [Patch Changes](#patch-changes-25)
+ - [v7.1.1](#v711)
+ - [Patch Changes](#patch-changes-26)
- [v7.1.0](#v710)
- [Minor Changes](#minor-changes-8)
- - [Patch Changes](#patch-changes-26)
+ - [Patch Changes](#patch-changes-27)
- [Changes by Package](#changes-by-package-7)
- [v7.0.2](#v702)
- - [Patch Changes](#patch-changes-27)
- - [v7.0.1](#v701)
- [Patch Changes](#patch-changes-28)
+ - [v7.0.1](#v701)
+ - [Patch Changes](#patch-changes-29)
- [v7.0.0](#v700)
- [Breaking Changes](#breaking-changes)
- [Package Restructuring](#package-restructuring)
@@ -145,201 +150,201 @@ We manage release notes in this file instead of the paginated Github Releases Pa
- [Major Changes (`react-router`)](#major-changes-react-router)
- [Major Changes (`@react-router/*`)](#major-changes-react-router-1)
- [Minor Changes](#minor-changes-9)
- - [Patch Changes](#patch-changes-29)
+ - [Patch Changes](#patch-changes-30)
- [Changes by Package](#changes-by-package-8)
- [React Router v6 Releases](#react-router-v6-releases)
- [v6.30.1](#v6301)
- - [Patch Changes](#patch-changes-30)
+ - [Patch Changes](#patch-changes-31)
- [v6.30.0](#v6300)
- [Minor Changes](#minor-changes-10)
- - [Patch Changes](#patch-changes-31)
+ - [Patch Changes](#patch-changes-32)
- [v6.29.0](#v6290)
- [Minor Changes](#minor-changes-11)
- - [Patch Changes](#patch-changes-32)
- - [v6.28.2](#v6282)
- [Patch Changes](#patch-changes-33)
- - [v6.28.1](#v6281)
+ - [v6.28.2](#v6282)
- [Patch Changes](#patch-changes-34)
+ - [v6.28.1](#v6281)
+ - [Patch Changes](#patch-changes-35)
- [v6.28.0](#v6280)
- - [What's Changed](#whats-changed-7)
+ - [What's Changed](#whats-changed-8)
- [Minor Changes](#minor-changes-12)
- - [Patch Changes](#patch-changes-35)
+ - [Patch Changes](#patch-changes-36)
- [v6.27.0](#v6270)
- - [What's Changed](#whats-changed-8)
+ - [What's Changed](#whats-changed-9)
- [Stabilized APIs](#stabilized-apis)
- [Minor Changes](#minor-changes-13)
- - [Patch Changes](#patch-changes-36)
- - [v6.26.2](#v6262)
- [Patch Changes](#patch-changes-37)
- - [v6.26.1](#v6261)
+ - [v6.26.2](#v6262)
- [Patch Changes](#patch-changes-38)
+ - [v6.26.1](#v6261)
+ - [Patch Changes](#patch-changes-39)
- [v6.26.0](#v6260)
- [Minor Changes](#minor-changes-14)
- - [Patch Changes](#patch-changes-39)
- - [v6.25.1](#v6251)
- [Patch Changes](#patch-changes-40)
+ - [v6.25.1](#v6251)
+ - [Patch Changes](#patch-changes-41)
- [v6.25.0](#v6250)
- - [What's Changed](#whats-changed-9)
+ - [What's Changed](#whats-changed-10)
- [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation)
- [Minor Changes](#minor-changes-15)
- - [Patch Changes](#patch-changes-41)
- - [v6.24.1](#v6241)
- [Patch Changes](#patch-changes-42)
+ - [v6.24.1](#v6241)
+ - [Patch Changes](#patch-changes-43)
- [v6.24.0](#v6240)
- - [What's Changed](#whats-changed-10)
+ - [What's Changed](#whats-changed-11)
- [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war)
- [Minor Changes](#minor-changes-16)
- - [Patch Changes](#patch-changes-43)
- - [v6.23.1](#v6231)
- [Patch Changes](#patch-changes-44)
+ - [v6.23.1](#v6231)
+ - [Patch Changes](#patch-changes-45)
- [v6.23.0](#v6230)
- - [What's Changed](#whats-changed-11)
+ - [What's Changed](#whats-changed-12)
- [Data Strategy (unstable)](#data-strategy-unstable)
- [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable)
- [Minor Changes](#minor-changes-17)
- [v6.22.3](#v6223)
- - [Patch Changes](#patch-changes-45)
- - [v6.22.2](#v6222)
- [Patch Changes](#patch-changes-46)
- - [v6.22.1](#v6221)
+ - [v6.22.2](#v6222)
- [Patch Changes](#patch-changes-47)
+ - [v6.22.1](#v6221)
+ - [Patch Changes](#patch-changes-48)
- [v6.22.0](#v6220)
- - [What's Changed](#whats-changed-12)
+ - [What's Changed](#whats-changed-13)
- [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag)
- [Minor Changes](#minor-changes-18)
- - [Patch Changes](#patch-changes-48)
- - [v6.21.3](#v6213)
- [Patch Changes](#patch-changes-49)
- - [v6.21.2](#v6212)
+ - [v6.21.3](#v6213)
- [Patch Changes](#patch-changes-50)
- - [v6.21.1](#v6211)
+ - [v6.21.2](#v6212)
- [Patch Changes](#patch-changes-51)
+ - [v6.21.1](#v6211)
+ - [Patch Changes](#patch-changes-52)
- [v6.21.0](#v6210)
- - [What's Changed](#whats-changed-13)
+ - [What's Changed](#whats-changed-14)
- [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath)
- [Partial Hydration](#partial-hydration)
- [Minor Changes](#minor-changes-19)
- - [Patch Changes](#patch-changes-52)
- - [v6.20.1](#v6201)
- [Patch Changes](#patch-changes-53)
+ - [v6.20.1](#v6201)
+ - [Patch Changes](#patch-changes-54)
- [v6.20.0](#v6200)
- [Minor Changes](#minor-changes-20)
- - [Patch Changes](#patch-changes-54)
+ - [Patch Changes](#patch-changes-55)
- [v6.19.0](#v6190)
- - [What's Changed](#whats-changed-14)
+ - [What's Changed](#whats-changed-15)
- [`unstable_flushSync` API](#unstable_flushsync-api)
- [Minor Changes](#minor-changes-21)
- - [Patch Changes](#patch-changes-55)
+ - [Patch Changes](#patch-changes-56)
- [v6.18.0](#v6180)
- - [What's Changed](#whats-changed-15)
+ - [What's Changed](#whats-changed-16)
- [New Fetcher APIs](#new-fetcher-apis)
- [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist)
- [Minor Changes](#minor-changes-22)
- - [Patch Changes](#patch-changes-56)
+ - [Patch Changes](#patch-changes-57)
- [v6.17.0](#v6170)
- - [What's Changed](#whats-changed-16)
+ - [What's Changed](#whats-changed-17)
- [View Transitions 🚀](#view-transitions-)
- [Minor Changes](#minor-changes-23)
- - [Patch Changes](#patch-changes-57)
+ - [Patch Changes](#patch-changes-58)
- [v6.16.0](#v6160)
- [Minor Changes](#minor-changes-24)
- - [Patch Changes](#patch-changes-58)
+ - [Patch Changes](#patch-changes-59)
- [v6.15.0](#v6150)
- [Minor Changes](#minor-changes-25)
- - [Patch Changes](#patch-changes-59)
- - [v6.14.2](#v6142)
- [Patch Changes](#patch-changes-60)
- - [v6.14.1](#v6141)
+ - [v6.14.2](#v6142)
- [Patch Changes](#patch-changes-61)
+ - [v6.14.1](#v6141)
+ - [Patch Changes](#patch-changes-62)
- [v6.14.0](#v6140)
- - [What's Changed](#whats-changed-17)
+ - [What's Changed](#whats-changed-18)
- [JSON/Text Submissions](#jsontext-submissions)
- [Minor Changes](#minor-changes-26)
- - [Patch Changes](#patch-changes-62)
+ - [Patch Changes](#patch-changes-63)
- [v6.13.0](#v6130)
- - [What's Changed](#whats-changed-18)
+ - [What's Changed](#whats-changed-19)
- [`future.v7_startTransition`](#futurev7_starttransition)
- [Minor Changes](#minor-changes-27)
- - [Patch Changes](#patch-changes-63)
- - [v6.12.1](#v6121)
- [Patch Changes](#patch-changes-64)
+ - [v6.12.1](#v6121)
+ - [Patch Changes](#patch-changes-65)
- [v6.12.0](#v6120)
- - [What's Changed](#whats-changed-19)
+ - [What's Changed](#whats-changed-20)
- [`React.startTransition` support](#reactstarttransition-support)
- [Minor Changes](#minor-changes-28)
- - [Patch Changes](#patch-changes-65)
- - [v6.11.2](#v6112)
- [Patch Changes](#patch-changes-66)
- - [v6.11.1](#v6111)
+ - [v6.11.2](#v6112)
- [Patch Changes](#patch-changes-67)
+ - [v6.11.1](#v6111)
+ - [Patch Changes](#patch-changes-68)
- [v6.11.0](#v6110)
- [Minor Changes](#minor-changes-29)
- - [Patch Changes](#patch-changes-68)
+ - [Patch Changes](#patch-changes-69)
- [v6.10.0](#v6100)
- - [What's Changed](#whats-changed-20)
+ - [What's Changed](#whats-changed-21)
- [Minor Changes](#minor-changes-30)
- [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod)
- - [Patch Changes](#patch-changes-69)
+ - [Patch Changes](#patch-changes-70)
- [v6.9.0](#v690)
- - [What's Changed](#whats-changed-21)
+ - [What's Changed](#whats-changed-22)
- [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties)
- [Introducing Lazy Route Modules](#introducing-lazy-route-modules)
- [Minor Changes](#minor-changes-31)
- - [Patch Changes](#patch-changes-70)
- - [v6.8.2](#v682)
- [Patch Changes](#patch-changes-71)
- - [v6.8.1](#v681)
+ - [v6.8.2](#v682)
- [Patch Changes](#patch-changes-72)
+ - [v6.8.1](#v681)
+ - [Patch Changes](#patch-changes-73)
- [v6.8.0](#v680)
- [Minor Changes](#minor-changes-32)
- - [Patch Changes](#patch-changes-73)
+ - [Patch Changes](#patch-changes-74)
- [v6.7.0](#v670)
- [Minor Changes](#minor-changes-33)
- - [Patch Changes](#patch-changes-74)
- - [v6.6.2](#v662)
- [Patch Changes](#patch-changes-75)
- - [v6.6.1](#v661)
+ - [v6.6.2](#v662)
- [Patch Changes](#patch-changes-76)
+ - [v6.6.1](#v661)
+ - [Patch Changes](#patch-changes-77)
- [v6.6.0](#v660)
- - [What's Changed](#whats-changed-22)
+ - [What's Changed](#whats-changed-23)
- [Minor Changes](#minor-changes-34)
- - [Patch Changes](#patch-changes-77)
+ - [Patch Changes](#patch-changes-78)
- [v6.5.0](#v650)
- - [What's Changed](#whats-changed-23)
+ - [What's Changed](#whats-changed-24)
- [Minor Changes](#minor-changes-35)
- - [Patch Changes](#patch-changes-78)
- - [v6.4.5](#v645)
- [Patch Changes](#patch-changes-79)
- - [v6.4.4](#v644)
+ - [v6.4.5](#v645)
- [Patch Changes](#patch-changes-80)
- - [v6.4.3](#v643)
+ - [v6.4.4](#v644)
- [Patch Changes](#patch-changes-81)
- - [v6.4.2](#v642)
+ - [v6.4.3](#v643)
- [Patch Changes](#patch-changes-82)
- - [v6.4.1](#v641)
+ - [v6.4.2](#v642)
- [Patch Changes](#patch-changes-83)
+ - [v6.4.1](#v641)
+ - [Patch Changes](#patch-changes-84)
- [v6.4.0](#v640)
- - [What's Changed](#whats-changed-24)
+ - [What's Changed](#whats-changed-25)
- [Remix Data APIs](#remix-data-apis)
- - [Patch Changes](#patch-changes-84)
+ - [Patch Changes](#patch-changes-85)
- [v6.3.0](#v630)
- [Minor Changes](#minor-changes-36)
- [v6.2.2](#v622)
- - [Patch Changes](#patch-changes-85)
- - [v6.2.1](#v621)
- [Patch Changes](#patch-changes-86)
+ - [v6.2.1](#v621)
+ - [Patch Changes](#patch-changes-87)
- [v6.2.0](#v620)
- [Minor Changes](#minor-changes-37)
- - [Patch Changes](#patch-changes-87)
- - [v6.1.1](#v611)
- [Patch Changes](#patch-changes-88)
+ - [v6.1.1](#v611)
+ - [Patch Changes](#patch-changes-89)
- [v6.1.0](#v610)
- [Minor Changes](#minor-changes-38)
- - [Patch Changes](#patch-changes-89)
- - [v6.0.2](#v602)
- [Patch Changes](#patch-changes-90)
- - [v6.0.1](#v601)
+ - [v6.0.2](#v602)
- [Patch Changes](#patch-changes-91)
+ - [v6.0.1](#v601)
+ - [Patch Changes](#patch-changes-92)
- [v6.0.0](#v600)
@@ -367,6 +372,128 @@ 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.9.4
+
+Date: 2025-10-08
+
+### What's Changed
+
+#### `useRoute()` (unstable)
+
+This release includes a new `unstable_useRoute()` hook that provides a type-safe way to access route `loaderData`/`actionData` from a specific route in Framework Mode. Think if it like a better version of `useRouteLoaderData` that works with the typegen system and also supports `actionData`. Check out the changelog entry below for more information.
+
+### Patch Changes
+
+- `@react-router/dev` - Update `valibot` dependency to `^1.1.0` ([#14379](https://github.com/remix-run/react-router/pull/14379))
+- `@react-router/node` - Validate format of incoming session ids ([#14426](https://github.com/remix-run/react-router/pull/14426))
+
+### Unstable Changes
+
+⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_
+
+- `react-router` - handle external redirects in from server actions ([#14400](https://github.com/remix-run/react-router/pull/14400))
+- `react-router` - New (unstable) `useRoute` hook for accessing data from specific routes ([#14407](https://github.com/remix-run/react-router/pull/14407))
+
+ For example, let's say you have an `admin` route somewhere in your app and you want any child routes of `admin` to all have access to the `loaderData` and `actionData` from `admin.`
+
+ ```tsx
+ // app/routes/admin.tsx
+ import { Outlet } from "react-router";
+
+ export const loader = () => ({ message: "Hello, loader!" });
+
+ export const action = () => ({ count: 1 });
+
+ export default function Component() {
+ return (
+
+ {/* ... */}
+
+ {/* ... */}
+
+ );
+ }
+ ```
+
+ You might even want to create a reusable widget that all of the routes nested under `admin` could use:
+
+ ```tsx
+ import { unstable_useRoute as useRoute } from "react-router";
+
+ export function AdminWidget() {
+ // How to get `message` and `count` from `admin` route?
+ }
+ ```
+
+ In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/dmin");
+ // ^^^^^^^^^^^
+ }
+ ```
+
+ `useRoute` returns `undefined` if the route is not part of the current page:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ }
+ ```
+
+ Note: the `root` route is the exception since it is guaranteed to be part of the current page.
+ As a result, `useRoute` never returns `undefined` for `root`.
+
+ `loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ const { loaderData, actionData } = admin;
+ console.log(loaderData);
+ // ^? { message: string } | undefined
+ console.log(actionData);
+ // ^? { count: number } | undefined
+ }
+ ```
+
+ If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments:
+
+ ```tsx
+ export function AdminWidget() {
+ const currentRoute = useRoute();
+ currentRoute.loaderData;
+ currentRoute.actionData;
+ }
+ ```
+
+ This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`.
+
+ Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route.
+ As a result, `loaderData` and `actionData` are typed as `unknown`.
+ If you want more type-safety, you can either narrow the type yourself with something like `zod` or you can refactor your app to pass down typed props to your `AdminWidget`:
+
+ ```tsx
+ export function AdminWidget({
+ message,
+ count,
+ }: {
+ message: string;
+ count: number;
+ }) {
+ /* ... */
+ }
+ ```
+
+**Full Changelog**: [`v7.9.3...v7.9.4`](https://github.com/remix-run/react-router/compare/react-router@7.9.3...react-router@7.9.4)
+
## v7.9.3
Date: 2025-09-26
diff --git a/contributors.yml b/contributors.yml
index 7f8e26b2fa..0b1dee6722 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -169,6 +169,7 @@
- JackPriceBurns
- jacob-briscoe
- jacob-ebey
+- jadlr
- JaffParker
- jakkku
- JakubDrozd
diff --git a/integration/helpers/express.ts b/integration/helpers/express.ts
new file mode 100644
index 0000000000..ff6268c450
--- /dev/null
+++ b/integration/helpers/express.ts
@@ -0,0 +1,75 @@
+import tsx from "dedent";
+
+export function server() {
+ return tsx`
+ import { createRequestHandler } from "@react-router/express";
+ import express from "express";
+
+ const port = process.env.PORT ?? 3000
+ const hmrPort = process.env.HMR_PORT ?? 3001
+
+ const app = express();
+
+ const getLoadContext = () => ({});
+
+ if (process.env.NODE_ENV === "production") {
+ app.use(
+ "/assets",
+ express.static("build/client/assets", { immutable: true, maxAge: "1y" })
+ );
+ app.use(express.static("build/client", { maxAge: "1h" }));
+ app.all("*", createRequestHandler({
+ build: await import("./build/index.js"),
+ getLoadContext,
+ }));
+ } else {
+ const viteDevServer = await import("vite").then(
+ (vite) => vite.createServer({
+ server: {
+ middlewareMode: true,
+ hmr: { port: hmrPort },
+ },
+ })
+ );
+ app.use(viteDevServer.middlewares);
+ app.all("*", createRequestHandler({
+ build:() => viteDevServer.ssrLoadModule("virtual:react-router/server-build"),
+ getLoadContext,
+ }));
+ }
+
+ app.listen(port, () => console.log('http://localhost:' + port));
+ `;
+}
+
+export function rsc() {
+ return tsx`
+ import { createRequestListener } from "@mjackson/node-fetch-server";
+ import express from "express";
+
+ const port = process.env.PORT ?? 3000
+ const hmrPort = process.env.HMR_PORT ?? 3001
+
+ const app = express();
+
+ if (process.env.NODE_ENV === "production") {
+ app.use(
+ "/assets",
+ express.static("build/client/assets", { immutable: true, maxAge: "1y" })
+ );
+ app.all("*", createRequestListener((await import("./build/server/index.js")).default));
+ } else {
+ const viteDevServer = await import("vite").then(
+ (vite) => vite.createServer({
+ server: {
+ middlewareMode: true,
+ hmr: { port: hmrPort },
+ },
+ })
+ );
+ app.use(viteDevServer.middlewares);
+ }
+
+ app.listen(port, () => console.log('http://localhost:' + port));
+ `;
+}
diff --git a/integration/helpers/fixtures.ts b/integration/helpers/fixtures.ts
new file mode 100644
index 0000000000..ab644568ae
--- /dev/null
+++ b/integration/helpers/fixtures.ts
@@ -0,0 +1,138 @@
+import { ChildProcess } from "node:child_process";
+import * as fs from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+
+import { test as base } from "@playwright/test";
+import {
+ execa,
+ ExecaError,
+ type Options,
+ parseCommandString,
+ type ResultPromise,
+} from "execa";
+import * as Path from "pathe";
+
+import type { TemplateName } from "./vite.js";
+
+declare module "@playwright/test" {
+ interface Page {
+ errors: Error[];
+ }
+}
+
+const __filename = fileURLToPath(import.meta.url);
+const ROOT = Path.join(__filename, "../../..");
+const TMP = Path.join(ROOT, ".tmp/integration");
+const templatePath = (templateName: string) =>
+ Path.resolve(ROOT, "integration/helpers", templateName);
+
+type Edits = Record string)>;
+
+async function applyEdits(cwd: string, edits: Edits) {
+ const promises = Object.entries(edits).map(async ([file, transform]) => {
+ const filepath = Path.join(cwd, file);
+ await fs.writeFile(
+ filepath,
+ typeof transform === "function"
+ ? transform(await fs.readFile(filepath, "utf8"))
+ : transform,
+ "utf8",
+ );
+ return;
+ });
+ await Promise.all(promises);
+}
+
+export const test = base.extend<{
+ template: TemplateName;
+ files: Edits;
+ cwd: string;
+ edit: (edits: Edits) => Promise;
+ $: (
+ command: string,
+ options?: Pick,
+ ) => ResultPromise<{ reject: false }> & {
+ buffer: { stdout: string; stderr: string };
+ };
+}>({
+ template: ["vite-6-template", { option: true }],
+ files: [{}, { option: true }],
+ page: async ({ page }, use) => {
+ page.errors = [];
+ page.on("pageerror", (error: Error) => page.errors.push(error));
+ await use(page);
+ },
+
+ cwd: async ({ template, files }, use, testInfo) => {
+ await fs.mkdir(TMP, { recursive: true });
+ const cwd = await fs.mkdtemp(Path.join(TMP, template + "-"));
+ testInfo.attach("cwd", { body: cwd });
+
+ await fs.cp(templatePath(template), cwd, {
+ errorOnExist: true,
+ recursive: true,
+ });
+
+ await applyEdits(cwd, files);
+
+ await use(cwd);
+ },
+
+ edit: async ({ cwd }, use) => {
+ await use(async (edits) => applyEdits(cwd, edits));
+ },
+
+ $: async ({ cwd }, use) => {
+ const spawn = execa({
+ cwd,
+ env: {
+ NO_COLOR: "1",
+ FORCE_COLOR: "0",
+ },
+ reject: false,
+ });
+
+ let testHasEnded = false;
+ const processes: Array = [];
+ const unexpectedErrors: Array = [];
+
+ await use((command, options = {}) => {
+ const [file, ...args] = parseCommandString(command);
+
+ const p = spawn(file, args, options);
+ if (p instanceof ChildProcess) {
+ processes.push(p);
+ }
+
+ p.then((result) => {
+ if (!(result instanceof Error)) return result;
+
+ // Once the test has ended, this process will be killed as part of its teardown resulting in an ExecaError.
+ // We only care about surfacing errors that occurred during test execution, not during teardown.
+ const expectedError = testHasEnded && result instanceof ExecaError;
+ if (expectedError) return result;
+ unexpectedErrors.push(result);
+ });
+
+ const buffer = { stdout: "", stderr: "" };
+ p.stdout?.on("data", (data) => (buffer.stdout += data.toString()));
+ p.stderr?.on("data", (data) => (buffer.stderr += data.toString()));
+ return Object.assign(p, { buffer });
+ });
+
+ testHasEnded = true;
+ processes.forEach((p) => p.kill());
+
+ // Throw any unexpected errors that occurred during test execution
+ if (unexpectedErrors.length > 0) {
+ const errorMessage =
+ unexpectedErrors.length === 1
+ ? `Unexpected process error: ${unexpectedErrors[0].message}`
+ : `${unexpectedErrors.length} unexpected process errors:\n${unexpectedErrors.map((e, i) => `${i + 1}. ${e.message}`).join("\n")}`;
+
+ const error = new Error(errorMessage);
+ error.stack = unexpectedErrors[0].stack;
+ throw error;
+ }
+ },
+});
diff --git a/integration/helpers/stream.ts b/integration/helpers/stream.ts
new file mode 100644
index 0000000000..2b9fe49c49
--- /dev/null
+++ b/integration/helpers/stream.ts
@@ -0,0 +1,29 @@
+import type { Readable } from "node:stream";
+
+export async function match(
+ stream: Readable,
+ pattern: string | RegExp,
+ options: {
+ /** Measured in ms */
+ timeout?: number;
+ } = {},
+): Promise {
+ // Prepare error outside of promise so that stacktrace points to caller of `matchLine`
+ const timeoutError = new Error(
+ `Timed out - Could not find pattern: ${pattern}`,
+ );
+ return new Promise(async (resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(timeoutError),
+ options.timeout ?? 10_000,
+ );
+ stream.on("data", (data) => {
+ const line: string = data.toString();
+ const matches = line.match(pattern);
+ if (matches) {
+ resolve(matches);
+ clearTimeout(timeout);
+ }
+ });
+ });
+}
diff --git a/integration/helpers/templates.ts b/integration/helpers/templates.ts
new file mode 100644
index 0000000000..6e580c5ba9
--- /dev/null
+++ b/integration/helpers/templates.ts
@@ -0,0 +1,30 @@
+const templates = [
+ // Vite Major templates
+ { name: "vite-5-template", displayName: "Vite 5" },
+ { name: "vite-6-template", displayName: "Vite 6" },
+ { name: "vite-7-beta-template", displayName: "Vite 7 Beta" },
+ { name: "vite-rolldown-template", displayName: "Vite Rolldown" },
+
+ // RSC templates
+ { name: "rsc-vite", displayName: "RSC (Vite)" },
+ { name: "rsc-parcel", displayName: "RSC (Parcel)" },
+ { name: "rsc-vite-framework", displayName: "RSC Framework" },
+
+ // Cloudflare
+ // { name: "cloudflare-dev-proxy-template", displayName: "Cloudflare Dev Proxy" },
+ { name: "vite-plugin-cloudflare-template", displayName: "Cloudflare" },
+] as const;
+
+export type Template = (typeof templates)[number];
+
+export function getTemplates(names?: Array) {
+ if (names === undefined) return templates;
+ return templates.filter(({ name }) => names.includes(name));
+}
+
+export const viteMajorTemplates = getTemplates([
+ "vite-5-template",
+ "vite-6-template",
+ "vite-7-beta-template",
+ "vite-rolldown-template",
+]);
diff --git a/integration/package.json b/integration/package.json
index 824fed2598..fb81ff14d5 100644
--- a/integration/package.json
+++ b/integration/package.json
@@ -25,7 +25,7 @@
"cheerio": "^1.0.0-rc.12",
"cross-spawn": "^7.0.3",
"dedent": "^0.7.0",
- "execa": "^5.1.1",
+ "execa": "^9.6.0",
"express": "^4.19.2",
"get-port": "^5.1.1",
"glob": "8.0.3",
diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts
index 7ddafe7421..9ddff47d05 100644
--- a/integration/rsc/rsc-test.ts
+++ b/integration/rsc/rsc-test.ts
@@ -435,6 +435,17 @@ implementations.forEach((implementation) => {
}
]
},
+ {
+ id: "throw-external-redirect-server-action",
+ path: "throw-external-redirect-server-action",
+ children: [
+ {
+ id: "throw-external-redirect-server-action.home",
+ index: true,
+ lazy: () => import("./routes/throw-external-redirect-server-action/home"),
+ }
+ ]
+ },
{
id: "side-effect-redirect-server-action",
path: "side-effect-redirect-server-action",
@@ -446,6 +457,17 @@ implementations.forEach((implementation) => {
}
]
},
+ {
+ id: "side-effect-external-redirect-server-action",
+ path: "side-effect-external-redirect-server-action",
+ children: [
+ {
+ id: "side-effect-external-redirect-server-action.home",
+ index: true,
+ lazy: () => import("./routes/side-effect-external-redirect-server-action/home"),
+ }
+ ]
+ },
{
id: "server-function-reference",
path: "server-function-reference",
@@ -986,6 +1008,82 @@ implementations.forEach((implementation) => {
);
}
`,
+ "src/routes/throw-external-redirect-server-action/home.actions.ts": js`
+ "use server";
+ import { redirect } from "react-router";
+
+ export async function redirectAction(formData: FormData) {
+ // Throw a redirect to an external URL
+ throw redirect("https://example.com/");
+ }
+ `,
+ "src/routes/throw-external-redirect-server-action/home.client.tsx": js`
+ "use client";
+
+ import { useState } from "react";
+
+ export function Counter() {
+ const [count, setCount] = useState(0);
+ return ;
+ }
+ `,
+ "src/routes/throw-external-redirect-server-action/home.tsx": js`
+ import { redirectAction } from "./home.actions";
+ import { Counter } from "./home.client";
+
+ export default function HomeRoute(props) {
+ return (
+
+
+
+
+ );
+ }
+ `,
+ "src/routes/side-effect-external-redirect-server-action/home.actions.ts": js`
+ "use server";
+ import { redirect } from "react-router";
+
+ export async function redirectAction() {
+ // Perform a side-effect redirect to an external URL
+ redirect("https://example.com/", { headers: { "x-test": "test" } });
+ return "redirected";
+ }
+ `,
+ "src/routes/side-effect-external-redirect-server-action/home.client.tsx": js`
+ "use client";
+ import { useState } from "react";
+
+ export function Counter() {
+ const [count, setCount] = useState(0);
+ return ;
+ }
+ `,
+ "src/routes/side-effect-external-redirect-server-action/home.tsx": js`
+ "use client";
+ import {useActionState} from "react";
+ import { redirectAction } from "./home.actions";
+ import { Counter } from "./home.client";
+
+ export default function HomeRoute(props) {
+ const [state, action] = useActionState(redirectAction, null);
+ return (
+
+
+ {state &&
{state}
}
+
+
+ );
+ }
+ `,
"src/routes/server-function-reference/home.actions.ts": js`
"use server";
@@ -1736,6 +1834,33 @@ implementations.forEach((implementation) => {
validateRSCHtml(await page.content());
});
+ test("Supports React Server Functions thrown external redirects", async ({
+ page,
+ }) => {
+ // Test is expected to fail currently — skip running it
+ // test.skip(true, "Known failing test for external redirect behavior");
+
+ await page.goto(
+ `http://localhost:${port}/throw-external-redirect-server-action/`,
+ );
+
+ // Verify initial server render
+ await page.waitForSelector("[data-count]");
+ expect(await page.locator("[data-count]").textContent()).toBe(
+ "Count: 0",
+ );
+ await page.click("[data-count]");
+ expect(await page.locator("[data-count]").textContent()).toBe(
+ "Count: 1",
+ );
+
+ // Submit the form to trigger server function redirect to external URL
+ await page.click("[data-submit]");
+
+ // We expect the browser to navigate to the external site (example.com)
+ await expect(page).toHaveURL(`https://example.com/`);
+ });
+
test("Supports React Server Functions side-effect redirects", async ({
page,
}) => {
@@ -1789,6 +1914,46 @@ implementations.forEach((implementation) => {
validateRSCHtml(await page.content());
});
+ test("Supports React Server Functions side-effect external redirects", async ({
+ page,
+ }) => {
+ // Test is expected to fail currently — skip running it
+ test.skip(implementation.name === "parcel", "Not working in parcel?");
+
+ await page.goto(
+ `http://localhost:${port}/side-effect-external-redirect-server-action`,
+ );
+
+ // Verify initial server render
+ await page.waitForSelector("[data-count]");
+ expect(await page.locator("[data-count]").textContent()).toBe(
+ "Count: 0",
+ );
+ await page.click("[data-count]");
+ expect(await page.locator("[data-count]").textContent()).toBe(
+ "Count: 1",
+ );
+
+ const responseHeadersPromise = new Promise>(
+ (resolve) => {
+ page.addListener("response", (response) => {
+ if (response.request().method() === "POST") {
+ resolve(response.headers());
+ }
+ });
+ },
+ );
+
+ // Submit the form to trigger server function redirect to external URL
+ await page.click("[data-submit]");
+
+ // We expect the browser to navigate to the external site (example.com)
+ await expect(page).toHaveURL(`https://example.com/`);
+
+ // Optionally assert that the server sent the header
+ expect((await responseHeadersPromise)["x-test"]).toBe("test");
+ });
+
test("Supports React Server Function References", async ({ page }) => {
await page.goto(`http://localhost:${port}/server-function-reference`);
diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts
index 9bcd7ac928..9ffcc5e183 100644
--- a/integration/typegen-test.ts
+++ b/integration/typegen-test.ts
@@ -1,31 +1,16 @@
-import { spawnSync } from "node:child_process";
-import { mkdirSync, renameSync } from "node:fs";
-import { readFile, writeFile } from "node:fs/promises";
-import * as path from "node:path";
+import fs from "node:fs/promises";
-import { expect, test } from "@playwright/test";
-import dedent from "dedent";
+import tsx from "dedent";
+import * as Path from "pathe";
-import { createProject } from "./helpers/vite";
+import { test } from "./helpers/fixtures";
-const tsx = dedent;
-
-const nodeBin = process.argv[0];
-const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js";
-const tscBin = "node_modules/typescript/bin/tsc";
-
-function typecheck(cwd: string) {
- const typegen = spawnSync(nodeBin, [reactRouterBin, "typegen"], { cwd });
- expect(typegen.stdout.toString()).toBe("");
- expect(typegen.stderr.toString()).toBe("");
- expect(typegen.status).toBe(0);
-
- return spawnSync(nodeBin, [tscBin], { cwd });
-}
-
-const viteConfig = ({ rsc }: { rsc: boolean } = { rsc: false }) => {
+const viteConfig = ({ rsc }: { rsc: boolean }) => {
+ const reactRouterImportSpecifier = rsc
+ ? "unstable_reactRouterRSC as reactRouter"
+ : "reactRouter";
return tsx`
- import { ${rsc ? "unstable_reactRouterRSC as reactRouter" : "reactRouter"} } from "@react-router/dev/vite";
+ import { ${reactRouterImportSpecifier} } from "@react-router/dev/vite";
export default {
plugins: [reactRouter()],
@@ -33,19 +18,22 @@ const viteConfig = ({ rsc }: { rsc: boolean } = { rsc: false }) => {
`;
};
-const expectType = tsx`
- export type Expect = T
-
- export type Equal =
- (() => T extends X ? 1 : 2) extends
- (() => T extends Y ? 1 : 2) ? true : false
-`;
+test.use({
+ files: {
+ "vite.config.ts": viteConfig({ rsc: false }),
+ "app/expect-type.ts": tsx`
+ export type Expect = T
+
+ export type Equal =
+ (() => T extends X ? 1 : 2) extends
+ (() => T extends Y ? 1 : 2) ? true : false
+ `,
+ },
+});
test.describe("typegen", () => {
- test("basic", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("basic", async ({ edit, $ }) => {
+ await edit({
"app/routes.ts": tsx`
import { type RouteConfig, route } from "@react-router/dev/routes";
@@ -68,237 +56,208 @@ test.describe("typegen", () => {
}
`,
});
-
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ await $("pnpm typecheck");
});
- test.describe("params", () => {
- test("repeated", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
-
- export default [
- route("only-required/:id/:id", "routes/only-required.tsx"),
- route("only-optional/:id?/:id?", "routes/only-optional.tsx"),
- route("optional-then-required/:id?/:id", "routes/optional-then-required.tsx"),
- route("required-then-optional/:id/:id?", "routes/required-then-optional.tsx"),
- ] satisfies RouteConfig;
- `,
- "app/routes/only-required.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/only-required"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/only-optional.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/only-optional"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/optional-then-required.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/optional-then-required"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/required-then-optional.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/required-then-optional"
-
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test("splat", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
+ test("repeated params", async ({ edit, $ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
- export default [
- route("splat/*", "routes/splat.tsx")
- ] satisfies RouteConfig;
- `,
- "app/routes/splat.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/splat"
+ export default [
+ route("only-required/:id/:id", "routes/only-required.tsx"),
+ route("only-optional/:id?/:id?", "routes/only-optional.tsx"),
+ route("optional-then-required/:id?/:id", "routes/optional-then-required.tsx"),
+ route("required-then-optional/:id/:id?", "routes/required-then-optional.tsx"),
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/only-required.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/only-required"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/only-optional.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/only-optional"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/optional-then-required.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/optional-then-required"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/required-then-optional.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/required-then-optional"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
});
+ await $("pnpm typecheck");
+ });
- test("with extension", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
+ test("params with extension", async ({ edit, $ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
- export default [
- route(":lang.xml", "routes/param-with-ext.tsx"),
- route(":user?.pdf", "routes/optional-param-with-ext.tsx"),
- ] satisfies RouteConfig;
- `,
- "app/routes/param-with-ext.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/param-with-ext"
+ export default [
+ route(":lang.xml", "routes/param-with-ext.tsx"),
+ route(":user?.pdf", "routes/optional-param-with-ext.tsx"),
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/param-with-ext.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/param-with-ext"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/optional-param-with-ext.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/optional-param-with-ext"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/optional-param-with-ext.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/optional-param-with-ext"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
});
+ await $("pnpm typecheck");
+ });
- test("normalized params", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route, layout } from "@react-router/dev/routes";
+ test("normalized param", async ({ edit, $ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route, layout } from "@react-router/dev/routes";
- export default [
- route("parent/:p", "routes/parent.tsx", [
- route("route/:r", "routes/route.tsx", [
- route("child1/:c1a/:c1b", "routes/child1.tsx"),
- route("child2/:c2a/:c2b", "routes/child2.tsx")
- ]),
+ export default [
+ route("parent/:p", "routes/parent.tsx", [
+ route("route/:r", "routes/route.tsx", [
+ route("child1/:c1a/:c1b", "routes/child1.tsx"),
+ route("child2/:c2a/:c2b", "routes/child2.tsx")
]),
- layout("routes/layout.tsx", [
- route("in-layout1/:id", "routes/in-layout1.tsx"),
- route("in-layout2/:id/:other", "routes/in-layout2.tsx")
- ])
- ] satisfies RouteConfig;
- `,
- "app/routes/parent.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/parent"
+ ]),
+ layout("routes/layout.tsx", [
+ route("in-layout1/:id", "routes/in-layout1.tsx"),
+ route("in-layout2/:id/:other", "routes/in-layout2.tsx")
+ ])
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/parent.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/parent"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/route.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/route"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/route.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/route"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/child1.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/child1"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/child1.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/child1"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/child2.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/child2"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/child2.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/child2"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/layout.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/layout"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/layout.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/layout"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/in-layout1.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/in-layout1"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/in-layout1.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/in-layout1"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- "app/routes/in-layout2.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/in-layout2"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/in-layout2.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/in-layout2"
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return null
- }
- `,
- });
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ });
+ await $("pnpm typecheck");
+ });
+
+ test("splat", async ({ edit, $ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
+
+ export default [
+ route("splat/*", "routes/splat.tsx")
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/splat.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/splat"
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
});
+ await $("pnpm typecheck");
});
- test("clientLoader.hydrate = true", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("clientLoader.hydrate = true", async ({ edit, $ }) => {
+ await edit({
"app/routes/_index.tsx": tsx`
import type { Expect, Equal } from "../expect-type"
import type { Route } from "./+types/_index"
@@ -322,16 +281,11 @@ test.describe("typegen", () => {
}
`,
});
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ await $("pnpm typecheck");
});
- test("clientLoader data should not be serialized", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("clientLoader data should not be serialized", async ({ edit, $ }) => {
+ await edit({
"app/routes/_index.tsx": tsx`
import { useRouteLoaderData } from "react-router"
@@ -352,360 +306,67 @@ test.describe("typegen", () => {
}
`,
});
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ await $("pnpm typecheck");
});
- test.describe("server-first route component detection", async () => {
- test.describe("ServerComponent export", async () => {
- test("when RSC Framework Mode plugin is present", async () => {
- const cwd = await await createProject({
- "vite.config.ts": viteConfig({ rsc: true }),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
+ test("custom app dir", async ({ cwd, edit, $ }) => {
+ await edit({
+ "react-router.config.ts": tsx`
+ export default {
+ appDirectory: "src/myapp",
+ }
+ `,
+ "app/routes/products.$id.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/products.$id"
- export default [
- route("server-component/:id", "routes/server-component.tsx")
- ] satisfies RouteConfig;
- `,
- "app/routes/server-component.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/server-component"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return { planet: "world" }
+ }
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return { server: "server" }
- }
+ export default function Component({ loaderData }: Route.ComponentProps) {
+ type Test = Expect>
+ return Hello, {loaderData.planet}!
+ }
+ `,
+ });
+ await fs.mkdir(Path.join(cwd, "src"));
+ await fs.rename(Path.join(cwd, "app"), Path.join(cwd, "src/myapp"));
+ await $("pnpm typecheck");
+ });
- export function clientLoader() {
- return { client: "client" }
- }
+ test("matches", async ({ edit, $ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
- export function action() {
- return { server: "server" }
- }
+ export default [
+ route("parent1/:parent1", "routes/parent1.tsx", [
+ route("parent2/:parent2", "routes/parent2.tsx", [
+ route("current", "routes/current.tsx")
+ ])
+ ])
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/parent1.tsx": tsx`
+ import { Outlet } from "react-router"
- export function clientAction() {
- return { client: "client" }
- }
+ export function loader() {
+ return { parent1: 1 }
+ }
- export function ServerComponent({
- loaderData,
- actionData
- }: Route.ComponentProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- ServerComponent
- Loader data: {loaderData.server}
- Action data: {actionData?.server}
- >
- )
- }
-
- export function ErrorBoundary({
- loaderData,
- actionData
- }: Route.ErrorBoundaryProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- ErrorBoundary
- Loader data: {loaderData?.server}
- Action data: {actionData?.server}
- >
- )
- }
-
- export function HydrateFallback({
- loaderData,
- actionData
- }: Route.HydrateFallbackProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- HydrateFallback
- Loader data: {loaderData?.server}
- Action data: {actionData?.server}
- >
- )
- }
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test("when RSC Framework Mode plugin is not present", async () => {
- const cwd = await await createProject({
- "vite.config.ts": viteConfig({ rsc: false }),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
-
- export default [
- route("server-component/:id", "routes/server-component.tsx")
- ] satisfies RouteConfig;
- `,
- "app/routes/server-component.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/server-component"
-
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return { server: "server" }
- }
-
- export function clientLoader() {
- return { client: "client" }
- }
-
- export function action() {
- return { server: "server" }
- }
-
- export function clientAction() {
- return { client: "client" }
- }
-
- // 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
- }: Route.ComponentProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- ServerComponent (unused)
- Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
-
- export function ErrorBoundary({
- loaderData,
- actionData
- }: Route.ErrorBoundaryProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- ErrorBoundary
- {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
-
- export function HydrateFallback({
- loaderData,
- actionData
- }: Route.HydrateFallbackProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- HydrateFallback
- {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
- });
-
- test.describe("default export", async () => {
- async function createClientFirstRouteProject({ rsc }: { rsc: boolean }) {
- return await await createProject({
- "vite.config.ts": viteConfig({ rsc }),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
-
- export default [
- route("client-component/:id", "routes/client-component.tsx")
- ] satisfies RouteConfig;
- `,
- "app/routes/client-component.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/client-component"
-
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return { server: "server" }
- }
-
- export function clientLoader() {
- return { client: "client" }
- }
-
- export function action() {
- return { server: "server" }
- }
-
- export function clientAction() {
- return { client: "client" }
- }
-
- export default function ClientComponent({
- loaderData,
- actionData
- }: Route.ComponentProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- default (Component)
- Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
-
- export function ErrorBoundary({
- loaderData,
- actionData
- }: Route.ErrorBoundaryProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- ErrorBoundary
- {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
-
- export function HydrateFallback({
- loaderData,
- actionData
- }: Route.HydrateFallbackProps) {
- type TestLoaderData = Expect>
- type TestActionData = Expect>
-
- return (
- <>
- HydrateFallback
- {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
- {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
- >
- )
- }
- `,
- });
- }
-
- test("when RSC Framework Mode plugin is present", async () => {
- const cwd = await createClientFirstRouteProject({ rsc: true });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test("when RSC Framework Mode plugin is not present", async () => {
- const cwd = await createClientFirstRouteProject({ rsc: false });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
- });
- });
-
- test("custom app dir", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "react-router.config.ts": tsx`
- export default {
- appDirectory: "src/myapp",
- }
- `,
- "app/expect-type.ts": expectType,
- "app/routes/products.$id.tsx": tsx`
- import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/products.$id"
-
- export function loader({ params }: Route.LoaderArgs) {
- type Test = Expect>
- return { planet: "world" }
- }
-
- export default function Component({ loaderData }: Route.ComponentProps) {
- type Test = Expect>
- return Hello, {loaderData.planet}!
- }
- `,
- });
- mkdirSync(path.join(cwd, "src"));
- renameSync(path.join(cwd, "app"), path.join(cwd, "src/myapp"));
-
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test("matches", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
- "app/routes.ts": tsx`
- import { type RouteConfig, route } from "@react-router/dev/routes";
-
- export default [
- route("parent1/:parent1", "routes/parent1.tsx", [
- route("parent2/:parent2", "routes/parent2.tsx", [
- route("current", "routes/current.tsx")
- ])
- ])
- ] satisfies RouteConfig;
- `,
- "app/routes/parent1.tsx": tsx`
- import { Outlet } from "react-router"
-
- export function loader() {
- return { parent1: 1 }
- }
-
- export default function Component() {
- return (
-
- )
- }
- `,
- "app/routes/parent2.tsx": tsx`
- import { Outlet } from "react-router"
+ export default function Component() {
+ return (
+
+ )
+ }
+ `,
+ "app/routes/parent2.tsx": tsx`
+ import { Outlet } from "react-router"
export function loader() {
return { parent2: 2 }
@@ -764,16 +425,11 @@ test.describe("typegen", () => {
}
`,
});
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ await $("pnpm typecheck");
});
- test("route files with absolute paths", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("route files with absolute paths", async ({ edit, $ }) => {
+ await edit({
"app/routes.ts": tsx`
import path from "node:path";
import { type RouteConfig, route } from "@react-router/dev/routes";
@@ -797,17 +453,11 @@ test.describe("typegen", () => {
}
`,
});
-
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ await $("pnpm typecheck");
});
- test("href", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("href", async ({ edit, $ }) => {
+ await edit({
"app/routes.ts": tsx`
import path from "node:path";
import { type RouteConfig, route } from "@react-router/dev/routes";
@@ -855,101 +505,23 @@ test.describe("typegen", () => {
// @ts-expect-error
href("/optional-param")
- // @ts-expect-error
- href("/optional-param/:opt", { opt: "hello" })
- href("/optional-param/:opt?")
- href("/optional-param/:opt?", { opt: "hello" })
-
- href("/leading-and-trailing-slash")
- // @ts-expect-error
- href("/leading-and-trailing-slash/")
-
- export default function Component() {}
- `,
- });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test.describe("virtual:react-router/server-build", async () => {
- test("static import matches 'createRequestHandler' argument type", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/routes.ts": tsx`
- import { type RouteConfig } from "@react-router/dev/routes";
- export default [] satisfies RouteConfig;
- `,
- "app/handler.ts": tsx`
- import { createRequestHandler } from "react-router";
- import * as serverBuild from "virtual:react-router/server-build";
- export default createRequestHandler(serverBuild);
- `,
- });
-
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
-
- test("works with tsconfig 'moduleDetection' set to 'force'", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/routes.ts": tsx`
- import { type RouteConfig } from "@react-router/dev/routes";
- export default [] satisfies RouteConfig;
- `,
- "app/handler.ts": tsx`
- import { createRequestHandler } from "react-router";
- import * as serverBuild from "virtual:react-router/server-build";
- export default createRequestHandler(serverBuild);
- `,
- });
-
- const tsconfig = JSON.parse(
- await readFile(path.join(cwd, "tsconfig.json"), "utf-8"),
- );
- tsconfig.compilerOptions.moduleDetection = "force";
- await writeFile(
- path.join(cwd, "tsconfig.json"),
- JSON.stringify(tsconfig),
- "utf-8",
- );
-
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
- });
+ // @ts-expect-error
+ href("/optional-param/:opt", { opt: "hello" })
+ href("/optional-param/:opt?")
+ href("/optional-param/:opt?", { opt: "hello" })
- test("dynamic import matches 'createRequestHandler' function argument type", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/routes.ts": tsx`
- import { type RouteConfig } from "@react-router/dev/routes";
- export default [] satisfies RouteConfig;
- `,
- "app/handler.ts": tsx`
- import { createRequestHandler } from "react-router";
- export default createRequestHandler(
- () => import("virtual:react-router/server-build")
- );
- `,
- });
+ href("/leading-and-trailing-slash")
+ // @ts-expect-error
+ href("/leading-and-trailing-slash/")
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ export default function Component() {}
+ `,
});
+ await $("pnpm typecheck");
});
- test("reuse route file at multiple paths", async () => {
- const cwd = await createProject({
- "vite.config.ts": viteConfig(),
- "app/expect-type.ts": expectType,
+ test("reuse route file at multiple paths", async ({ edit, $ }) => {
+ await edit({
"app/routes.ts": tsx`
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
@@ -1021,10 +593,344 @@ test.describe("typegen", () => {
}
`,
});
+ await $("pnpm typecheck");
+ });
+
+ test.describe("virtual:react-router/server-build", async () => {
+ test("static import matches 'createRequestHandler' argument type", async ({
+ edit,
+ $,
+ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig } from "@react-router/dev/routes";
+ export default [] satisfies RouteConfig;
+ `,
+ "app/handler.ts": tsx`
+ import { createRequestHandler } from "react-router";
+ import * as serverBuild from "virtual:react-router/server-build";
+ export default createRequestHandler(serverBuild);
+ `,
+ });
+ await $("pnpm typecheck");
+ });
+
+ test("works with tsconfig 'moduleDetection' set to 'force'", async ({
+ edit,
+ $,
+ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig } from "@react-router/dev/routes";
+ export default [] satisfies RouteConfig;
+ `,
+ "app/handler.ts": tsx`
+ import { createRequestHandler } from "react-router";
+ import * as serverBuild from "virtual:react-router/server-build";
+ export default createRequestHandler(serverBuild);
+ `,
+ "tsconfig.json": (contents) => {
+ const tsconfig = JSON.parse(contents);
+ tsconfig.compilerOptions.moduleDetection = "force";
+ return JSON.stringify(tsconfig, null, 2);
+ },
+ });
+ await $("pnpm typecheck");
+ });
+
+ test("dynamic import matches 'createRequestHandler' function argument type", async ({
+ edit,
+ $,
+ }) => {
+ await edit({
+ "app/routes.ts": tsx`
+ import { type RouteConfig } from "@react-router/dev/routes";
+ export default [] satisfies RouteConfig;
+ `,
+ "app/handler.ts": tsx`
+ import { createRequestHandler } from "react-router";
+ export default createRequestHandler(
+ () => import("virtual:react-router/server-build")
+ );
+ `,
+ });
+ await $("pnpm typecheck");
+ });
+ });
+
+ test.describe("server-first route component detection", () => {
+ test.describe("ServerComponent export", () => {
+ test("when RSC Framework Mode plugin is present", async ({ edit, $ }) => {
+ await edit({
+ "vite.config.ts": viteConfig({ rsc: true }),
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
+
+ export default [
+ route("server-component/:id", "routes/server-component.tsx")
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/server-component.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/server-component"
+
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return { server: "server" }
+ }
+
+ export function clientLoader() {
+ return { client: "client" }
+ }
+
+ export function action() {
+ return { server: "server" }
+ }
+
+ export function clientAction() {
+ return { client: "client" }
+ }
+
+ export function ServerComponent({
+ loaderData,
+ actionData
+ }: Route.ComponentProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ ServerComponent
+ Loader data: {loaderData.server}
+ Action data: {actionData?.server}
+ >
+ )
+ }
+
+ export function ErrorBoundary({
+ loaderData,
+ actionData
+ }: Route.ErrorBoundaryProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ ErrorBoundary
+ Loader data: {loaderData?.server}
+ Action data: {actionData?.server}
+ >
+ )
+ }
+
+ export function HydrateFallback({
+ loaderData,
+ actionData
+ }: Route.HydrateFallbackProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ HydrateFallback
+ Loader data: {loaderData?.server}
+ Action data: {actionData?.server}
+ >
+ )
+ }
+ `,
+ });
+ await $("pnpm typecheck");
+ });
+
+ test("when RSC Framework Mode plugin is not present", async ({
+ edit,
+ $,
+ }) => {
+ await edit({
+ "vite.config.ts": viteConfig({ rsc: false }),
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
+
+ export default [
+ route("server-component/:id", "routes/server-component.tsx")
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/server-component.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/server-component"
+
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return { server: "server" }
+ }
+
+ export function clientLoader() {
+ return { client: "client" }
+ }
+
+ export function action() {
+ return { server: "server" }
+ }
+
+ export function clientAction() {
+ return { client: "client" }
+ }
+
+ // 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
+ }: Route.ComponentProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ ServerComponent (unused)
+ Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+
+ export function ErrorBoundary({
+ loaderData,
+ actionData
+ }: Route.ErrorBoundaryProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ ErrorBoundary
+ {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+
+ export function HydrateFallback({
+ loaderData,
+ actionData
+ }: Route.HydrateFallbackProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ HydrateFallback
+ {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+ `,
+ });
+ await $("pnpm typecheck");
+ });
+ });
- const proc = typecheck(cwd);
- expect(proc.stdout.toString()).toBe("");
- expect(proc.stderr.toString()).toBe("");
- expect(proc.status).toBe(0);
+ test.describe("default export", () => {
+ const clientFirstRouteFiles = {
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes";
+
+ export default [
+ route("client-component/:id", "routes/client-component.tsx")
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/client-component.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/client-component"
+
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return { server: "server" }
+ }
+
+ export function clientLoader() {
+ return { client: "client" }
+ }
+
+ export function action() {
+ return { server: "server" }
+ }
+
+ export function clientAction() {
+ return { client: "client" }
+ }
+
+ export default function ClientComponent({
+ loaderData,
+ actionData
+ }: Route.ComponentProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ default (Component)
+ Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+
+ export function ErrorBoundary({
+ loaderData,
+ actionData
+ }: Route.ErrorBoundaryProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ ErrorBoundary
+ {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+
+ export function HydrateFallback({
+ loaderData,
+ actionData
+ }: Route.HydrateFallbackProps) {
+ type TestLoaderData = Expect>
+ type TestActionData = Expect>
+
+ return (
+ <>
+ HydrateFallback
+ {loaderData && Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}
}
+ {actionData && Action data: {"server" in actionData ? actionData.server : actionData.client}
}
+ >
+ )
+ }
+ `,
+ };
+
+ test("when RSC Framework Mode plugin is present", async ({ edit, $ }) => {
+ await edit({
+ "vite.config.ts": viteConfig({ rsc: true }),
+ ...clientFirstRouteFiles,
+ });
+ await $("pnpm typecheck");
+ });
+
+ test("when RSC Framework Mode plugin is not present", async ({
+ edit,
+ $,
+ }) => {
+ await edit({
+ "vite.config.ts": viteConfig({ rsc: false }),
+ ...clientFirstRouteFiles,
+ });
+ await $("pnpm typecheck");
+ });
+ });
});
});
diff --git a/integration/use-route-test.ts b/integration/use-route-test.ts
new file mode 100644
index 0000000000..44b3bca2cb
--- /dev/null
+++ b/integration/use-route-test.ts
@@ -0,0 +1,118 @@
+import tsx from "dedent";
+import { expect } from "@playwright/test";
+
+import { test } from "./helpers/fixtures";
+import * as Stream from "./helpers/stream";
+import getPort from "get-port";
+
+test.use({
+ files: {
+ "app/expect-type.ts": tsx`
+ export type Expect = T
+
+ export type Equal =
+ (() => T extends X ? 1 : 2) extends
+ (() => T extends Y ? 1 : 2) ? true : false
+ `,
+ "app/routes.ts": tsx`
+ import { type RouteConfig, route } from "@react-router/dev/routes"
+
+ export default [
+ route("parent", "routes/parent.tsx", [
+ route("current", "routes/current.tsx")
+ ]),
+ route("other", "routes/other.tsx"),
+ ] satisfies RouteConfig
+ `,
+ "app/root.tsx": tsx`
+ import { Outlet } from "react-router"
+
+ export const loader = () => ({ rootLoader: "root/loader" })
+ export const action = () => ({ rootAction: "root/action" })
+
+ export default function Component() {
+ return (
+ <>
+ Root
+
+ >
+ )
+ }
+ `,
+ "app/routes/parent.tsx": tsx`
+ import { Outlet } from "react-router"
+
+ export const loader = () => ({ parentLoader: "parent/loader" })
+ export const action = () => ({ parentAction: "parent/action" })
+
+ export default function Component() {
+ return (
+ <>
+ Parent
+
+ >
+ )
+ }
+ `,
+ "app/routes/current.tsx": tsx`
+ import { unstable_useRoute as useRoute } from "react-router"
+
+ import type { Expect, Equal } from "../expect-type"
+
+ export const loader = () => ({ currentLoader: "current/loader" })
+ export const action = () => ({ currentAction: "current/action" })
+
+ export default function Component() {
+ const current = useRoute()
+ type Test1 = Expect>
+
+ const root = useRoute("root")
+ type Test2 = Expect>
+
+ const parent = useRoute("routes/parent")
+ type Test3 = Expect>
+
+ const other = useRoute("routes/other")
+ type Test4 = Expect>
+
+ return (
+ <>
+ {root.loaderData?.rootLoader}
+ {parent?.loaderData?.parentLoader}
+ {/* @ts-expect-error */}
+ {current?.loaderData?.currentLoader}
+ {other === undefined ? "undefined" : "something else"}
+ >
+ )
+ }
+ `,
+ "app/routes/other.tsx": tsx`
+ export const loader = () => ({ otherLoader: "other/loader" })
+ export const action = () => ({ otherAction: "other/action" })
+
+ export default function Component() {
+ return Other
+ }
+ `,
+ },
+});
+
+test("useRoute", async ({ $, page }) => {
+ await $("pnpm typecheck");
+
+ const port = await getPort();
+ const url = `http://localhost:${port}`;
+
+ const dev = $(`pnpm dev --port ${port}`);
+ await Stream.match(dev.stdout, url);
+
+ await page.goto(url + "/parent/current", { waitUntil: "networkidle" });
+
+ await expect(page.locator("[data-root]")).toHaveText("root/loader");
+
+ await expect(page.locator("[data-parent]")).toHaveText("parent/loader");
+
+ await expect(page.locator("[data-current]")).toHaveText("current/loader");
+
+ await expect(page.locator("[data-other]")).toHaveText("undefined");
+});
diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts
index 59bab4ff2a..549587229e 100644
--- a/integration/vite-hmr-hdr-test.ts
+++ b/integration/vite-hmr-hdr-test.ts
@@ -1,155 +1,164 @@
-import fs from "node:fs/promises";
-import path from "node:path";
-import type { Page, PlaywrightWorkerOptions } from "@playwright/test";
+import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
+import getPort from "get-port";
+import dedent from "dedent";
-import type { Files, TemplateName } from "./helpers/vite.js";
-import {
- test,
- createEditor,
- EXPRESS_SERVER,
- viteConfig,
- viteMajorTemplates,
-} from "./helpers/vite.js";
+import * as Express from "./helpers/express";
+import { test } from "./helpers/fixtures";
+import * as Stream from "./helpers/stream";
+import { viteMajorTemplates, getTemplates } from "./helpers/templates";
+
+const tsx = dedent;
+const mdx = dedent;
const templates = [
...viteMajorTemplates,
- {
- templateName: "rsc-vite-framework",
- templateDisplayName: "RSC Framework Mode",
- },
-] as const satisfies ReadonlyArray<{
- templateName: TemplateName;
- templateDisplayName: string;
-}>;
-
-const indexRoute = `
- // imports
- import { useState, useEffect } from "react";
-
- export const meta = () => [{ title: "HMR updated title: 0" }]
-
- // loader
-
- export default function IndexRoute() {
- // hooks
- const [mounted, setMounted] = useState(false);
- useEffect(() => {
- setMounted(true);
- }, []);
-
- return (
-
-
Index
-
-
Mounted: {mounted ? "yes" : "no"}
-
HMR updated: 0
- {/* elements */}
-
- );
- }
-`;
-
-test.describe("Vite HMR & HDR", () => {
- templates.forEach(({ templateName, templateDisplayName }) => {
- test.describe(templateDisplayName, () => {
- test("vite dev", async ({ page, browserName, dev }) => {
- let files: Files = async ({ port }) => ({
- "vite.config.js": await viteConfig.basic({ port, templateName }),
- "app/routes/_index.tsx": indexRoute,
- });
- let { cwd, port } = await dev(files, templateName);
- await workflow({ templateName, page, browserName, cwd, port });
+ ...getTemplates(["rsc-vite-framework"]),
+];
+
+templates.forEach((template) => {
+ const isRsc = template.name.startsWith("rsc-");
+
+ test.describe(`${template.displayName} - HMR & HDR`, () => {
+ test.use({
+ template: template.name,
+ files: {
+ "app/routes/_index.tsx": tsx`
+ // imports
+ import { useState, useEffect } from "react";
+
+ export const meta = () => [{ title: "HMR updated title: 0" }]
+
+ // loader
+
+ export default function IndexRoute() {
+ // hooks
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return (
+
+
Index
+
+
Mounted: {mounted ? "yes" : "no"}
+
HMR updated: 0
+ {/* elements */}
+
+ );
+ }
+ `,
+ },
+ });
+
+ test("vite dev", async ({ page, edit, $ }) => {
+ const port = await getPort();
+ const url = `http://localhost:${port}`;
+
+ const dev = $(`pnpm dev --port ${port}`);
+ await Stream.match(dev.stdout, url);
+
+ await workflow({ isRsc, page, edit, url });
+ });
+
+ test("express", async ({ page, edit, $ }) => {
+ await edit({
+ "server.mjs": isRsc ? Express.rsc() : Express.server(),
});
- test("express", async ({ page, browserName, customDev }) => {
- let files: Files = async ({ port }) => ({
- "vite.config.js": await viteConfig.basic({ port, templateName }),
- "server.mjs": EXPRESS_SERVER({ port, templateName }),
- "app/routes/_index.tsx": indexRoute,
- });
- let { cwd, port } = await customDev(files, templateName);
- await workflow({ templateName, page, browserName, cwd, port });
+ await $("pnpm build");
+
+ const port = await getPort();
+ const url = `http://localhost:${port}`;
+
+ const server = $("node server.mjs", {
+ env: {
+ PORT: String(port),
+ HMR_PORT: String(await getPort()),
+ },
});
+ await Stream.match(server.stdout, url);
- test("mdx", async ({ page, dev }) => {
- test.skip(templateName.includes("rsc"), "RSC is not supported");
- let files: Files = async ({ port }) => ({
- "vite.config.ts": `
- import { defineConfig } from "vite";
- import { reactRouter } from "@react-router/dev/vite";
- import mdx from "@mdx-js/rollup";
-
- export default defineConfig({
- ${await viteConfig.server({ port })}
- plugins: [
- mdx(),
- reactRouter(),
- ],
- });
- `,
- "app/component.tsx": `
- import {useState} from "react";
-
- export const Counter = () => {
- const [count, setCount] = useState(0);
- return
- }
- `,
- "app/routes/mdx.mdx": `
- import { Counter } from "../component";
-
- # MDX Title (HMR: 0)
-
-
- `,
- });
-
- let { port, cwd } = await dev(files, templateName);
- let edit = createEditor(cwd);
- await page.goto(`http://localhost:${port}/mdx`, {
- waitUntil: "networkidle",
- });
-
- await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 0)");
- let button = page.locator("button");
- await expect(button).toHaveText("Count: 0");
- await button.click();
- await expect(button).toHaveText("Count: 1");
-
- await edit("app/routes/mdx.mdx", (contents) =>
- contents.replace("(HMR: 0)", "(HMR: 1)"),
- );
- await page.waitForLoadState("networkidle");
+ await workflow({ isRsc, page, edit, url });
+ });
+
+ test("mdx", async ({ page, edit, $ }) => {
+ test.skip(template.name.includes("rsc"), "RSC is not supported");
+
+ await edit({
+ "vite.config.ts": tsx`
+ import { defineConfig } from "vite";
+ import { reactRouter } from "@react-router/dev/vite";
+ import mdx from "@mdx-js/rollup";
+
+ export default defineConfig({
+ plugins: [
+ mdx(),
+ reactRouter(),
+ ],
+ });
+ `,
+ "app/component.tsx": tsx`
+ import {useState} from "react";
+
+ export const Counter = () => {
+ const [count, setCount] = useState(0);
+ return
+ }
+ `,
+ "app/routes/mdx.mdx": mdx`
+ import { Counter } from "../component";
+
+ # MDX Title (HMR: 0)
+
+
+ `,
+ });
+
+ const port = await getPort();
+ const url = `http://localhost:${port}`;
- await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 1)");
- await expect(page.locator("button")).toHaveText("Count: 1");
+ const dev = $(`pnpm dev --port ${port}`);
+ await Stream.match(dev.stdout, url);
- expect(page.errors).toEqual([]);
+ await page.goto(url + "/mdx", { waitUntil: "networkidle" });
+
+ await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 0)");
+ let button = page.locator("button");
+ await expect(button).toHaveText("Count: 0");
+ await button.click();
+ await expect(button).toHaveText("Count: 1");
+
+ await edit({
+ "app/routes/mdx.mdx": (contents) =>
+ contents.replace("(HMR: 0)", "(HMR: 1)"),
});
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 1)");
+ await expect(page.locator("button")).toHaveText("Count: 1");
+
+ expect(page.errors).toEqual([]);
});
});
});
async function workflow({
- templateName,
+ isRsc,
page,
- browserName,
- cwd,
- port,
+ edit,
+ url,
}: {
- templateName: TemplateName;
+ isRsc: boolean;
page: Page;
- browserName: PlaywrightWorkerOptions["browserName"];
- cwd: string;
- port: number;
+ edit: (
+ edits: Record string)>,
+ ) => Promise;
+ url: string;
}) {
- let edit = createEditor(cwd);
-
// setup: initial render
- await page.goto(`http://localhost:${port}/`, {
- waitUntil: "networkidle",
- });
+ await page.goto(url, { waitUntil: "networkidle" });
await expect(page.locator("#index [data-title]")).toHaveText("Index");
// setup: hydration
@@ -164,15 +173,16 @@ async function workflow({
await expect(hmrStatus).toHaveText("HMR updated: 0");
let input = page.locator("#index input");
await expect(input).toBeVisible();
- await input.type("stateful");
+ await input.fill("stateful");
expect(page.errors).toEqual([]);
// route: HMR
- await edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace("HMR updated title: 0", "HMR updated title: 1")
- .replace("HMR updated: 0", "HMR updated: 1"),
- );
+ await edit({
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace("HMR updated title: 0", "HMR updated title: 1")
+ .replace("HMR updated: 0", "HMR updated: 1"),
+ });
await page.waitForLoadState("networkidle");
await expect(page).toHaveTitle("HMR updated title: 1");
@@ -181,31 +191,33 @@ async function workflow({
expect(page.errors).toEqual([]);
// route: add loader
- await edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace(
- "// imports",
- `// imports\nimport { useLoaderData } from "react-router"`,
- )
- .replace(
- "// loader",
- `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`,
- )
- .replace(
- "// hooks",
- "// hooks\nconst { message } = useLoaderData();",
- )
- .replace(
- "{/* elements */}",
- `{/* elements */}\n{message}
`,
- ),
- );
+ await edit({
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { useLoaderData } from "react-router"`,
+ )
+ .replace(
+ "// loader",
+ `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`,
+ )
+ .replace(
+ "// hooks",
+ "// hooks\nconst { message } = useLoaderData();",
+ )
+ .replace(
+ "{/* elements */}",
+ `{/* elements */}\n{message}
`,
+ ),
+ });
await page.waitForLoadState("networkidle");
let hdrStatus = page.locator("#index [data-hdr]");
await expect(hdrStatus).toHaveText("HDR updated: 0");
+
// React Fast Refresh cannot preserve state for a component when hooks are added or removed
await expect(input).toHaveValue("");
- await input.type("stateful");
+ await input.fill("stateful");
expect(page.errors.length).toBeGreaterThan(0);
expect(
// When adding a loader, a harmless error is logged to the browser console.
@@ -220,19 +232,21 @@ async function workflow({
page.errors = [];
// route: HDR
- await edit("app/routes/_index.tsx", (contents) =>
- contents.replace("HDR updated: 0", "HDR updated: 1"),
- );
+ await edit({
+ "app/routes/_index.tsx": (contents) =>
+ contents.replace("HDR updated: 0", "HDR updated: 1"),
+ });
await page.waitForLoadState("networkidle");
await expect(hdrStatus).toHaveText("HDR updated: 1");
await expect(input).toHaveValue("stateful");
// route: HMR + HDR
- await edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace("HMR updated: 1", "HMR updated: 2")
- .replace("HDR updated: 1", "HDR updated: 2"),
- );
+ await edit({
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace("HMR updated: 1", "HMR updated: 2")
+ .replace("HDR updated: 1", "HDR updated: 2"),
+ });
await page.waitForLoadState("networkidle");
await expect(hmrStatus).toHaveText("HMR updated: 2");
await expect(hdrStatus).toHaveText("HDR updated: 2");
@@ -240,23 +254,20 @@ async function workflow({
expect(page.errors).toEqual([]);
// create new non-route component module
- await fs.writeFile(
- path.join(cwd, "app/component.tsx"),
- String.raw`
- export function MyComponent() {
- return Component HMR: 0
;
- }
+ await edit({
+ "app/component.tsx": tsx`
+ export function MyComponent() {
+ return Component HMR: 0
;
+ }
`,
- "utf8",
- );
- await edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace(
- "// imports",
- `// imports\nimport { MyComponent } from "../component";`,
- )
- .replace("{/* elements */}", "{/* elements */}\n"),
- );
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { MyComponent } from "../component";`,
+ )
+ .replace("{/* elements */}", "{/* elements */}\n"),
+ });
await page.waitForLoadState("networkidle");
let component = page.locator("#index [data-component]");
await expect(component).toBeVisible();
@@ -265,57 +276,53 @@ async function workflow({
expect(page.errors).toEqual([]);
// non-route: HMR
- await edit("app/component.tsx", (contents) =>
- contents.replace("Component HMR: 0", "Component HMR: 1"),
- );
+ await edit({
+ "app/component.tsx": (contents) =>
+ contents.replace("Component HMR: 0", "Component HMR: 1"),
+ });
await page.waitForLoadState("networkidle");
await expect(component).toHaveText("Component HMR: 1");
await expect(input).toHaveValue("stateful");
expect(page.errors).toEqual([]);
// create new non-route server module
- await fs.writeFile(
- path.join(cwd, "app/indirect-hdr-dep.ts"),
- String.raw`export const indirect = "indirect 0"`,
- "utf8",
- );
- await fs.writeFile(
- path.join(cwd, "app/direct-hdr-dep.ts"),
- String.raw`
+ await edit({
+ "app/indirect-hdr-dep.ts": tsx`export const indirect = "indirect 0"`,
+ "app/direct-hdr-dep.ts": tsx`
import { indirect } from "./indirect-hdr-dep"
export const direct = "direct 0 & " + indirect
`,
- "utf8",
- );
- await edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace(
- "// imports",
- `// imports\nimport { direct } from "../direct-hdr-dep"`,
- )
- .replace(
- `{ message: "HDR updated: 2" }`,
- `{ message: "HDR updated: " + direct }`,
- ),
- );
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { direct } from "../direct-hdr-dep"`,
+ )
+ .replace(
+ `{ message: "HDR updated: 2" }`,
+ `{ message: "HDR updated: " + direct }`,
+ ),
+ });
await page.waitForLoadState("networkidle");
await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0");
await expect(input).toHaveValue("stateful");
expect(page.errors).toEqual([]);
// non-route: HDR for direct dependency
- await edit("app/direct-hdr-dep.ts", (contents) =>
- contents.replace("direct 0 &", "direct 1 &"),
- );
+ await edit({
+ "app/direct-hdr-dep.ts": (contents) =>
+ contents.replace("direct 0 &", "direct 1 &"),
+ });
await page.waitForLoadState("networkidle");
await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0");
await expect(input).toHaveValue("stateful");
expect(page.errors).toEqual([]);
// non-route: HDR for indirect dependency
- await edit("app/indirect-hdr-dep.ts", (contents) =>
- contents.replace("indirect 0", "indirect 1"),
- );
+ await edit({
+ "app/indirect-hdr-dep.ts": (contents) =>
+ contents.replace("indirect 0", "indirect 1"),
+ });
await page.waitForLoadState("networkidle");
await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1");
await expect(input).toHaveValue("stateful");
@@ -323,20 +330,24 @@ async function workflow({
// everything everywhere all at once
await Promise.all([
- edit("app/routes/_index.tsx", (contents) =>
- contents
- .replace("HMR updated: 2", "HMR updated: 3")
- .replace("HDR updated: ", "HDR updated: route & "),
- ),
- edit("app/component.tsx", (contents) =>
- contents.replace("Component HMR: 1", "Component HMR: 2"),
- ),
- edit("app/direct-hdr-dep.ts", (contents) =>
- contents.replace("direct 1 &", "direct 2 &"),
- ),
- edit("app/indirect-hdr-dep.ts", (contents) =>
- contents.replace("indirect 1", "indirect 2"),
- ),
+ edit({
+ "app/routes/_index.tsx": (contents) =>
+ contents
+ .replace("HMR updated: 2", "HMR updated: 3")
+ .replace("HDR updated: ", "HDR updated: route & "),
+ }),
+ edit({
+ "app/component.tsx": (contents) =>
+ contents.replace("Component HMR: 1", "Component HMR: 2"),
+ }),
+ edit({
+ "app/direct-hdr-dep.ts": (contents) =>
+ contents.replace("direct 1 &", "direct 2 &"),
+ }),
+ edit({
+ "app/indirect-hdr-dep.ts": (contents) =>
+ contents.replace("indirect 1", "indirect 2"),
+ }),
]);
await page.waitForLoadState("networkidle");
await expect(hmrStatus).toHaveText("HMR updated: 3");
@@ -345,8 +356,9 @@ async function workflow({
"HDR updated: route & direct 2 & indirect 2",
);
// TODO: Investigate why this is flaky in CI for RSC Framework Mode
- if (!templateName.includes("rsc")) {
+ if (isRsc) {
await expect(input).toHaveValue("stateful");
}
+
expect(page.errors).toEqual([]);
}
diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md
index da45cd74b2..49f7ad6ac1 100644
--- a/packages/create-react-router/CHANGELOG.md
+++ b/packages/create-react-router/CHANGELOG.md
@@ -1,7 +1,13 @@
# `create-react-router`
+## 7.9.4
+
+_No changes_
+
## 7.9.3
+_No changes_
+
## 7.9.2
_No changes_
diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json
index f0eab96b1a..c51639a244 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.9.3",
+ "version": "7.9.4",
"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 5ec74a1162..16cf02621b 100644
--- a/packages/react-router-architect/CHANGELOG.md
+++ b/packages/react-router-architect/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/architect`
+## 7.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+ - `@react-router/node@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json
index 9b63583c6f..2e4c1ac8bf 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.9.3",
+ "version": "7.9.4",
"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 d74b9aa8d9..9a471e4a52 100644
--- a/packages/react-router-cloudflare/CHANGELOG.md
+++ b/packages/react-router-cloudflare/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/cloudflare`
+## 7.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json
index 249f43f0f8..44275aa75e 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.9.3",
+ "version": "7.9.4",
"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 2d0aa8e6df..e9029d2b52 100644
--- a/packages/react-router-dev/CHANGELOG.md
+++ b/packages/react-router-dev/CHANGELOG.md
@@ -1,5 +1,116 @@
# `@react-router/dev`
+## 7.9.4
+
+### Patch Changes
+
+- Update `valibot` dependency to `^1.1.0` ([#14379](https://github.com/remix-run/react-router/pull/14379))
+
+- New (unstable) `useRoute` hook for accessing data from specific routes ([#14407](https://github.com/remix-run/react-router/pull/14407))
+
+ For example, let's say you have an `admin` route somewhere in your app and you want any child routes of `admin` to all have access to the `loaderData` and `actionData` from `admin.`
+
+ ```tsx
+ // app/routes/admin.tsx
+ import { Outlet } from "react-router";
+
+ export const loader = () => ({ message: "Hello, loader!" });
+
+ export const action = () => ({ count: 1 });
+
+ export default function Component() {
+ return (
+
+ {/* ... */}
+
+ {/* ... */}
+
+ );
+ }
+ ```
+
+ You might even want to create a reusable widget that all of the routes nested under `admin` could use:
+
+ ```tsx
+ import { unstable_useRoute as useRoute } from "react-router";
+
+ export function AdminWidget() {
+ // How to get `message` and `count` from `admin` route?
+ }
+ ```
+
+ In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/dmin");
+ // ^^^^^^^^^^^
+ }
+ ```
+
+ `useRoute` returns `undefined` if the route is not part of the current page:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ }
+ ```
+
+ Note: the `root` route is the exception since it is guaranteed to be part of the current page.
+ As a result, `useRoute` never returns `undefined` for `root`.
+
+ `loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ const { loaderData, actionData } = admin;
+ console.log(loaderData);
+ // ^? { message: string } | undefined
+ console.log(actionData);
+ // ^? { count: number } | undefined
+ }
+ ```
+
+ If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments:
+
+ ```tsx
+ export function AdminWidget() {
+ const currentRoute = useRoute();
+ currentRoute.loaderData;
+ currentRoute.actionData;
+ }
+ ```
+
+ This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`.
+
+ Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route.
+ As a result, `loaderData` and `actionData` are typed as `unknown`.
+ If you want more type-safety, you can either narrow the type yourself with something like `zod` or you can refactor your app to pass down typed props to your `AdminWidget`:
+
+ ```tsx
+ export function AdminWidget({
+ message,
+ count,
+ }: {
+ message: string;
+ count: number;
+ }) {
+ /* ... */
+ }
+ ```
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+ - `@react-router/node@7.9.4`
+ - `@react-router/serve@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-dev/__tests__/route-config-test.ts b/packages/react-router-dev/__tests__/route-config-test.ts
index 0692335b2b..8ecc5f03a2 100644
--- a/packages/react-router-dev/__tests__/route-config-test.ts
+++ b/packages/react-router-dev/__tests__/route-config-test.ts
@@ -76,7 +76,7 @@ describe("route config", () => {
"Route config in "routes.ts" is invalid.
Path: routes.0.children.0.file
- Invalid type: Expected string but received undefined"
+ Invalid key: Expected "file" but received undefined"
`);
});
@@ -129,7 +129,7 @@ describe("route config", () => {
"Route config in "routes.ts" is invalid.
Path: routes.0.children.0.file
- Invalid type: Expected string but received undefined
+ Invalid key: Expected "file" but received undefined
Path: routes.0.children.1.file
Invalid type: Expected string but received 123
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index 5d8c1a0e17..2722b58c1a 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.9.3",
+ "version": "7.9.4",
"description": "Dev tools and CLI for React Router",
"homepage": "https://reactrouter.com",
"bugs": {
@@ -89,7 +89,7 @@
"react-refresh": "^0.14.0",
"semver": "^7.3.7",
"tinyglobby": "^0.2.14",
- "valibot": "^0.41.0",
+ "valibot": "^1.1.0",
"vite-node": "^3.2.2"
},
"devDependencies": {
diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts
index 74df004ee6..dcab03fd27 100644
--- a/packages/react-router-dev/typegen/generate.ts
+++ b/packages/react-router-dev/typegen/generate.ts
@@ -105,13 +105,16 @@ export function generateRoutes(ctx: Context): Array {
interface Register {
pages: Pages
routeFiles: RouteFiles
+ routeModules: RouteModules
}
}
` +
"\n\n" +
Babel.generate(pagesType(allPages)).code +
"\n\n" +
- Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code,
+ Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code +
+ "\n\n" +
+ Babel.generate(routeModulesType(ctx)).code,
};
// **/+types/*.ts
@@ -193,6 +196,29 @@ function routeFilesType({
);
}
+function routeModulesType(ctx: Context) {
+ return t.tsTypeAliasDeclaration(
+ t.identifier("RouteModules"),
+ null,
+ t.tsTypeLiteral(
+ Object.values(ctx.config.routes).map((route) =>
+ t.tsPropertySignature(
+ t.stringLiteral(route.id),
+ t.tsTypeAnnotation(
+ t.tsTypeQuery(
+ t.tsImportType(
+ t.stringLiteral(
+ `./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
function isInAppDirectory(ctx: Context, routeFile: string): boolean {
const path = Path.resolve(ctx.config.appDirectory, routeFile);
return path.startsWith(ctx.config.appDirectory);
diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md
index 75c2ecb0ef..3eef443c6c 100644
--- a/packages/react-router-dom/CHANGELOG.md
+++ b/packages/react-router-dom/CHANGELOG.md
@@ -1,5 +1,12 @@
# react-router-dom
+## 7.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json
index 715770624e..348dc4fb66 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.9.3",
+ "version": "7.9.4",
"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 d3e49f9170..3315423171 100644
--- a/packages/react-router-express/CHANGELOG.md
+++ b/packages/react-router-express/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/express`
+## 7.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+ - `@react-router/node@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json
index 18f5bf6a13..fd349d39ea 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.9.3",
+ "version": "7.9.4",
"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 0153883a9c..0b8d38e5bb 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.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@react-router/dev@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json
index d7728fd930..606c050dfe 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.9.3",
+ "version": "7.9.4",
"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 8955ab93d2..4ec8cddc49 100644
--- a/packages/react-router-node/CHANGELOG.md
+++ b/packages/react-router-node/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/node`
+## 7.9.4
+
+### Patch Changes
+
+- Validate format of incoming session ids ([#14426](https://github.com/remix-run/react-router/pull/14426))
+- Updated dependencies:
+ - `react-router@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-node/__tests__/sessions-test.ts b/packages/react-router-node/__tests__/sessions-test.ts
index 9a72c5b5ec..95446861a5 100644
--- a/packages/react-router-node/__tests__/sessions-test.ts
+++ b/packages/react-router-node/__tests__/sessions-test.ts
@@ -55,6 +55,32 @@ describe("File session storage", () => {
expect(session.get("user")).toBeUndefined();
});
+ it("returns an empty session for invalid session ids", async () => {
+ let spy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ let { getSession, commitSession } = createFileSessionStorage({
+ dir,
+ });
+
+ let cookie = `__session=${btoa(JSON.stringify("0123456789abcdef"))}`;
+ let session = await getSession(cookie);
+ session.set("user", "mjackson");
+ expect(session.get("user")).toBe("mjackson");
+ let setCookie = await commitSession(session);
+ session = await getSession(getCookieFromSetCookie(setCookie));
+ expect(session.get("user")).toBe("mjackson");
+
+ cookie = `__session=${btoa(JSON.stringify("0123456789abcdeg"))}`;
+ session = await getSession(cookie);
+ session.set("user", "mjackson");
+ expect(session.get("user")).toBe("mjackson");
+ debugger;
+ setCookie = await commitSession(session);
+ session = await getSession(getCookieFromSetCookie(setCookie));
+ expect(session.get("user")).toBeUndefined();
+
+ spy.mockRestore();
+ });
+
it("doesn't destroy the entire session directory when destroying an empty file session", async () => {
let { getSession, destroySession } = createFileSessionStorage({
dir,
diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json
index be542771c3..d995c0702d 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.9.3",
+ "version": "7.9.4",
"description": "Node.js platform abstractions for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-node/sessions/fileStorage.ts b/packages/react-router-node/sessions/fileStorage.ts
index 0df4bacb4d..3d2c30ae41 100644
--- a/packages/react-router-node/sessions/fileStorage.ts
+++ b/packages/react-router-node/sessions/fileStorage.ts
@@ -47,6 +47,9 @@ export function createFileSessionStorage({
try {
let file = getFile(dir, id);
+ if (!file) {
+ throw new Error("Error generating session");
+ }
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, content, { encoding: "utf-8", flag: "wx" });
return id;
@@ -58,6 +61,9 @@ export function createFileSessionStorage({
async readData(id) {
try {
let file = getFile(dir, id);
+ if (!file) {
+ return null;
+ }
let content = JSON.parse(await fsp.readFile(file, "utf-8"));
let data = content.data;
let expires =
@@ -81,6 +87,9 @@ export function createFileSessionStorage({
async updateData(id, data, expires) {
let content = JSON.stringify({ data, expires });
let file = getFile(dir, id);
+ if (!file) {
+ return;
+ }
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, content, "utf-8");
},
@@ -90,8 +99,12 @@ export function createFileSessionStorage({
if (!id) {
return;
}
+ let file = getFile(dir, id);
+ if (!file) {
+ return;
+ }
try {
- await fsp.unlink(getFile(dir, id));
+ await fsp.unlink(file);
} catch (error: any) {
if (error.code !== "ENOENT") throw error;
}
@@ -99,7 +112,11 @@ export function createFileSessionStorage({
});
}
-export function getFile(dir: string, id: string): string {
+export function getFile(dir: string, id: string): string | null {
+ if (!/^[0-9a-f]{16}$/i.test(id)) {
+ return null;
+ }
+
// Divide the session id up into a directory (first 2 bytes) and filename
// (remaining 6 bytes) to reduce the chance of having very large directories,
// which should speed up file access. This is a maximum of 2^16 directories,
diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md
index 7d95d12953..73ffdbf88e 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.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@react-router/dev@7.9.4`
+
## 7.9.3
### 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 691b5353ab..8ed145ee96 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.9.3",
+ "version": "7.9.4",
"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 cb2c8edc65..21f876abd6 100644
--- a/packages/react-router-serve/CHANGELOG.md
+++ b/packages/react-router-serve/CHANGELOG.md
@@ -1,5 +1,14 @@
# `@react-router/serve`
+## 7.9.4
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.9.4`
+ - `@react-router/node@7.9.4`
+ - `@react-router/express@7.9.4`
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json
index 4d42b286a5..4b0ba29102 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.9.3",
+ "version": "7.9.4",
"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 2f0d19ed90..479bccdc23 100644
--- a/packages/react-router/CHANGELOG.md
+++ b/packages/react-router/CHANGELOG.md
@@ -1,5 +1,110 @@
# `react-router`
+## 7.9.4
+
+### Patch Changes
+
+- handle external redirects in from server actions ([#14400](https://github.com/remix-run/react-router/pull/14400))
+- New (unstable) `useRoute` hook for accessing data from specific routes ([#14407](https://github.com/remix-run/react-router/pull/14407))
+
+ For example, let's say you have an `admin` route somewhere in your app and you want any child routes of `admin` to all have access to the `loaderData` and `actionData` from `admin.`
+
+ ```tsx
+ // app/routes/admin.tsx
+ import { Outlet } from "react-router";
+
+ export const loader = () => ({ message: "Hello, loader!" });
+
+ export const action = () => ({ count: 1 });
+
+ export default function Component() {
+ return (
+
+ {/* ... */}
+
+ {/* ... */}
+
+ );
+ }
+ ```
+
+ You might even want to create a reusable widget that all of the routes nested under `admin` could use:
+
+ ```tsx
+ import { unstable_useRoute as useRoute } from "react-router";
+
+ export function AdminWidget() {
+ // How to get `message` and `count` from `admin` route?
+ }
+ ```
+
+ In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/dmin");
+ // ^^^^^^^^^^^
+ }
+ ```
+
+ `useRoute` returns `undefined` if the route is not part of the current page:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ }
+ ```
+
+ Note: the `root` route is the exception since it is guaranteed to be part of the current page.
+ As a result, `useRoute` never returns `undefined` for `root`.
+
+ `loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error:
+
+ ```tsx
+ export function AdminWidget() {
+ const admin = useRoute("routes/admin");
+ if (!admin) {
+ throw new Error(`AdminWidget used outside of "routes/admin"`);
+ }
+ const { loaderData, actionData } = admin;
+ console.log(loaderData);
+ // ^? { message: string } | undefined
+ console.log(actionData);
+ // ^? { count: number } | undefined
+ }
+ ```
+
+ If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments:
+
+ ```tsx
+ export function AdminWidget() {
+ const currentRoute = useRoute();
+ currentRoute.loaderData;
+ currentRoute.actionData;
+ }
+ ```
+
+ This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`.
+
+ Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route.
+ As a result, `loaderData` and `actionData` are typed as `unknown`.
+ If you want more type-safety, you can either narrow the type yourself with something like `zod` or you can refactor your app to pass down typed props to your `AdminWidget`:
+
+ ```tsx
+ export function AdminWidget({
+ message,
+ count,
+ }: {
+ message: string;
+ count: number;
+ }) {
+ /* ... */
+ }
+ ```
+
## 7.9.3
### Patch Changes
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index ed1418c11e..fe3c25cea6 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -144,6 +144,7 @@ export {
useRouteError,
useRouteLoaderData,
useRoutes,
+ useRoute as unstable_useRoute,
} from "./lib/hooks";
// Expose old RR DOM API
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index d3f969ef02..259f75ace4 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -50,8 +50,13 @@ import {
resolveTo,
stripBasename,
} from "./router/utils";
-import type { SerializeFrom } from "./types/route-data";
+import type {
+ GetActionData,
+ GetLoaderData,
+ SerializeFrom,
+} from "./types/route-data";
import type { unstable_ClientOnErrorFunction } from "./components";
+import type { RouteModules } from "./types/register";
/**
* Resolves a URL against the current {@link Location}.
@@ -1282,6 +1287,7 @@ enum DataRouterStateHook {
UseRevalidator = "useRevalidator",
UseNavigateStable = "useNavigate",
UseRouteId = "useRouteId",
+ UseRoute = "useRoute",
}
function getDataRouterConsoleError(
@@ -1838,3 +1844,39 @@ function warningOnce(key: string, cond: boolean, message: string) {
warning(false, message);
}
}
+
+type UseRouteArgs = [] | [routeId: keyof RouteModules];
+
+// prettier-ignore
+type UseRouteResult =
+ Args extends [] ? UseRoute :
+ Args extends ["root"] ? UseRoute<"root"> :
+ Args extends [infer RouteId extends keyof RouteModules] ? UseRoute | undefined :
+ never;
+
+type UseRoute = {
+ loaderData: RouteId extends keyof RouteModules
+ ? GetLoaderData | undefined
+ : unknown;
+ actionData: RouteId extends keyof RouteModules
+ ? GetActionData | undefined
+ : unknown;
+};
+
+export function useRoute(
+ ...args: Args
+): UseRouteResult {
+ const currentRouteId: keyof RouteModules = useCurrentRouteId(
+ DataRouterStateHook.UseRoute,
+ );
+ const id: keyof RouteModules = args[0] ?? currentRouteId;
+
+ const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData);
+ const route = state.matches.find(({ route }) => route.id === id);
+
+ if (route === undefined) return undefined as UseRouteResult;
+ return {
+ loaderData: state.loaderData[id],
+ actionData: state.actionData?.[id],
+ } as UseRouteResult;
+}
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index 9b9974bc70..5046e28179 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -140,7 +140,7 @@ export function createCallServer({
Promise.resolve(payloadPromise)
.then(async (payload) => {
if (payload.type === "redirect") {
- if (payload.reload) {
+ if (payload.reload || isExternalLocation(payload.location)) {
window.location.href = payload.location;
return () => {};
}
@@ -163,7 +163,7 @@ export function createCallServer({
globalVar.__routerActionID <= actionId
) {
if (rerender.type === "redirect") {
- if (rerender.reload) {
+ if (rerender.reload || isExternalLocation(rerender.location)) {
window.location.href = rerender.location;
return;
}
@@ -1047,3 +1047,8 @@ function debounce(callback: (...args: unknown[]) => unknown, wait: number) {
timeoutId = window.setTimeout(() => callback(...args), wait);
};
}
+
+function isExternalLocation(location: string) {
+ const newLocation = new URL(location, window.location.href);
+ return newLocation.origin !== window.location.origin;
+}
diff --git a/packages/react-router/lib/types/register.ts b/packages/react-router/lib/types/register.ts
index a5bbd7f92b..51dbacf4a4 100644
--- a/packages/react-router/lib/types/register.ts
+++ b/packages/react-router/lib/types/register.ts
@@ -1,3 +1,5 @@
+import type { RouteModule } from "./route-module";
+
/**
* Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation.
* React Router should handle this for you via type generation.
@@ -7,6 +9,7 @@
export interface Register {
// pages
// routeFiles
+ // routeModules
}
// pages
@@ -25,3 +28,10 @@ export type RouteFiles = Register extends {
}
? Registered
: AnyRouteFiles;
+
+type AnyRouteModules = Record;
+export type RouteModules = Register extends {
+ routeModules: infer Registered extends AnyRouteModules;
+}
+ ? Registered
+ : AnyRouteModules;
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 563bcad27c..f89b50e9ba 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -1,6 +1,6 @@
{
"name": "react-router",
- "version": "7.9.3",
+ "version": "7.9.4",
"description": "Declarative routing for React",
"keywords": [
"react",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 182ceea34b..c3e43eaea6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,7 +113,7 @@ importers:
version: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks:
specifier: next
- version: 6.1.0-canary-128abcfa-20250917(eslint@8.57.0)
+ version: 6.1.0-canary-d15d7fd7-20250929(eslint@8.57.0)
fast-glob:
specifier: 3.2.11
version: 3.2.11
@@ -217,8 +217,8 @@ importers:
specifier: ^0.7.0
version: 0.7.0
execa:
- specifier: ^5.1.1
- version: 5.1.1
+ specifier: ^9.6.0
+ version: 9.6.0
express:
specifier: ^4.19.2
version: 4.21.2
@@ -598,7 +598,7 @@ importers:
version: 3.0.1(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1))
vite-tsconfig-paths:
specifier: ^4.2.1
- version: 4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1))
+ version: 4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1)(terser@5.15.0))
integration/helpers/vite-6-template:
dependencies:
@@ -1117,8 +1117,8 @@ importers:
specifier: ^0.2.14
version: 0.2.14
valibot:
- specifier: ^0.41.0
- version: 0.41.0(typescript@5.4.5)
+ specifier: ^1.1.0
+ version: 1.1.0(typescript@5.4.5)
vite-node:
specifier: ^3.2.2
version: 3.2.4(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)
@@ -1161,7 +1161,7 @@ importers:
version: 0.4.30(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))
esbuild-register:
specifier: ^3.6.0
- version: 3.6.0(esbuild@0.25.0)
+ version: 3.6.0(esbuild@0.25.4)
execa:
specifier: 5.1.1
version: 5.1.1
@@ -1573,7 +1573,7 @@ importers:
version: 5.1.3(@types/node@22.14.0)(lightningcss@1.30.1)(terser@5.15.0)
vite-tsconfig-paths:
specifier: ^4.2.1
- version: 4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1))
+ version: 4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1)(terser@5.15.0))
playground/framework-vite-7-beta:
dependencies:
@@ -4561,6 +4561,9 @@ packages:
'@rushstack/eslint-patch@1.10.1':
resolution: {integrity: sha512-S3Kq8e7LqxkA9s7HKLqXGTGck1uwis5vAXan3FnU5yw1Ec5hsSGnq4s/UCaSqABPOnOTg7zASLyst7+ohgWexg==}
+ '@sec-ant/readable-stream@0.4.1':
+ resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+
'@shikijs/engine-oniguruma@3.8.1':
resolution: {integrity: sha512-KGQJZHlNY7c656qPFEQpIoqOuC4LrxjyNndRdzk5WKB/Ie87+NJCF1xo9KkOUxwxylk7rT6nhlZyTGTC4fCe1g==}
@@ -4588,6 +4591,10 @@ packages:
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
+ '@sindresorhus/merge-streams@4.0.0':
+ resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
+ engines: {node: '>=18'}
+
'@sinonjs/commons@2.0.0':
resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==}
@@ -6252,8 +6259,8 @@ packages:
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
- eslint-plugin-react-hooks@6.1.0-canary-128abcfa-20250917:
- resolution: {integrity: sha512-cR/EftrsVDqCbmfq6IEsLaPqMhkLFgoiJvnSF6nArECbchE8ZQJyGQv7sXGwsf1sKYXr7N9vaB45iDmZAx4Ecw==}
+ eslint-plugin-react-hooks@6.1.0-canary-d15d7fd7-20250929:
+ resolution: {integrity: sha512-BeJu8hPQW+FjteWcCVdVezI2ogQs2mrHSOznrk00dbXztd8NqnyHlB7Z1wx3ZwkUVVAVHmmxrBCrRMn6UP15FA==}
engines: {node: '>=18'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@@ -6376,6 +6383,10 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
+ execa@9.6.0:
+ resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
+ engines: {node: ^18.19.0 || >=20.5.0}
+
exit-hook@2.2.1:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
@@ -6438,6 +6449,10 @@ packages:
picomatch:
optional: true
+ figures@6.1.0:
+ resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
+ engines: {node: '>=18'}
+
file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
@@ -6584,6 +6599,10 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
+ get-stream@9.0.1:
+ resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
+ engines: {node: '>=18'}
+
get-symbol-description@1.0.2:
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
engines: {node: '>= 0.4'}
@@ -6767,6 +6786,10 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
+ human-signals@8.0.1:
+ resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
+ engines: {node: '>=18.18.0'}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -6988,6 +7011,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
+ is-stream@4.0.1:
+ resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
+ engines: {node: '>=18'}
+
is-string@1.0.7:
resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==}
engines: {node: '>= 0.4'}
@@ -7004,6 +7031,10 @@ packages:
resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==}
engines: {node: '>= 0.4'}
+ is-unicode-supported@2.1.0:
+ resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+ engines: {node: '>=18'}
+
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -8004,6 +8035,10 @@ packages:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
+ npm-run-path@6.0.0:
+ resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
+ engines: {node: '>=18'}
+
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -8149,6 +8184,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
+ parse-ms@4.0.0:
+ resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
+ engines: {node: '>=18'}
+
parse-statements@1.0.11:
resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
@@ -8180,6 +8219,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -8318,6 +8361,10 @@ packages:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ pretty-ms@9.3.0:
+ resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
+ engines: {node: '>=18'}
+
printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
@@ -9064,6 +9111,10 @@ packages:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
+ strip-final-newline@4.0.0:
+ resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
+ engines: {node: '>=18'}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -9388,6 +9439,10 @@ packages:
resolution: {integrity: sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==}
engines: {node: '>=4'}
+ unicorn-magic@0.3.0:
+ resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
+ engines: {node: '>=18'}
+
unified@10.1.2:
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
@@ -9493,16 +9548,16 @@ packages:
resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==}
engines: {node: '>=10.12.0'}
- valibot@0.41.0:
- resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==}
+ valibot@1.0.0:
+ resolution: {integrity: sha512-1Hc0ihzWxBar6NGeZv7fPLY0QuxFMyxwYR2sF1Blu7Wq7EnremwY2W02tit2ij2VJT8HcSkHAQqmFfl77f73Yw==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
- valibot@1.0.0:
- resolution: {integrity: sha512-1Hc0ihzWxBar6NGeZv7fPLY0QuxFMyxwYR2sF1Blu7Wq7EnremwY2W02tit2ij2VJT8HcSkHAQqmFfl77f73Yw==}
+ valibot@1.1.0:
+ resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
@@ -9878,6 +9933,10 @@ packages:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'}
+ yoctocolors@2.1.2:
+ resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
+ engines: {node: '>=18'}
+
youch@3.3.4:
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
@@ -12878,6 +12937,8 @@ snapshots:
'@rushstack/eslint-patch@1.10.1': {}
+ '@sec-ant/readable-stream@0.4.1': {}
+
'@shikijs/engine-oniguruma@3.8.1':
dependencies:
'@shikijs/types': 3.8.1
@@ -12908,6 +12969,8 @@ snapshots:
'@sinclair/typebox@0.27.8': {}
+ '@sindresorhus/merge-streams@4.0.0': {}
+
'@sinonjs/commons@2.0.0':
dependencies:
type-detect: 4.0.8
@@ -14784,6 +14847,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ esbuild-register@3.6.0(esbuild@0.25.4):
+ dependencies:
+ debug: 4.4.1
+ esbuild: 0.25.4
+ transitivePeerDependencies:
+ - supports-color
+
esbuild@0.19.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.19.12
@@ -15065,7 +15135,7 @@ snapshots:
dependencies:
eslint: 8.57.0
- eslint-plugin-react-hooks@6.1.0-canary-128abcfa-20250917(eslint@8.57.0):
+ eslint-plugin-react-hooks@6.1.0-canary-d15d7fd7-20250929(eslint@8.57.0):
dependencies:
'@babel/core': 7.27.7
'@babel/parser': 7.27.7
@@ -15256,6 +15326,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
+ execa@9.6.0:
+ dependencies:
+ '@sindresorhus/merge-streams': 4.0.0
+ cross-spawn: 7.0.6
+ figures: 6.1.0
+ get-stream: 9.0.1
+ human-signals: 8.0.1
+ is-plain-obj: 4.1.0
+ is-stream: 4.0.1
+ npm-run-path: 6.0.0
+ pretty-ms: 9.3.0
+ signal-exit: 4.1.0
+ strip-final-newline: 4.0.0
+ yoctocolors: 2.1.2
+
exit-hook@2.2.1: {}
exit@0.1.2: {}
@@ -15348,6 +15433,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
+ figures@6.1.0:
+ dependencies:
+ is-unicode-supported: 2.1.0
+
file-entry-cache@6.0.1:
dependencies:
flat-cache: 3.0.4
@@ -15502,6 +15591,11 @@ snapshots:
get-stream@6.0.1: {}
+ get-stream@9.0.1:
+ dependencies:
+ '@sec-ant/readable-stream': 0.4.1
+ is-stream: 4.0.1
+
get-symbol-description@1.0.2:
dependencies:
call-bind: 1.0.7
@@ -15748,6 +15842,8 @@ snapshots:
human-signals@2.1.0: {}
+ human-signals@8.0.1: {}
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -15929,6 +16025,8 @@ snapshots:
is-stream@2.0.1: {}
+ is-stream@4.0.1: {}
+
is-string@1.0.7:
dependencies:
has-tostringtag: 1.0.2
@@ -15945,6 +16043,8 @@ snapshots:
dependencies:
which-typed-array: 1.1.15
+ is-unicode-supported@2.1.0: {}
+
is-weakmap@2.0.2: {}
is-weakref@1.0.2:
@@ -17543,6 +17643,11 @@ snapshots:
dependencies:
path-key: 3.1.1
+ npm-run-path@6.0.0:
+ dependencies:
+ path-key: 4.0.0
+ unicorn-magic: 0.3.0
+
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
@@ -17725,6 +17830,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse-ms@4.0.0: {}
+
parse-statements@1.0.11: {}
parse5-htmlparser2-tree-adapter@7.0.0:
@@ -17750,6 +17857,8 @@ snapshots:
path-key@3.1.1: {}
+ path-key@4.0.0: {}
+
path-parse@1.0.7: {}
path-scurry@1.10.2:
@@ -17868,6 +17977,10 @@ snapshots:
ansi-styles: 5.2.0
react-is: 19.1.0
+ pretty-ms@9.3.0:
+ dependencies:
+ parse-ms: 4.0.0
+
printable-characters@1.0.42: {}
proc-log@3.0.0: {}
@@ -18710,6 +18823,8 @@ snapshots:
strip-final-newline@2.0.0: {}
+ strip-final-newline@4.0.0: {}
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
@@ -19056,6 +19171,8 @@ snapshots:
unicode-property-aliases-ecmascript@2.0.0: {}
+ unicorn-magic@0.3.0: {}
+
unified@10.1.2:
dependencies:
'@types/unist': 2.0.10
@@ -19200,11 +19317,11 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.4
convert-source-map: 1.9.0
- valibot@0.41.0(typescript@5.4.5):
+ valibot@1.0.0(typescript@5.4.5):
optionalDependencies:
typescript: 5.4.5
- valibot@1.0.0(typescript@5.4.5):
+ valibot@1.1.0(typescript@5.4.5):
optionalDependencies:
typescript: 5.4.5
@@ -19362,7 +19479,7 @@ snapshots:
- supports-color
- typescript
- vite-tsconfig-paths@4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1)):
+ vite-tsconfig-paths@4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@22.14.0)(lightningcss@1.30.1)(terser@5.15.0)):
dependencies:
debug: 4.4.1
globrex: 0.1.2
@@ -19703,6 +19820,8 @@ snapshots:
yoctocolors-cjs@2.1.2: {}
+ yoctocolors@2.1.2: {}
+
youch@3.3.4:
dependencies:
cookie: 0.7.2
diff --git a/scripts/delete-pre-tags.sh b/scripts/delete-pre-tags.sh
index 2959d37279..f521897d90 100755
--- a/scripts/delete-pre-tags.sh
+++ b/scripts/delete-pre-tags.sh
@@ -24,5 +24,7 @@ echo "Found ${NUM_TAGS} tags to delete. To delete, run the following commands:"
echo ""
echo "git push origin --delete ${TAGS_LINE}"
echo "git fetch --prune --prune-tags"
+echo ""
+echo ""
set +e