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

Parent1

- -
- ) - } - `, - "app/routes/parent2.tsx": tsx` - import { Outlet } from "react-router" + export default function Component() { + return ( +
+

Parent1

+ +
+ ) + } + `, + "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