;
+ },
+ loader() {
+ return { message: "hello" };
+ },
+ },
+ ]);
+
+ render();
+
+ await waitFor(() => screen.findByText("Message: hello"));
+ ```
+
+- `@react-router/dev` - Automatic types for future flags ([#13506](https://github.com/remix-run/react-router/pull/13506))
+
+### Patch Changes
+
+You may notice this list is a bit larger than usual! The team ate their vegetables last week and spent the week [squashing bugs](https://x.com/BrooksLybrand/status/1918406062920589731) to work on lowering the issue count that had ballooned a bit since the v7 release.
+
+- `react-router` - Fix `react-router` module augmentation for `NodeNext` ([#13498](https://github.com/remix-run/react-router/pull/13498))
+- `react-router` - Don't bundle `react-router` in `react-router/dom` CJS export ([#13497](https://github.com/remix-run/react-router/pull/13497))
+- `react-router` - Fix bug where a submitting `fetcher` would get stuck in a `loading` state if a revalidating `loader` redirected ([#12873](https://github.com/remix-run/react-router/pull/12873))
+- `react-router` - Fix hydration error if a server `loader` returned `undefined` ([#13496](https://github.com/remix-run/react-router/pull/13496))
+- `react-router` - Fix initial load 404 scenarios in data mode ([#13500](https://github.com/remix-run/react-router/pull/13500))
+- `react-router` - Stabilize `useRevalidator`'s `revalidate` function ([#13542](https://github.com/remix-run/react-router/pull/13542))
+- `react-router` - Preserve status code if a `clientAction` throws a `data()` result in framework mode ([#13522](https://github.com/remix-run/react-router/pull/13522))
+- `react-router` - Be defensive against leading double slashes in paths to avoid `Invalid URL` errors from the URL constructor ([#13510](https://github.com/remix-run/react-router/pull/13510))
+ - Note we do not sanitize/normalize these paths - we only detect them so we can avoid the error that would be thrown by `new URL("//", window.location.origin)`
+- `react-router` - Remove `Navigator` declaration for `navigator.connection.saveData` to avoid messing with any other types beyond `saveData` in user land ([#13512](https://github.com/remix-run/react-router/pull/13512))
+- `react-router` - Fix `handleError` `params` values on `.data` requests for routes with a dynamic param as the last URL segment ([#13481](https://github.com/remix-run/react-router/pull/13481))
+- `react-router` - Don't trigger an `ErrorBoundary` UI before the reload when we detect a manifest version mismatch in Lazy Route Discovery ([#13480](https://github.com/remix-run/react-router/pull/13480))
+- `react-router` - Inline `turbo-stream@2.4.1` dependency and fix decoding ordering of `Map`/`Set` instances ([#13518](https://github.com/remix-run/react-router/pull/13518))
+- `react-router` - Only render dev warnings during dev ([#13461](https://github.com/remix-run/react-router/pull/13461))
+- `react-router` - Short circuit post-processing on aborted `dataStrategy` requests ([#13521](https://github.com/remix-run/react-router/pull/13521))
+ - This resolves non-user-facing console errors of the form `Cannot read properties of undefined (reading 'result')`
+- `@react-router/dev` - Support project root directories without a `package.json` if it exists in a parent directory ([#13472](https://github.com/remix-run/react-router/pull/13472))
+- `@react-router/dev` - When providing a custom Vite config path via the CLI `--config`/`-c` flag, default the project root directory to the directory containing the Vite config when not explicitly provided ([#13472](https://github.com/remix-run/react-router/pull/13472))
+- `@react-router/dev` - In a `routes.ts` context, ensure the `--mode` flag is respected for `import.meta.env.MODE` ([#13485](https://github.com/remix-run/react-router/pull/13485))
+ - Previously, `import.meta.env.MODE` within a `routes.ts` context was always `"development"` for the `dev` and `typegen --watch` commands, but otherwise resolved to `"production"`. These defaults are still in place, but if a `--mode` flag is provided, this will now take precedence.
+- `@react-router/dev` - Ensure consistent project root directory resolution logic in CLI commands ([#13472](https://github.com/remix-run/react-router/pull/13472))
+- `@react-router/dev` - When executing `react-router.config.ts` and `routes.ts` with `vite-node`, ensure that PostCSS config files are ignored ([#13489](https://github.com/remix-run/react-router/pull/13489))
+- `@react-router/dev` - When extracting critical CSS during development, ensure it's loaded from the client environment to avoid issues with plugins that handle the SSR environment differently ([#13503](https://github.com/remix-run/react-router/pull/13503))
+- `@react-router/dev` - Fix "Status message is not supported by HTTP/2" error during dev when using HTTPS ([#13460](https://github.com/remix-run/react-router/pull/13460))
+- `@react-router/dev` - Update config when `react-router.config.ts` is created or deleted during development ([#12319](https://github.com/remix-run/react-router/pull/12319))
+- `@react-router/dev` - Skip unnecessary `routes.ts` evaluation before Vite build is started ([#13513](https://github.com/remix-run/react-router/pull/13513))
+- `@react-router/dev` - Fix `TS2300: Duplicate identifier` errors caused by generated types ([#13499](https://github.com/remix-run/react-router/pull/13499))
+- Previously, routes that had the same full path would cause duplicate entries in the generated types for `href` (`.react-router/types/+register.ts`), causing type checking errors
+
+### Unstable Changes
+
+⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_
+
+- `react-router` - Fix a few bugs with error bubbling in middleware use-cases ([#13538](https://github.com/remix-run/react-router/pull/13538))
+- `@react-router/dev` - When `future.unstable_viteEnvironmentApi` is enabled, ensure that `build.assetsDir` in Vite config is respected when `environments.client.build.assetsDir` is not configured ([#13491](https://github.com/remix-run/react-router/pull/13491))
+
+### Changes by Package
+
+- [`create-react-router`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/create-react-router/CHANGELOG.md#760)
+- [`react-router`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router/CHANGELOG.md#760)
+- [`@react-router/architect`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-architect/CHANGELOG.md#760)
+- [`@react-router/cloudflare`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-cloudflare/CHANGELOG.md#760)
+- [`@react-router/dev`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-dev/CHANGELOG.md#760)
+- [`@react-router/express`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-express/CHANGELOG.md#760)
+- [`@react-router/fs-routes`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-fs-routes/CHANGELOG.md#760)
+- [`@react-router/node`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-node/CHANGELOG.md#760)
+- [`@react-router/remix-config-routes-adapter`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-remix-config-routes-adapter/CHANGELOG.md#760)
+- [`@react-router/serve`](https://github.com/remix-run/react-router/blob/react-router%407.6.0/packages/react-router-serve/CHANGELOG.md#760)
+
+**Full Changelog**: [`v7.5.3...v7.6.0`](https://github.com/remix-run/react-router/compare/react-router@7.5.3...react-router@7.6.0)
+
## v7.5.3
Date: 2025-04-28
diff --git a/contributors.yml b/contributors.yml
index b7f7351fd2..1105211f05 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -111,6 +111,7 @@
- fyzhu
- fz6m
- gaspard
+- gatzjames
- gavriguy
- Geist5000
- gesposito
@@ -145,6 +146,7 @@
- ivanjonas
- Ivanrenes
- JackPriceBurns
+- jacob-briscoe
- jacob-ebey
- JaffParker
- jakkku
@@ -181,6 +183,7 @@
- kddnewton
- ken0x0a
- kentcdodds
+- kettanaito
- kiliman
- kkirsche
- kno-raziel
@@ -246,6 +249,7 @@
- namoscato
- ned-park
- nenene3
+- nichtsam
- nikeee
- nilubisan
- Nismit
@@ -333,6 +337,7 @@
- thethmuu
- thisiskartik
- thomasgauvin
+- ThomasTheTitan
- thomasverleye
- ThornWu
- tiborbarsi
diff --git a/docs/api/components/Link.md b/docs/api/components/Link.md
index ea6e2d8767..d4ba095ce7 100644
--- a/docs/api/components/Link.md
+++ b/docs/api/components/Link.md
@@ -93,6 +93,8 @@ Consider a route hierarchy where a parent route pattern is "blog" and a child ro
- **route** - default, resolves the link relative to the route pattern. In the example above a relative link of `".."` will remove both `:slug/edit` segments back to "/blog".
- **path** - relative to the path so `..` will only remove one URL segment up to "/blog/:slug"
+Note that index routes and layout routes have no paths so they are not included in the relative path calculation.
+
### reloadDocument
[modes: framework, data, declarative]
diff --git a/docs/api/hooks/useBlocker.md b/docs/api/hooks/useBlocker.md
index 9f91774e5f..21297ac991 100644
--- a/docs/api/hooks/useBlocker.md
+++ b/docs/api/hooks/useBlocker.md
@@ -25,3 +25,70 @@ useBlocker(shouldBlock): Blocker
[modes: framework, data]
_No documentation_
+
+## Examples
+
+### Basic
+
+```tsx
+import { useCallback, useState } from "react";
+import { BlockerFunction, useBlocker } from "react-router";
+
+export function ImportantForm() {
+ const [value, setValue] = useState("");
+
+ const shouldBlock = useCallback(
+ () => value !== "",
+ [value]
+ );
+ const blocker = useBlocker(shouldBlock);
+
+ return (
+
+ );
+}
+```
diff --git a/docs/api/hooks/useParams.md b/docs/api/hooks/useParams.md
index 77c5d4b9b0..a20d871701 100644
--- a/docs/api/hooks/useParams.md
+++ b/docs/api/hooks/useParams.md
@@ -13,21 +13,97 @@ title: useParams
Returns an object of key/value pairs of the dynamic params from the current URL that were matched by the routes. Child routes inherit all params from their parent routes.
```tsx
-import { useParams } from "react-router"
+import { useParams } from "react-router";
function SomeComponent() {
- let params = useParams()
- params.postId
+ let params = useParams();
+ params.postId;
}
```
Assuming a route pattern like `/posts/:postId` is matched by `/posts/123` then `params.postId` will be `"123"`.
+## Examples
+### Basic Usage
-## Signature
+```tsx
+import { useParams } from "react-router";
+
+// given a route like:
+} />;
+
+// or a data route like:
+createBrowserRouter([
+ {
+ path: "/posts/:postId",
+ component: Post,
+ },
+]);
+
+// or in routes.ts
+route("/posts/:postId", "routes/post.tsx");
+```
+
+Access the params in a component:
+
+```tsx
+import { useParams } from "react-router";
+
+export default function Post() {
+ let params = useParams();
+ return
Post: {params.postId}
;
+}
+```
+
+### Multiple Params
+
+Patterns can have multiple params:
+
+```tsx
+"/posts/:postId/comments/:commentId";
+```
+
+All will be available in the params object:
```tsx
-useParams(): Readonly
+import { useParams } from "react-router";
+
+export default function Post() {
+ let params = useParams();
+ return (
+
+ );
+}
```
+### Catchall Params
+
+Catchall params are defined with `*`:
+
+```tsx
+"/files/*";
+```
+
+The matched value will be available in the params object as follows:
+
+```tsx
+import { useParams } from "react-router";
+
+export default function File() {
+ let params = useParams();
+ let catchall = params["*"];
+ // ...
+}
+```
+
+You can destructure the catchall param:
+
+```tsx
+export default function File() {
+ let { "*": catchall } = useParams();
+ console.log(catchall);
+}
+```
diff --git a/docs/api/hooks/useSearchParams.md b/docs/api/hooks/useSearchParams.md
index c8b32f4cab..3ccf95c4d6 100644
--- a/docs/api/hooks/useSearchParams.md
+++ b/docs/api/hooks/useSearchParams.md
@@ -33,4 +33,65 @@ useSearchParams(defaultInit): undefined
[modes: framework, data, declarative]
-_No documentation_
+You can initialize the search params with a default value, though it **will not** change the URL on the first render.
+
+```tsx
+// a search param string
+useSearchParams("?tab=1");
+
+// a short-hand object
+useSearchParams({ tab: "1" });
+
+// object keys can be arrays for multiple values on the key
+useSearchParams({ brand: ["nike", "reebok"] });
+
+// an array of tuples
+useSearchParams([["tab", "1"]]);
+
+// a URLSearchParams object
+useSearchParams(new URLSearchParams("?tab=1"));
+```
+
+## SetSearchParams Function
+
+The second element of the tuple is a function that can be used to update the search params. It accepts the same types as `defaultInit` and will cause a navigation to the new URL.
+
+```tsx
+let [searchParams, setSearchParams] = useSearchParams();
+
+// a search param string
+setSearchParams("?tab=1");
+
+// a short-hand object
+setSearchParams({ tab: "1" });
+
+// object keys can be arrays for multiple values on the key
+setSearchParams({ brand: ["nike", "reebok"] });
+
+// an array of tuples
+setSearchParams([["tab", "1"]]);
+
+// a URLSearchParams object
+setSearchParams(new URLSearchParams("?tab=1"));
+```
+
+It also supports a function callback like `setState`:
+
+```tsx
+setSearchParams((searchParams) => {
+ searchParams.set("tab", "2");
+ return searchParams;
+});
+```
+
+## Notes
+
+Note that `searchParams` is a stable reference, so you can reliably use it as a dependency in `useEffect` hooks.
+
+```tsx
+useEffect(() => {
+ console.log(searchParams.get("tab"));
+}, [searchParams]);
+```
+
+However, this also means it's mutable. If you change the object without calling `setSearchParams`, its values will change between renders if some other state causes the component to re-render and URL will not reflect the values.
diff --git a/docs/how-to/suspense.md b/docs/how-to/suspense.md
index fc61dc7124..d3bcd1443f 100644
--- a/docs/how-to/suspense.md
+++ b/docs/how-to/suspense.md
@@ -29,6 +29,8 @@ export async function loader({}: Route.LoaderArgs) {
}
```
+Note you can't return a single promise, it must be an object with keys.
+
## 2. Render the fallback and resolved UI
The promise will be available on `loaderData`, `` will await the promise and trigger `` to render the fallback UI.
diff --git a/docs/start/data/route-object.md b/docs/start/data/route-object.md
index 995e6fb4b1..0ecb56ff8f 100644
--- a/docs/start/data/route-object.md
+++ b/docs/start/data/route-object.md
@@ -139,7 +139,17 @@ export default function Items() {
## `shouldRevalidate`
-By default, all routes are revalidated after actions. This function allows a route to opt-out of revalidation for actions that don't affect its data.
+Loader data is automatically revalidated after certain events like navigations and form submissions.
+
+This hook enables you to opt in or out of the default revalidation behavior. The default behavior is nuanced to avoid calling loaders unnecessarily.
+
+A route loader is revalidated when:
+
+- its own route params change
+- any change to URL search params
+- after any actions are called
+
+By defining this function, you opt out of the default behavior completely and can manually control when loader data is revalidated for navigations and form submissions.
```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
@@ -159,6 +169,10 @@ createBrowserRouter([
]);
```
+[`ShouldRevalidateFunctionArgs` Reference Documentation ↗](https://api.reactrouter.com/v7/interfaces/react_router.ShouldRevalidateFunctionArgs.html)
+
+Please note the default behavior is different in [Framework Mode](../modes).
+
## `lazy`
Most properties can be lazily imported to reduce the initial bundle size.
diff --git a/docs/start/framework/deploying.md b/docs/start/framework/deploying.md
index 99f2d47c43..1ed1dee57f 100644
--- a/docs/start/framework/deploying.md
+++ b/docs/start/framework/deploying.md
@@ -1,6 +1,6 @@
---
title: Deploying
-hidden: true
+order: 10
---
# Deploying
@@ -14,138 +14,104 @@ React Router can be deployed two ways:
- Fullstack Hosting
- Static Hosting
-To get the most benefits from React and React Router, we recommend fullstack hosting.
+The official [React Router templates](https://github.com/remix-run/react-router-templates) can help you bootstrap an application or be used as a reference for your own application.
-## Fullstack Hosting
+When deploying to static hosting, you can deploy React Router the same as any other single page application with React.
-You can get the most out of React and React Router by deploying to a fullstack hosting provider.
+## Templates
-### Cloudflare
+After running the `create-react-router` command, make sure to follow the instructions in the README.
-Click this button to automatically deploy a starter project with your GitHub account:
-
-[![Deploy to Cloudflare][cloudflare_button]][cloudflare]
-
-This template includes:
-
-- SQL database with Cloudflare D1
-- Key Value storage with Cloudflare KV
-- Asset upload and storage with Cloudflare R2
-- Image uploads, storage, and optimized `` component with Cloudflare Images
-
-[View it live →](https://react-router-template.pages.dev)
-
-### Epic Stack (Fly.io)
-
-Start with the Epic Stack template and follow the instructions in the README:
+### Node.js with Docker
```
-npx degit @epicweb-dev/template my-app
+npx create-react-router@latest --template remix-run/react-router-templates/default
```
-This maximalist template includes a lot, including, but not limited to:
+- Server Rendering
+- Tailwind CSS
-- Regional hosting on Fly.io
-- Multi-region, distributed, SQLite Database with LiteFS and Prisma
-- Image hosting
-- Error monitoring with Sentry
-- Grafana Dashboards of the running app
-- CI with GitHub actions
-- Authentication with Permissions
-- Full unit/integration testing setup
-- Transactional Email with Resend
+The containerized application can be deployed to any platform that supports Docker, including:
-[View it live →](https://react-router-template.fly.dev)
+- AWS ECS
+- Google Cloud Run
+- Azure Container Apps
+- Digital Ocean App Platform
+- Fly.io
+- Railway
-### Ion (AWS)
-
-Start with the ion template and follow the instructions in the README:
+### Node with Docker (Custom Server)
```
-npx degit @sst/react-template my-app
+npx create-react-router@latest --template remix-run/react-router-templates/node-custom-server
```
-This template includes:
+- Server Rendering
+- Tailwind CSS
+- Custom express server for more control
-- Data Persistence with DynamoDB
-- Delayed Jobs with Amazon SQS
-- Image uploads, storage, and optimized `` component with S3
-- Asset uploads and storage with S3
+The containerized application can be deployed to any platform that supports Docker, including:
-[View it live →](#TODO)
+- AWS ECS
+- Google Cloud Run
+- Azure Container Apps
+- Digital Ocean App Platform
+- Fly.io
+- Railway
-### Netlify
+### Node with Docker and Postgres
-Click this button to automatically deploy a starter project with your GitHub account:
-
-[![Deploy to Netlify][netlify_button]][netlify_spa]
+```
+npx create-react-router@latest --template remix-run/react-router-templates/node-postgres
+```
-This template includes:
+- Server Rendering
+- Postgres Database with Drizzle
+- Tailwind CSS
+- Custom express server for more control
-- Integration with Supabase
-- Optimized Image Transforms with `` and Netlify Image CDN
+The containerized application can be deployed to any platform that supports Docker, including:
-[View it live →](#TODO)
+- AWS ECS
+- Google Cloud Run
+- Azure Container Apps
+- Digital Ocean App Platform
+- Fly.io
+- Railway
### Vercel
-Click this button to automatically deploy a starter project with your GitHub account:
-
-[![Deploy to Vercel][vercel_button]][vercel_spa]
-
-This template includes:
-
-- Postgres database integration with Vercel Postgres
-- Optimized Image Transforms with `` and Vercel images
-- ISR for statically pre-rendered routes
-
-[View it live →](#TODO)
-
-### Manual Fullstack Deployment
-
-If you want to deploy to your own server or a different hosting provider, see the [Manual Deployment](../how-to/manual-deployment) guide.
-
-## Static Hosting
+```
+npx create-react-router@latest --template remix-run/react-router-templates/vercel
+```
-React Router doesn't require a server and can run on any static hosting provider.
+- Server Rendering
+- Tailwind CSS
-### Popular Static Hosting Providers
+### Cloudflare Workers w/ D1
-You can get started with the following Deploy Now buttons:
+```
+npx create-react-router@latest --template remix-run/react-router-templates/cloudflare-d1
+```
-[![Deploy SPA Cloudflare][cloudflare_button]][cloudflare_spa]
+- Server Rendering
+- D1 Database with Drizzle ORM
+- Tailwind CSS
-[![Deploy Netlify SPA][netlify_button]][netlify_spa]
+### Cloudflare Workers
-[![Deploy Vercel SPA][vercel_button]][vercel_spa]
+```
+npx create-react-router@latest --template remix-run/react-router-templates/cloudflare
+```
-### Manual Static Hosting
+- Server Rendering
+- Tailwind CSS
-Ensure the `ssr` flag is `false` in your Vite config:
+### Netlify
-```ts
-import react from "@react-router/dev/vite";
-import { defineConfig } from "vite";
-export default defineConfig({
- plugins: [react({ ssr: false })],
-});
```
-
-Build the app:
-
-```shellscript
-npx vite build
+npx create-react-router@latest --template remix-run/react-router-templates/netlify
```
-And then deploy the `build/client` folder to any static host.
-
-You'll need to ensure that all requests are routed to `index.html`. This is different with every host/server, so you'll need to find out how with your host/server.
-
-[netlify_button]: https://www.netlify.com/img/deploy/button.svg
-[netlify_spa]: https://app.netlify.com/start/deploy?repository=https://github.com/ryanflorence/templates&create_from_path=netlify-spa
-[netlify_spa]: https://app.netlify.com/start/deploy?repository=https://github.com/ryanflorence/templates&create_from_path=netlify
-[vercel_button]: https://vercel.com/button
-[vercel_spa]: https://vercel.com/new/clone?repository-url=https://github.com/ryanflorence/templates/tree/main/vercel-spa
-[cloudflare_button]: https://deploy.workers.cloudflare.com/button
-[cloudflare_spa]: https://deploy.workers.cloudflare.com/?url=https://github.com/ryanflorence/templates/tree/main/cloudflare-spa
-[cloudflare]: https://deploy.workers.cloudflare.com/?url=https://github.com/ryanflorence/templates/tree/main/cloudflare
+- Server Rendering
+- Tailwind CSS
diff --git a/docs/start/framework/route-module.md b/docs/start/framework/route-module.md
index e5cfd3584c..9f22d36b19 100644
--- a/docs/start/framework/route-module.md
+++ b/docs/start/framework/route-module.md
@@ -351,7 +351,9 @@ export default function Root() {
## `shouldRevalidate`
-By default, all routes are revalidated after actions. This function allows a route to opt-out of revalidation for actions that don't affect its data.
+In framework mode, route loaders are automatically revalidated after all navigations and form submissions (this is different from [Data Mode](../data/route-object#shouldrevalidate)). This enables middleware and loaders to share a request context and optimize in different ways than then they would be in Data Mode.
+
+Defining this function allows you to opt out of revalidation for a route loader for navigations and form submissions.
```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
@@ -363,6 +365,8 @@ export function shouldRevalidate(
}
```
+[`ShouldRevalidateFunctionArgs` Reference Documentation ↗](https://api.reactrouter.com/v7/interfaces/react_router.ShouldRevalidateFunctionArgs.html)
+
---
Next: [Rendering Strategies](./rendering)
diff --git a/docs/start/modes.md b/docs/start/modes.md
index 536dd09d2c..c16bf47043 100644
--- a/docs/start/modes.md
+++ b/docs/start/modes.md
@@ -151,7 +151,7 @@ This is mostly for the LLMs, but knock yourself out:
| useAsyncError | ✅ | ✅ | |
| useAsyncValue | ✅ | ✅ | |
| useBeforeUnload | ✅ | ✅ | ✅ |
-| useBlocker | ✅ | ✅ | ✅ |
+| useBlocker | ✅ | ✅ | |
| useFetcher | ✅ | ✅ | |
| useFetchers | ✅ | ✅ | |
| useFormAction | ✅ | ✅ | |
diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts
index 9dda94a4c1..e5e9bc8fe1 100644
--- a/integration/fog-of-war-test.ts
+++ b/integration/fog-of-war-test.ts
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
+import { PassThrough } from "node:stream";
import {
createAppFixture,
@@ -6,6 +7,7 @@ import {
js,
} from "./helpers/create-fixture.js";
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import { reactRouterConfig } from "./helpers/vite.js";
function getFiles() {
return {
@@ -118,6 +120,10 @@ test.describe("Fog of War", () => {
let res = await fixture.requestDocument("/");
let html = await res.text();
+ expect(html).toContain("window.__reactRouterManifest = {");
+ expect(html).not.toContain(
+ ' {
await app.clickLink("/a");
await page.waitForSelector("#a-index");
});
+
+ test("allows configuration of the manifest path", async ({ page }) => {
+ let fixture = await createFixture({
+ files: {
+ ...getFiles(),
+ "react-router.config.ts": reactRouterConfig({
+ routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" },
+ }),
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ let wrongManifestRequests: string[] = [];
+ let manifestRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.url().includes("/__manifest")) {
+ wrongManifestRequests.push(req.url());
+ }
+ if (req.url().includes("/custom-manifest")) {
+ manifestRequests.push(req.url());
+ }
+ });
+
+ await app.goto("/", true);
+ expect(
+ await page.evaluate(() =>
+ Object.keys((window as any).__reactRouterManifest.routes)
+ )
+ ).toEqual(["root", "routes/_index", "routes/a"]);
+ expect(manifestRequests).toEqual([
+ expect.stringMatching(/\/custom-manifest\?p=%2F&p=%2Fa&version=/),
+ ]);
+ manifestRequests = [];
+
+ await app.clickLink("/a");
+ await page.waitForSelector("#a");
+ expect(await app.getHtml("#a")).toBe(`
;
+ }
+ `,
+ "app/routes/fetch.tsx": js`
+ export async function loader() {
+ await new Promise((r) => setTimeout(r, 10000));
+ return 'nope';
+ }
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ // Capture console logs and uncaught errors
+ let msgs: string[] = [];
+ page.on("console", (msg) => msgs.push(msg.text()));
+ page.on("pageerror", (error) => msgs.push(error.message));
+
+ await app.goto("/", true);
+ app.clickElement("#fetch");
+ await app.clickSubmitButton("/?index");
+ await page.waitForSelector("#other");
+ expect(msgs).toEqual([]);
+ });
});
test.describe("prefetching", () => {
diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts
index 6128229996..bc3b9cfab9 100644
--- a/integration/vite-presets-test.ts
+++ b/integration/vite-presets-test.ts
@@ -29,7 +29,7 @@ const files = {
export default {
// Ensure user config takes precedence over preset config
appDirectory: "app",
-
+
presets: [
// Ensure user config is passed to reactRouterConfig hook
{
@@ -221,6 +221,7 @@ test.describe("Vite / presets", async () => {
"future",
"prerender",
"routes",
+ "routeDiscovery",
"serverBuildFile",
"serverBundles",
"serverModuleFormat",
diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md
index f60efec0a6..85040a474a 100644
--- a/packages/create-react-router/CHANGELOG.md
+++ b/packages/create-react-router/CHANGELOG.md
@@ -1,5 +1,9 @@
# `create-react-router`
+## 7.6.0
+
+_No changes_
+
## 7.5.3
_No changes_
diff --git a/packages/create-react-router/__tests__/github-mocks.ts b/packages/create-react-router/__tests__/github-mocks.ts
index 607e2ded8a..7662317518 100644
--- a/packages/create-react-router/__tests__/github-mocks.ts
+++ b/packages/create-react-router/__tests__/github-mocks.ts
@@ -1,6 +1,6 @@
import fsp from "node:fs/promises";
import * as path from "node:path";
-import { rest } from "msw";
+import { http } from "msw";
import type { setupServer } from "msw/node";
import invariant from "tiny-invariant";
@@ -47,10 +47,8 @@ type GHContent = {
encoding: "base64";
};
-type ResponseResolver = Parameters[1];
-
-let sendTarball: ResponseResolver = async (req, res, ctx) => {
- let { owner, repo } = req.params;
+let sendTarball = async (args: { owner: string; repo: string }) => {
+ let { owner, repo } = args;
invariant(typeof owner === "string", "owner must be a string");
invariant(typeof repo === "string", "repo must be a string");
@@ -65,139 +63,131 @@ let sendTarball: ResponseResolver = async (req, res, ctx) => {
let fileBuffer = await fsp.readFile(pathToTarball);
- return res(
- ctx.body(fileBuffer),
- ctx.set("Content-Type", "application/x-gzip")
- );
+ return new Response(fileBuffer, {
+ headers: {
+ "Content-Type": "application/x-gzip",
+ },
+ });
};
let githubHandlers: Array = [
- rest.head(
+ http.head(
`https://github.com/remix-run/react-router/tree/main/:type/:name`,
- async (_req, res, ctx) => {
- return res(ctx.status(200));
+ () => {
+ return new Response();
}
),
- rest.head(
+ http.head(
`https://github.com/remix-run/examples/tree/main/:type/:name`,
- async (_req, res, ctx) => {
- return res(ctx.status(200));
+ () => {
+ return new Response();
}
),
- rest.head(
+ http.head<{ status: string }>(
`https://github.com/error-username/:status`,
- async (req, res, ctx) => {
- return res(ctx.status(Number(req.params.status)));
+ ({ params }) => {
+ return new Response(null, { status: Number(params.status) });
}
),
- rest.head(`https://github.com/:owner/:repo`, async (req, res, ctx) => {
- return res(ctx.status(200));
+ http.head(`https://github.com/:owner/:repo`, () => {
+ return new Response();
}),
- rest.head(
+ http.head<{ status: string }>(
`https://api.github.com/repos/error-username/:status`,
- async (req, res, ctx) => {
- return res(ctx.status(Number(req.params.status)));
+ ({ params }) => {
+ return new Response(null, { status: Number(params.status) });
}
),
- rest.head(
+ http.head(
`https://api.github.com/repos/private-org/private-repo`,
- async (req, res, ctx) => {
+ ({ request }) => {
let status =
- req.headers.get("Authorization") === "token valid-token" ? 200 : 404;
- return res(ctx.status(status));
- }
- ),
- rest.head(
- `https://api.github.com/repos/:owner/:repo`,
- async (req, res, ctx) => {
- return res(ctx.status(200));
- }
- ),
- rest.head(
- `https://github.com/:owner/:repo/tree/:branch/:path*`,
- async (req, res, ctx) => {
- return res(ctx.status(200));
+ request.headers.get("Authorization") === "token valid-token"
+ ? 200
+ : 404;
+ return new Response(null, { status });
}
),
- rest.get(
+ http.head(`https://api.github.com/repos/:owner/:repo`, () => {
+ return new Response();
+ }),
+ http.head(`https://github.com/:owner/:repo/tree/:branch/:path*`, () => {
+ return new Response();
+ }),
+ http.get<{ owner: string; repo: string }>(
`https://api.github.com/repos/:owner/:repo/git/trees/:branch`,
- async (req, res, ctx) => {
- let { owner, repo } = req.params;
+ async ({ params }) => {
+ let { owner, repo } = params;
- return res(
- ctx.status(200),
- ctx.json({
- sha: "7d906ff5bbb79401a4a8ec1e1799845ed502c0a1",
- url: `https://api.github.com/repos/${owner}/${repo}/trees/7d906ff5bbb79401a4a8ec1e1799845ed502c0a1`,
- tree: [
- {
- path: "package.json",
- mode: "040000",
- type: "blob",
- sha: "a405cd8355516db9c96e1467fb14b74c97ac0a65",
- size: 138,
- url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/a405cd8355516db9c96e1467fb14b74c97ac0a65`,
- },
- {
- path: "template",
- mode: "040000",
- type: "tree",
- sha: "3f350a670e8fefd58535a9e1878539dc19afb4b5",
- url: `https://api.github.com/repos/${owner}/${repo}/trees/3f350a670e8fefd58535a9e1878539dc19afb4b5`,
- },
- ],
- })
- );
+ return Response.json({
+ sha: "7d906ff5bbb79401a4a8ec1e1799845ed502c0a1",
+ url: `https://api.github.com/repos/${owner}/${repo}/trees/7d906ff5bbb79401a4a8ec1e1799845ed502c0a1`,
+ tree: [
+ {
+ path: "package.json",
+ mode: "040000",
+ type: "blob",
+ sha: "a405cd8355516db9c96e1467fb14b74c97ac0a65",
+ size: 138,
+ url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/a405cd8355516db9c96e1467fb14b74c97ac0a65`,
+ },
+ {
+ path: "template",
+ mode: "040000",
+ type: "tree",
+ sha: "3f350a670e8fefd58535a9e1878539dc19afb4b5",
+ url: `https://api.github.com/repos/${owner}/${repo}/trees/3f350a670e8fefd58535a9e1878539dc19afb4b5`,
+ },
+ ],
+ });
}
),
- rest.get(
+ http.get<{ owner: string; repo: string; path: string }>(
`https://api.github.com/repos/:owner/:repo/contents/:path`,
- async (req, res, ctx) => {
- let { owner, repo } = req.params;
- if (typeof req.params.path !== "string") {
- throw new Error("req.params.path must be a string");
+ async ({ request, params }) => {
+ let { owner, repo } = params;
+ if (typeof params.path !== "string") {
+ throw new Error("params.path must be a string");
}
- let path = decodeURIComponent(req.params.path).trim();
+ let contentsPath = decodeURIComponent(params.path).trim();
let isMockable = owner === "remix-run" && repo === "react-router";
if (!isMockable) {
- let message = `Attempting to get content description for unmockable resource: ${owner}/${repo}/${path}`;
+ let message = `Attempting to get content description for unmockable resource: ${owner}/${repo}/${contentsPath}`;
console.error(message);
throw new Error(message);
}
- let localPath = path.join(__dirname, "../../..", path);
+ let localPath = path.join(__dirname, "../../..", contentsPath);
let isLocalDir = await isDirectory(localPath);
let isLocalFile = await isFile(localPath);
if (!isLocalDir && !isLocalFile) {
- return res(
- ctx.status(404),
- ctx.json({
+ return Response.json(
+ {
message: "Not Found",
documentation_url:
"https://docs.github.com/rest/reference/repos#get-repository-content",
- })
+ },
+ { status: 404 }
);
}
if (isLocalFile) {
let encoding = "base64" as const;
let content = await fsp.readFile(localPath, { encoding: "utf-8" });
- return res(
- ctx.status(200),
- ctx.json({
- content: Buffer.from(content, "utf-8").toString(encoding),
- encoding,
- })
- );
+ return Response.json({
+ content: Buffer.from(content, "utf-8").toString(encoding),
+ encoding,
+ });
}
let dirList = await fsp.readdir(localPath);
+ let url = new URL(request.url);
let contentDescriptions = await Promise.all(
dirList.map(async (name): Promise => {
- let relativePath = path.join(path, name);
+ let relativePath = path.join(contentsPath, name);
// NOTE: this is a cheat-code so we don't have to determine the sha of the file
// and our sha endpoint handler doesn't have to do a reverse-lookup.
let sha = relativePath;
@@ -209,31 +199,31 @@ let githubHandlers: Array = [
path: relativePath,
sha,
size,
- url: `https://api.github.com/repos/${owner}/${repo}/contents/${path}?${req.url.searchParams}`,
- html_url: `https://github.com/${owner}/${repo}/tree/main/${path}`,
+ url: `https://api.github.com/repos/${owner}/${repo}/contents/${contentsPath}?${url.searchParams}`,
+ html_url: `https://github.com/${owner}/${repo}/tree/main/${contentsPath}`,
git_url: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`,
download_url: null,
type: isDir ? "dir" : "file",
_links: {
- self: `https://api.github.com/repos/${owner}/${repo}/contents/${path}${req.url.searchParams}`,
+ self: `https://api.github.com/repos/${owner}/${repo}/contents/${contentsPath}${url.searchParams}`,
git: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`,
- html: `https://github.com/${owner}/${repo}/tree/main/${path}`,
+ html: `https://github.com/${owner}/${repo}/tree/main/${contentsPath}`,
},
};
})
);
- return res(ctx.json(contentDescriptions));
+ return Response.json(contentDescriptions);
}
),
- rest.get(
+ http.get<{ owner: string; repo: string; sha: string }>(
`https://api.github.com/repos/:owner/:repo/git/blobs/:sha`,
- async (req, res, ctx) => {
- let { owner, repo } = req.params;
- if (typeof req.params.sha !== "string") {
- throw new Error("req.params.sha must be a string");
+ async ({ params }) => {
+ let { owner, repo } = params;
+ if (typeof params.sha !== "string") {
+ throw new Error("params.sha must be a string");
}
- let sha = decodeURIComponent(req.params.sha).trim();
+ let sha = decodeURIComponent(params.sha).trim();
// if the sha includes a "/" that means it's not a sha but a relativePath
// and therefore the client is getting content it got from the local
// mock environment, not the actual github API.
@@ -259,17 +249,17 @@ let githubHandlers: Array = [
encoding,
};
- return res(ctx.json(resource));
+ return Response.json(resource);
}
),
- rest.get(
- `https://api.github.com/repos/:owner/:repo/contents/:path*`,
- async (req, res, ctx) => {
- let { owner, repo } = req.params;
+ http.get<{ owner: string; repo: string; path?: string[] }>(
+ `https://api.github.com/repos/:owner/:repo/contents{/*path}`,
+ async ({ params }) => {
+ let { owner, repo } = params;
- let relativePath = req.params.path;
+ let relativePath = params.path;
if (typeof relativePath !== "string") {
- throw new Error("req.params.path must be a string");
+ throw new Error("params.path must be a string");
}
let fullPath = path.join(__dirname, "..", relativePath);
let encoding = "base64" as const;
@@ -279,83 +269,80 @@ let githubHandlers: Array = [
let resource: GHContent = {
sha,
- node_id: `${req.params.path}_node_id`,
+ node_id: `${params.path}_node_id`,
size,
url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`,
content: Buffer.from(content, "utf-8").toString(encoding),
encoding,
};
- return res(ctx.json(resource));
+ return Response.json(resource);
}
),
- rest.get(
+ http.get<{ branch: string }>(
`https://codeload.github.com/private-org/private-repo/tar.gz/:branch`,
- (req, res, ctx) => {
- if (req.headers.get("Authorization") !== "token valid-token") {
- return res(ctx.status(404));
+ ({ request }) => {
+ if (request.headers.get("Authorization") !== "token valid-token") {
+ return new Response(null, { status: 404 });
}
- req.params.owner = "private-org";
- req.params.repo = "private-repo";
- return sendTarball(req, res, ctx);
+ return sendTarball({ owner: "private-org", repo: "private-repo" });
}
),
- rest.get(
+ http.get<{ owner: string; repo: string; branch: string }>(
`https://codeload.github.com/:owner/:repo/tar.gz/:branch`,
- sendTarball
+ ({ params }) => {
+ return sendTarball({ owner: params.owner, repo: params.repo });
+ }
),
- rest.get(
+ http.get(
`https://api.github.com/repos/private-org/private-repo/tarball`,
- (req, res, ctx) => {
- if (req.headers.get("Authorization") !== "token valid-token") {
- return res(ctx.status(404));
+ ({ request }) => {
+ if (request.headers.get("Authorization") !== "token valid-token") {
+ return new Response(null, { status: 404 });
}
- req.params.owner = "private-org";
- req.params.repo = "private-repo";
-
- return sendTarball(req, res, ctx);
+ return sendTarball({ owner: "private-org", repo: "private-repo" });
}
),
- rest.get(
+ http.get<{ tag: string }>(
`https://api.github.com/repos/private-org/private-repo/releases/tags/:tag`,
- (req, res, ctx) => {
- if (req.headers.get("Authorization") !== "token valid-token") {
- return res(ctx.status(404));
+ ({ request, params }) => {
+ if (request.headers.get("Authorization") !== "token valid-token") {
+ return new Response(null, { status: 404 });
}
- let { tag } = req.params;
- return res(
- ctx.status(200),
- ctx.json({
- assets: [
- {
- browser_download_url: `https://github.com/private-org/private-repo/releases/download/${tag}/template.tar.gz`,
- id: "working-asset-id",
- },
- ],
- })
- );
+ let { tag } = params;
+ return Response.json({
+ assets: [
+ {
+ browser_download_url: `https://github.com/private-org/private-repo/releases/download/${tag}/template.tar.gz`,
+ id: "working-asset-id",
+ },
+ ],
+ });
}
),
- rest.get(
+ http.get(
`https://api.github.com/repos/private-org/private-repo/releases/assets/working-asset-id`,
- (req, res, ctx) => {
- if (req.headers.get("Authorization") !== "token valid-token") {
- return res(ctx.status(404));
+ ({ request }) => {
+ if (request.headers.get("Authorization") !== "token valid-token") {
+ return new Response(null, { status: 404 });
}
- req.params.owner = "private-org";
- req.params.repo = "private-repo";
- return sendTarball(req, res, ctx);
+ return sendTarball({ owner: "private-org", repo: "private-repo" });
}
),
- rest.get(
+ http.get<{ status: string }>(
`https://api.github.com/repos/error-username/:status/tarball`,
- async (req, res, ctx) => {
- return res(ctx.status(Number(req.params.status)));
+ ({ params }) => {
+ return new Response(null, { status: Number(params.status) });
+ }
+ ),
+ http.get<{ owner: string; repo: string }>(
+ `https://api.github.com/repos/:owner/:repo/tarball`,
+ ({ params }) => {
+ return sendTarball({ owner: params.owner, repo: params.repo });
}
),
- rest.get(`https://api.github.com/repos/:owner/:repo/tarball`, sendTarball),
- rest.get(`https://api.github.com/repos/:repo*`, async (req, res, ctx) => {
- return res(ctx.json({ default_branch: "main" }));
+ http.get(`https://api.github.com/repos/:repo*`, () => {
+ return Response.json({ default_branch: "main" });
}),
];
diff --git a/packages/create-react-router/__tests__/msw.ts b/packages/create-react-router/__tests__/msw.ts
index cb0bbbf058..73c2e526c5 100644
--- a/packages/create-react-router/__tests__/msw.ts
+++ b/packages/create-react-router/__tests__/msw.ts
@@ -1,43 +1,39 @@
import path from "node:path";
import fsp from "node:fs/promises";
import { setupServer } from "msw/node";
-import { rest } from "msw";
+import { http, type RequestHandler } from "msw";
import { githubHandlers } from "./github-mocks";
-type RequestHandler = Parameters[0];
-
let miscHandlers: Array = [
- rest.get(
- "https://registry.npmjs.org/react-router/latest",
- async (req, res, ctx) => {
- return res(ctx.body(JSON.stringify({ version: "123.0.0" })));
- }
- ),
- rest.head(
+ http.get("https://registry.npmjs.org/react-router/latest", () => {
+ return Response.json({ version: "123.0.0" });
+ }),
+ http.head<{ status: string }>(
"https://example.com/error/:status/template.tar.gz",
- async (req, res, ctx) => {
- return res(ctx.status(Number(req.params.status)));
+ ({ params }) => {
+ return new Response(null, { status: Number(params.status) });
}
),
- rest.get(
+ http.get<{ status: string }>(
"https://example.com/error/:status/template.tar.gz",
- async (req, res, ctx) => {
- return res(ctx.status(Number(req.params.status)));
+ ({ params }) => {
+ return new Response(null, { status: Number(params.status) });
}
),
- rest.head("https://example.com/template.tar.gz", async (req, res, ctx) => {
- return res(ctx.status(200));
+ http.head("https://example.com/template.tar.gz", () => {
+ return new Response();
}),
- rest.get("https://example.com/template.tar.gz", async (req, res, ctx) => {
+ http.get("https://example.com/template.tar.gz", async () => {
let fileBuffer = await fsp.readFile(
path.join(__dirname, "fixtures", "template.tar.gz")
);
- return res(
- ctx.body(fileBuffer),
- ctx.set("Content-Type", "application/x-gzip")
- );
+ return new Response(fileBuffer, {
+ headers: {
+ "Content-Type": "application/x-gzip",
+ },
+ });
}),
];
diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json
index 096b536fd8..c5587c879f 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.5.3",
+ "version": "7.6.0",
"description": "Create a new React Router app",
"homepage": "https://reactrouter.com",
"bugs": {
@@ -57,7 +57,7 @@
"@types/tar-fs": "^2.0.1",
"esbuild": "0.25.0",
"esbuild-register": "^3.6.0",
- "msw": "^1.2.3",
+ "msw": "^2.7.5",
"tiny-invariant": "^1.2.0",
"tsup": "^8.3.0",
"typescript": "^5.1.6",
diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md
index 62c3f93227..a7d1626957 100644
--- a/packages/react-router-architect/CHANGELOG.md
+++ b/packages/react-router-architect/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/architect`
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+ - `@react-router/node@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json
index 2c3b0c6241..d701acf959 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.5.3",
+ "version": "7.6.0",
"description": "Architect server request handler for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md
index 9d00dc827f..a1676a6975 100644
--- a/packages/react-router-cloudflare/CHANGELOG.md
+++ b/packages/react-router-cloudflare/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/cloudflare`
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json
index 56909f2e74..9349c573b8 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.5.3",
+ "version": "7.6.0",
"description": "Cloudflare platform abstractions for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md
index 875b0059e9..89b7f678f5 100644
--- a/packages/react-router-dev/CHANGELOG.md
+++ b/packages/react-router-dev/CHANGELOG.md
@@ -1,5 +1,98 @@
# `@react-router/dev`
+## 7.6.0
+
+### Minor Changes
+
+- Added a new `react-router.config.ts` `routeDiscovery` option to configure Lazy Route Discovery behavior. ([#13451](https://github.com/remix-run/react-router/pull/13451))
+
+ - By default, Lazy Route Discovery is enabled and makes manifest requests to the `/__manifest` path:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }`
+ - You can modify the manifest path used:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" }`
+ - Or you can disable this feature entirely and include all routes in the manifest on initial document load:
+ - `routeDiscovery: { mode: "initial" }`
+
+- Automatic types for future flags ([#13506](https://github.com/remix-run/react-router/pull/13506))
+
+ Some future flags alter the way types should work in React Router.
+ Previously, you had to remember to manually opt-in to the new types.
+
+ For example, for `unstable_middleware`:
+
+ ```ts
+ // react-router.config.ts
+
+ // Step 1: Enable middleware
+ export default {
+ future: {
+ unstable_middleware: true,
+ },
+ };
+
+ // Step 2: Enable middleware types
+ declare module "react-router" {
+ interface Future {
+ unstable_middleware: true; // 👈 Enable middleware types
+ }
+ }
+ ```
+
+ It was up to you to keep the runtime future flags synced with the types for those future flags.
+ This was confusing and error-prone.
+
+ Now, React Router will automatically enable types for future flags.
+ That means you only need to specify the runtime future flag:
+
+ ```ts
+ // react-router.config.ts
+
+ // Step 1: Enable middleware
+ export default {
+ future: {
+ unstable_middleware: true,
+ },
+ };
+
+ // No step 2! That's it!
+ ```
+
+ Behind the scenes, React Router will generate the corresponding `declare module` into `.react-router/types`.
+ Currently this is done in `.react-router/types/+register.ts` but this is an implementation detail that may change in the future.
+
+### Patch Changes
+
+- Support project root directories without a `package.json` if it exists in a parent directory ([#13472](https://github.com/remix-run/react-router/pull/13472))
+
+- When providing a custom Vite config path via the CLI `--config`/`-c` flag, default the project root directory to the directory containing the Vite config when not explicitly provided ([#13472](https://github.com/remix-run/react-router/pull/13472))
+
+- In a `routes.ts` context, ensure the `--mode` flag is respected for `import.meta.env.MODE` ([#13485](https://github.com/remix-run/react-router/pull/13485))
+
+ Previously, `import.meta.env.MODE` within a `routes.ts` context was always `"development"` for the `dev` and `typegen --watch` commands, but otherwise resolved to `"production"`. These defaults are still in place, but if a `--mode` flag is provided, this will now take precedence.
+
+- Ensure consistent project root directory resolution logic in CLI commands ([#13472](https://github.com/remix-run/react-router/pull/13472))
+
+- When executing `react-router.config.ts` and `routes.ts` with `vite-node`, ensure that PostCSS config files are ignored ([#13489](https://github.com/remix-run/react-router/pull/13489))
+
+- When extracting critical CSS during development, ensure it's loaded from the client environment to avoid issues with plugins that handle the SSR environment differently ([#13503](https://github.com/remix-run/react-router/pull/13503))
+
+- When `future.unstable_viteEnvironmentApi` is enabled, ensure that `build.assetsDir` in Vite config is respected when `environments.client.build.assetsDir` is not configured ([#13491](https://github.com/remix-run/react-router/pull/13491))
+
+- Fix "Status message is not supported by HTTP/2" error during dev when using HTTPS ([#13460](https://github.com/remix-run/react-router/pull/13460))
+
+- Update config when `react-router.config.ts` is created or deleted during development. ([#12319](https://github.com/remix-run/react-router/pull/12319))
+
+- Skip unnecessary `routes.ts` evaluation before Vite build is started ([#13513](https://github.com/remix-run/react-router/pull/13513))
+
+- Fix `TS2300: Duplicate identifier` errors caused by generated types ([#13499](https://github.com/remix-run/react-router/pull/13499))
+
+ Previously, routes that had the same full path would cause duplicate entries in the generated types for `href` (`.react-router/types/+register.ts`), causing type checking errors.
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+ - `@react-router/node@7.6.0`
+ - `@react-router/serve@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-dev/cli/commands.ts b/packages/react-router-dev/cli/commands.ts
index 62005266eb..782d631ac1 100644
--- a/packages/react-router-dev/cli/commands.ts
+++ b/packages/react-router-dev/cli/commands.ts
@@ -17,14 +17,18 @@ import * as Typegen from "../typegen";
import { preloadVite, getVite } from "../vite/vite";
export async function routes(
- reactRouterRoot?: string,
+ rootDirectory?: string,
flags: {
config?: string;
json?: boolean;
+ mode?: string;
} = {}
): Promise {
- let rootDirectory = reactRouterRoot ?? process.cwd();
- let configResult = await loadConfig({ rootDirectory });
+ rootDirectory = resolveRootDirectory(rootDirectory, flags);
+ let configResult = await loadConfig({
+ rootDirectory,
+ mode: flags.mode ?? "production",
+ });
if (!configResult.ok) {
console.error(colors.red(configResult.error));
@@ -39,9 +43,7 @@ export async function build(
root?: string,
options: ViteBuildOptions = {}
): Promise {
- if (!root) {
- root = process.env.REACT_ROUTER_ROOT || process.cwd();
- }
+ root = resolveRootDirectory(root, options);
let { build } = await import("../vite/build");
if (options.profile) {
@@ -54,12 +56,14 @@ export async function build(
}
}
-export async function dev(root: string, options: ViteDevOptions = {}) {
+export async function dev(root?: string, options: ViteDevOptions = {}) {
let { dev } = await import("../vite/dev");
if (options.profile) {
await profiler.start();
}
exitHook(() => profiler.stop(console.info));
+
+ root = resolveRootDirectory(root, options);
await dev(root, options);
// keep `react-router dev` alive by waiting indefinitely
@@ -77,21 +81,25 @@ let conjunctionListFormat = new Intl.ListFormat("en", {
export async function generateEntry(
entry?: string,
- reactRouterRoot?: string,
+ rootDirectory?: string,
flags: {
typescript?: boolean;
config?: string;
+ mode?: string;
} = {}
) {
// if no entry passed, attempt to create both
if (!entry) {
- await generateEntry("entry.client", reactRouterRoot, flags);
- await generateEntry("entry.server", reactRouterRoot, flags);
+ await generateEntry("entry.client", rootDirectory, flags);
+ await generateEntry("entry.server", rootDirectory, flags);
return;
}
- let rootDirectory = reactRouterRoot ?? process.cwd();
- let configResult = await loadConfig({ rootDirectory });
+ rootDirectory = resolveRootDirectory(rootDirectory, flags);
+ let configResult = await loadConfig({
+ rootDirectory,
+ mode: flags.mode ?? "production",
+ });
if (!configResult.ok) {
console.error(colors.red(configResult.error));
@@ -162,6 +170,17 @@ export async function generateEntry(
);
}
+function resolveRootDirectory(root?: string, flags?: { config?: string }) {
+ if (root) {
+ return path.resolve(root);
+ }
+
+ return (
+ process.env.REACT_ROUTER_ROOT ||
+ (flags?.config ? path.dirname(path.resolve(flags.config)) : process.cwd())
+ );
+}
+
async function checkForEntry(
rootDirectory: string,
appDirectory: string,
@@ -198,17 +217,30 @@ async function createClientEntry(
return contents;
}
-export async function typegen(root: string, flags: { watch: boolean }) {
- root ??= process.cwd();
+export async function typegen(
+ root: string,
+ flags: {
+ watch: boolean;
+ mode?: string;
+ config?: string;
+ }
+) {
+ root = resolveRootDirectory(root, flags);
if (flags.watch) {
await preloadVite();
const vite = getVite();
const logger = vite.createLogger("info", { prefix: "[react-router]" });
- await Typegen.watch(root, { logger });
+ await Typegen.watch(root, {
+ mode: flags.mode ?? "development",
+ logger,
+ });
await new Promise(() => {}); // keep alive
return;
}
- await Typegen.run(root);
+
+ await Typegen.run(root, {
+ mode: flags.mode ?? "production",
+ });
}
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index 999e19eec3..5c130eb6cf 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
import PackageJson from "@npmcli/package-json";
import * as ViteNode from "../vite/vite-node";
import type * as Vite from "vite";
-import path from "pathe";
+import Path from "pathe";
import chokidar, {
type FSWatcher,
type EmitArgs as ChokidarEmitArgs,
@@ -158,6 +158,24 @@ export type ReactRouterConfig = {
* other platforms and tools.
*/
presets?: Array;
+ /**
+ * Control the "Lazy Route Discovery" behavior
+ *
+ * - `routeDiscovery.mode`: By default, this resolves to `lazy` which will
+ * lazily discover routes as the user navigates around your application.
+ * You can set this to `initial` to opt-out of this behavior and load all
+ * routes with the initial HTML document load.
+ * - `routeDiscovery.manifestPath`: The path to serve the manifest file from.
+ * Only applies to `mode: "lazy"` and defaults to `/__manifest`.
+ */
+ routeDiscovery?:
+ | {
+ mode: "lazy";
+ manifestPath?: string;
+ }
+ | {
+ mode: "initial";
+ };
/**
* The file name of the server build output. This file
* should end in a `.js` extension and should be deployed to your server.
@@ -205,6 +223,17 @@ export type ResolvedReactRouterConfig = Readonly<{
* function returning an array to dynamically generate URLs.
*/
prerender: ReactRouterConfig["prerender"];
+ /**
+ * Control the "Lazy Route Discovery" behavior
+ *
+ * - `routeDiscovery.mode`: By default, this resolves to `lazy` which will
+ * lazily discover routes as the user navigates around your application.
+ * You can set this to `initial` to opt-out of this behavior and load all
+ * routes with the initial HTML document load.
+ * - `routeDiscovery.manifestPath`: The path to serve the manifest file from.
+ * Only applies to `mode: "lazy"` and defaults to `/__manifest`.
+ */
+ routeDiscovery: ReactRouterConfig["routeDiscovery"];
/**
* An object of all available routes, keyed by route id.
*/
@@ -321,10 +350,12 @@ async function resolveConfig({
root,
viteNodeContext,
reactRouterConfigFile,
+ skipRoutes,
}: {
root: string;
viteNodeContext: ViteNode.Context;
reactRouterConfigFile?: string;
+ skipRoutes?: boolean;
}): Promise> {
let reactRouterUserConfig: ReactRouterConfig = {};
@@ -388,19 +419,25 @@ async function resolveConfig({
ssr: true,
} as const satisfies Partial;
+ let userAndPresetConfigs = mergeReactRouterConfig(
+ ...presets,
+ reactRouterUserConfig
+ );
+
let {
appDirectory: userAppDirectory,
basename,
buildDirectory: userBuildDirectory,
buildEnd,
prerender,
+ routeDiscovery: userRouteDiscovery,
serverBuildFile,
serverBundles,
serverModuleFormat,
ssr,
} = {
...defaults, // Default values should be completely overridden by user/preset config, not merged
- ...mergeReactRouterConfig(...presets, reactRouterUserConfig),
+ ...userAndPresetConfigs,
};
if (!ssr && serverBundles) {
@@ -420,75 +457,111 @@ async function resolveConfig({
);
}
- let appDirectory = path.resolve(root, userAppDirectory || "app");
- let buildDirectory = path.resolve(root, userBuildDirectory);
+ let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"];
+ if (userRouteDiscovery == null) {
+ if (ssr) {
+ routeDiscovery = {
+ mode: "lazy",
+ manifestPath: "/__manifest",
+ };
+ } else {
+ routeDiscovery = { mode: "initial" };
+ }
+ } else if (userRouteDiscovery.mode === "initial") {
+ routeDiscovery = userRouteDiscovery;
+ } else if (userRouteDiscovery.mode === "lazy") {
+ if (!ssr) {
+ return err(
+ 'The `routeDiscovery.mode` config cannot be set to "lazy" when setting `ssr:false`'
+ );
+ }
+
+ let { manifestPath } = userRouteDiscovery;
+ if (manifestPath != null && !manifestPath.startsWith("/")) {
+ return err(
+ "The `routeDiscovery.manifestPath` config must be a root-relative " +
+ 'pathname beginning with a slash (i.e., "/__manifest")'
+ );
+ }
+
+ routeDiscovery = userRouteDiscovery;
+ }
+
+ let appDirectory = Path.resolve(root, userAppDirectory || "app");
+ let buildDirectory = Path.resolve(root, userBuildDirectory);
let rootRouteFile = findEntry(appDirectory, "root");
if (!rootRouteFile) {
- let rootRouteDisplayPath = path.relative(
+ let rootRouteDisplayPath = Path.relative(
root,
- path.join(appDirectory, "root.tsx")
+ Path.join(appDirectory, "root.tsx")
);
return err(
`Could not find a root route module in the app directory as "${rootRouteDisplayPath}"`
);
}
- let routes: RouteManifest = {
- root: { path: "", id: "root", file: rootRouteFile },
- };
+ let routes: RouteManifest = {};
- let routeConfigFile = findEntry(appDirectory, "routes");
+ if (!skipRoutes) {
+ routes = {
+ root: { path: "", id: "root", file: rootRouteFile },
+ };
- try {
- if (!routeConfigFile) {
- let routeConfigDisplayPath = path.relative(
- root,
- path.join(appDirectory, "routes.ts")
- );
- return err(`Route config file not found at "${routeConfigDisplayPath}".`);
- }
+ let routeConfigFile = findEntry(appDirectory, "routes");
- setAppDirectory(appDirectory);
- let routeConfigExport = (
- await viteNodeContext.runner.executeFile(
- path.join(appDirectory, routeConfigFile)
- )
- ).default;
- let routeConfig = await routeConfigExport;
-
- let result = validateRouteConfig({
- routeConfigFile,
- routeConfig,
- });
+ try {
+ if (!routeConfigFile) {
+ let routeConfigDisplayPath = Path.relative(
+ root,
+ Path.join(appDirectory, "routes.ts")
+ );
+ return err(
+ `Route config file not found at "${routeConfigDisplayPath}".`
+ );
+ }
- if (!result.valid) {
- return err(result.message);
- }
+ setAppDirectory(appDirectory);
+ let routeConfigExport = (
+ await viteNodeContext.runner.executeFile(
+ Path.join(appDirectory, routeConfigFile)
+ )
+ ).default;
+ let routeConfig = await routeConfigExport;
+
+ let result = validateRouteConfig({
+ routeConfigFile,
+ routeConfig,
+ });
- routes = {
- ...routes,
- ...configRoutesToRouteManifest(appDirectory, routeConfig),
- };
- } catch (error: any) {
- return err(
- [
- colors.red(`Route config in "${routeConfigFile}" is invalid.`),
- "",
- error.loc?.file && error.loc?.column && error.frame
- ? [
- path.relative(appDirectory, error.loc.file) +
- ":" +
- error.loc.line +
- ":" +
- error.loc.column,
- error.frame.trim?.(),
- ]
- : error.stack,
- ]
- .flat()
- .join("\n")
- );
+ if (!result.valid) {
+ return err(result.message);
+ }
+
+ routes = {
+ ...routes,
+ ...configRoutesToRouteManifest(appDirectory, routeConfig),
+ };
+ } catch (error: any) {
+ return err(
+ [
+ colors.red(`Route config in "${routeConfigFile}" is invalid.`),
+ "",
+ error.loc?.file && error.loc?.column && error.frame
+ ? [
+ Path.relative(appDirectory, error.loc.file) +
+ ":" +
+ error.loc.line +
+ ":" +
+ error.loc.column,
+ error.frame.trim?.(),
+ ]
+ : error.stack,
+ ]
+ .flat()
+ .join("\n")
+ );
+ }
}
let future: FutureConfig = {
@@ -512,11 +585,12 @@ async function resolveConfig({
future,
prerender,
routes,
+ routeDiscovery,
serverBuildFile,
serverBundles,
serverModuleFormat,
ssr,
- });
+ } satisfies ResolvedReactRouterConfig);
for (let preset of reactRouterUserConfig.presets ?? []) {
await preset.reactRouterConfigResolved?.({ reactRouterConfig });
@@ -529,7 +603,8 @@ type ChokidarEventName = ChokidarEmitArgs[0];
type ChangeHandler = (args: {
result: Result;
- configCodeUpdated: boolean;
+ configCodeChanged: boolean;
+ routeConfigCodeChanged: boolean;
configChanged: boolean;
routeConfigChanged: boolean;
path: string;
@@ -545,23 +620,38 @@ export type ConfigLoader = {
export async function createConfigLoader({
rootDirectory: root,
watch,
+ mode,
+ skipRoutes,
}: {
watch: boolean;
rootDirectory?: string;
+ mode: string;
+ skipRoutes?: boolean;
}): Promise {
- root = root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd();
+ root = Path.normalize(root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd());
+ let vite = await import("vite");
let viteNodeContext = await ViteNode.createContext({
root,
- mode: watch ? "development" : "production",
+ mode,
+ // Filter out any info level logs from vite-node
+ customLogger: vite.createLogger("warn", {
+ prefix: "[react-router]",
+ }),
});
- let reactRouterConfigFile = findEntry(root, "react-router.config", {
- absolute: true,
- });
+ let reactRouterConfigFile: string | undefined;
+
+ let updateReactRouterConfigFile = () => {
+ reactRouterConfigFile = findEntry(root, "react-router.config", {
+ absolute: true,
+ });
+ };
+
+ updateReactRouterConfigFile();
let getConfig = () =>
- resolveConfig({ root, viteNodeContext, reactRouterConfigFile });
+ resolveConfig({ root, viteNodeContext, reactRouterConfigFile, skipRoutes });
let appDirectory: string;
@@ -571,9 +661,9 @@ export async function createConfigLoader({
throw new Error(initialConfigResult.error);
}
- appDirectory = initialConfigResult.value.appDirectory;
+ appDirectory = Path.normalize(initialConfigResult.value.appDirectory);
- let lastConfig = initialConfigResult.value;
+ let currentConfig = initialConfigResult.value;
let fsWatcher: FSWatcher | undefined;
let changeHandlers: ChangeHandler[] = [];
@@ -590,54 +680,110 @@ export async function createConfigLoader({
changeHandlers.push(handler);
if (!fsWatcher) {
- fsWatcher = chokidar.watch(
- [
- ...(reactRouterConfigFile ? [reactRouterConfigFile] : []),
- appDirectory,
- ],
- { ignoreInitial: true }
- );
+ fsWatcher = chokidar.watch([root, appDirectory], {
+ ignoreInitial: true,
+ ignored: (path) => {
+ let dirname = Path.dirname(path);
+
+ return (
+ !dirname.startsWith(appDirectory) &&
+ // Ensure we're only watching files outside of the app directory
+ // that are at the root level, not nested in subdirectories
+ path !== root && // Watch the root directory itself
+ dirname !== root // Watch files at the root level
+ );
+ },
+ });
fsWatcher.on("all", async (...args: ChokidarEmitArgs) => {
let [event, rawFilepath] = args;
- let filepath = path.normalize(rawFilepath);
+ let filepath = Path.normalize(rawFilepath);
+
+ let fileAddedOrRemoved = event === "add" || event === "unlink";
let appFileAddedOrRemoved =
- appDirectory &&
- (event === "add" || event === "unlink") &&
- filepath.startsWith(path.normalize(appDirectory));
+ fileAddedOrRemoved &&
+ filepath.startsWith(Path.normalize(appDirectory));
- let configCodeUpdated = Boolean(
- viteNodeContext.devServer?.moduleGraph.getModuleById(filepath)
- );
+ let rootRelativeFilepath = Path.relative(root, filepath);
+
+ let configFileAddedOrRemoved =
+ fileAddedOrRemoved &&
+ isEntryFile("react-router.config", rootRelativeFilepath);
+
+ if (configFileAddedOrRemoved) {
+ updateReactRouterConfigFile();
+ }
+
+ let moduleGraphChanged =
+ configFileAddedOrRemoved ||
+ Boolean(
+ viteNodeContext.devServer?.moduleGraph.getModuleById(filepath)
+ );
- if (configCodeUpdated || appFileAddedOrRemoved) {
- viteNodeContext.devServer?.moduleGraph.invalidateAll();
- viteNodeContext.runner?.moduleCache.clear();
+ // Bail out if no relevant changes detected
+ if (!moduleGraphChanged && !appFileAddedOrRemoved) {
+ return;
}
- if (appFileAddedOrRemoved || configCodeUpdated) {
- let result = await getConfig();
+ viteNodeContext.devServer?.moduleGraph.invalidateAll();
+ viteNodeContext.runner?.moduleCache.clear();
+
+ let result = await getConfig();
- let configChanged = result.ok && !isEqual(lastConfig, result.value);
+ let prevAppDirectory = appDirectory;
+ appDirectory = Path.normalize(
+ (result.value ?? currentConfig).appDirectory
+ );
- let routeConfigChanged =
- result.ok && !isEqual(lastConfig?.routes, result.value.routes);
+ if (appDirectory !== prevAppDirectory) {
+ fsWatcher!.unwatch(prevAppDirectory);
+ fsWatcher!.add(appDirectory);
+ }
- for (let handler of changeHandlers) {
- handler({
- result,
- configCodeUpdated,
- configChanged,
- routeConfigChanged,
- path: filepath,
- event,
- });
- }
+ let configCodeChanged =
+ configFileAddedOrRemoved ||
+ (reactRouterConfigFile !== undefined &&
+ isEntryFileDependency(
+ viteNodeContext.devServer.moduleGraph,
+ reactRouterConfigFile,
+ filepath
+ ));
+
+ let routeConfigFile = !skipRoutes
+ ? findEntry(appDirectory, "routes", {
+ absolute: true,
+ })
+ : undefined;
+ let routeConfigCodeChanged =
+ routeConfigFile !== undefined &&
+ isEntryFileDependency(
+ viteNodeContext.devServer.moduleGraph,
+ routeConfigFile,
+ filepath
+ );
+
+ let configChanged =
+ result.ok &&
+ !isEqual(omitRoutes(currentConfig), omitRoutes(result.value));
+
+ let routeConfigChanged =
+ result.ok && !isEqual(currentConfig?.routes, result.value.routes);
+
+ for (let handler of changeHandlers) {
+ handler({
+ result,
+ configCodeChanged,
+ routeConfigCodeChanged,
+ configChanged,
+ routeConfigChanged,
+ path: filepath,
+ event,
+ });
+ }
- if (result.ok) {
- lastConfig = result.value;
- }
+ if (result.ok) {
+ currentConfig = result.value;
}
});
}
@@ -656,9 +802,19 @@ export async function createConfigLoader({
};
}
-export async function loadConfig({ rootDirectory }: { rootDirectory: string }) {
+export async function loadConfig({
+ rootDirectory,
+ mode,
+ skipRoutes,
+}: {
+ rootDirectory: string;
+ mode: string;
+ skipRoutes?: boolean;
+}) {
let configLoader = await createConfigLoader({
rootDirectory,
+ mode,
+ skipRoutes,
watch: false,
});
let config = await configLoader.getConfig();
@@ -675,8 +831,8 @@ export async function resolveEntryFiles({
}) {
let { appDirectory } = reactRouterConfig;
- let defaultsDirectory = path.resolve(
- path.dirname(require.resolve("@react-router/dev/package.json")),
+ let defaultsDirectory = Path.resolve(
+ Path.dirname(require.resolve("@react-router/dev/package.json")),
"dist",
"config",
"defaults"
@@ -688,7 +844,20 @@ export async function resolveEntryFiles({
let entryServerFile: string;
let entryClientFile = userEntryClientFile || "entry.client.tsx";
- let pkgJson = await PackageJson.load(rootDirectory);
+ let packageJsonPath = findEntry(rootDirectory, "package", {
+ extensions: [".json"],
+ absolute: true,
+ walkParents: true,
+ });
+
+ if (!packageJsonPath) {
+ throw new Error(
+ `Could not find package.json in ${rootDirectory} or any of its parent directories`
+ );
+ }
+
+ let packageJsonDirectory = Path.dirname(packageJsonPath);
+ let pkgJson = await PackageJson.load(packageJsonDirectory);
let deps = pkgJson.content.dependencies ?? {};
if (userEntryServerFile) {
@@ -717,7 +886,7 @@ export async function resolveEntryFiles({
let packageManager = detectPackageManager() ?? "npm";
execSync(`${packageManager} install`, {
- cwd: rootDirectory,
+ cwd: packageJsonDirectory,
stdio: "inherit",
});
}
@@ -726,29 +895,105 @@ export async function resolveEntryFiles({
}
let entryClientFilePath = userEntryClientFile
- ? path.resolve(reactRouterConfig.appDirectory, userEntryClientFile)
- : path.resolve(defaultsDirectory, entryClientFile);
+ ? Path.resolve(reactRouterConfig.appDirectory, userEntryClientFile)
+ : Path.resolve(defaultsDirectory, entryClientFile);
let entryServerFilePath = userEntryServerFile
- ? path.resolve(reactRouterConfig.appDirectory, userEntryServerFile)
- : path.resolve(defaultsDirectory, entryServerFile);
+ ? Path.resolve(reactRouterConfig.appDirectory, userEntryServerFile)
+ : Path.resolve(defaultsDirectory, entryServerFile);
return { entryClientFilePath, entryServerFilePath };
}
+function omitRoutes(
+ config: ResolvedReactRouterConfig
+): ResolvedReactRouterConfig {
+ return {
+ ...config,
+ routes: {},
+ };
+}
+
const entryExts = [".js", ".jsx", ".ts", ".tsx"];
+function isEntryFile(entryBasename: string, filename: string) {
+ return entryExts.some((ext) => filename === `${entryBasename}${ext}`);
+}
+
function findEntry(
dir: string,
basename: string,
- options?: { absolute?: boolean }
+ options?: {
+ absolute?: boolean;
+ extensions?: string[];
+ walkParents?: boolean;
+ }
): string | undefined {
- for (let ext of entryExts) {
- let file = path.resolve(dir, basename + ext);
- if (fs.existsSync(file)) {
- return options?.absolute ?? false ? file : path.relative(dir, file);
+ let currentDir = Path.resolve(dir);
+ let { root } = Path.parse(currentDir);
+
+ while (true) {
+ for (let ext of options?.extensions ?? entryExts) {
+ let file = Path.resolve(currentDir, basename + ext);
+ if (fs.existsSync(file)) {
+ return options?.absolute ?? false ? file : Path.relative(dir, file);
+ }
+ }
+
+ if (!options?.walkParents) {
+ return undefined;
+ }
+
+ let parentDir = Path.dirname(currentDir);
+ // Break out when we've reached the root directory or we're about to get
+ // stuck in a loop where `path.dirname` keeps returning "/"
+ if (currentDir === root || parentDir === currentDir) {
+ return undefined;
+ }
+
+ currentDir = parentDir;
+ }
+}
+
+function isEntryFileDependency(
+ moduleGraph: Vite.ModuleGraph,
+ entryFilepath: string,
+ filepath: string,
+ visited = new Set()
+): boolean {
+ // Ensure normalized paths
+ entryFilepath = Path.normalize(entryFilepath);
+ filepath = Path.normalize(filepath);
+
+ if (visited.has(filepath)) {
+ return false;
+ }
+
+ visited.add(filepath);
+
+ if (filepath === entryFilepath) {
+ return true;
+ }
+
+ let mod = moduleGraph.getModuleById(filepath);
+
+ if (!mod) {
+ return false;
+ }
+
+ // Recursively check all importers to see if any of them are the entry file
+ for (let importer of mod.importers) {
+ if (!importer.id) {
+ continue;
+ }
+
+ if (
+ importer.id === entryFilepath ||
+ isEntryFileDependency(moduleGraph, entryFilepath, importer.id, visited)
+ ) {
+ return true;
}
}
- return undefined;
+ return false;
}
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index 444e31748a..2f868a322a 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.5.3",
+ "version": "7.6.0",
"description": "Dev tools and CLI for React Router",
"homepage": "https://reactrouter.com",
"bugs": {
diff --git a/packages/react-router-dev/typegen/index.ts b/packages/react-router-dev/typegen/index.ts
index 342f27f808..4168aa9bf8 100644
--- a/packages/react-router-dev/typegen/index.ts
+++ b/packages/react-router-dev/typegen/index.ts
@@ -14,8 +14,8 @@ import { getTypesDir, getTypesPath } from "./paths";
import * as Params from "./params";
import * as Route from "./route";
-export async function run(rootDirectory: string) {
- const ctx = await createContext({ rootDirectory, watch: false });
+export async function run(rootDirectory: string, { mode }: { mode: string }) {
+ const ctx = await createContext({ rootDirectory, mode, watch: false });
await writeAll(ctx);
}
@@ -25,27 +25,29 @@ export type Watcher = {
export async function watch(
rootDirectory: string,
- { logger }: { logger?: vite.Logger } = {}
+ { mode, logger }: { mode: string; logger?: vite.Logger }
): Promise {
- const ctx = await createContext({ rootDirectory, watch: true });
+ const ctx = await createContext({ rootDirectory, mode, watch: true });
await writeAll(ctx);
logger?.info(pc.green("generated types"), { timestamp: true, clear: true });
- ctx.configLoader.onChange(async ({ result, routeConfigChanged }) => {
- if (!result.ok) {
- logger?.error(pc.red(result.error), { timestamp: true, clear: true });
- return;
- }
+ ctx.configLoader.onChange(
+ async ({ result, configChanged, routeConfigChanged }) => {
+ if (!result.ok) {
+ logger?.error(pc.red(result.error), { timestamp: true, clear: true });
+ return;
+ }
- ctx.config = result.value;
- if (routeConfigChanged) {
- await writeAll(ctx);
- logger?.info(pc.green("regenerated types"), {
- timestamp: true,
- clear: true,
- });
+ ctx.config = result.value;
+ if (configChanged || routeConfigChanged) {
+ await writeAll(ctx);
+ logger?.info(pc.green("regenerated types"), {
+ timestamp: true,
+ clear: true,
+ });
+ }
}
- });
+ );
return {
close: async () => await ctx.configLoader.close(),
@@ -55,11 +57,13 @@ export async function watch(
async function createContext({
rootDirectory,
watch,
+ mode,
}: {
rootDirectory: string;
watch: boolean;
+ mode: string;
}): Promise {
- const configLoader = await createConfigLoader({ rootDirectory, watch });
+ const configLoader = await createConfigLoader({ rootDirectory, mode, watch });
const configResult = await configLoader.getConfig();
if (!configResult.ok) {
@@ -101,49 +105,45 @@ function register(ctx: Context) {
interface Register {
params: Params;
}
+
+ interface Future {
+ unstable_middleware: ${ctx.config.future.unstable_middleware}
+ }
}
`;
const { t } = Babel;
- const indexPaths = new Set(
- Object.values(ctx.config.routes)
- .filter((route) => route.index)
- .map((route) => route.path)
- );
+ const fullpaths = new Set();
+ Object.values(ctx.config.routes).forEach((route) => {
+ if (route.id !== "root" && !route.path) return;
+ const lineage = Route.lineage(ctx.config.routes, route);
+ const fullpath = Route.fullpath(lineage);
+ fullpaths.add(fullpath);
+ });
const typeParams = t.tsTypeAliasDeclaration(
t.identifier("Params"),
null,
t.tsTypeLiteral(
- Object.values(ctx.config.routes)
- .map((route) => {
- // filter out pathless (layout) routes
- if (route.id !== "root" && !route.path) return undefined;
-
- // filter out layout routes that have a corresponding index
- if (!route.index && indexPaths.has(route.path)) return undefined;
-
- const lineage = Route.lineage(ctx.config.routes, route);
- const fullpath = Route.fullpath(lineage);
- const params = Params.parse(fullpath);
- return t.tsPropertySignature(
- t.stringLiteral(fullpath),
- t.tsTypeAnnotation(
- t.tsTypeLiteral(
- Object.entries(params).map(([param, isRequired]) => {
- const property = t.tsPropertySignature(
- t.stringLiteral(param),
- t.tsTypeAnnotation(t.tsStringKeyword())
- );
- property.optional = !isRequired;
- return property;
- })
- )
+ Array.from(fullpaths).map((fullpath) => {
+ const params = Params.parse(fullpath);
+ return t.tsPropertySignature(
+ t.stringLiteral(fullpath),
+ t.tsTypeAnnotation(
+ t.tsTypeLiteral(
+ Object.entries(params).map(([param, isRequired]) => {
+ const property = t.tsPropertySignature(
+ t.stringLiteral(param),
+ t.tsTypeAnnotation(t.tsStringKeyword())
+ );
+ property.optional = !isRequired;
+ return property;
+ })
)
- );
- })
- .filter((x): x is Babel.Babel.TSPropertySignature => x !== undefined)
+ )
+ );
+ })
)
);
@@ -161,6 +161,7 @@ const virtual = ts`
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
+ export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
diff --git a/packages/react-router-dev/vite/build.ts b/packages/react-router-dev/vite/build.ts
index 31798a76e7..52ba04ece1 100644
--- a/packages/react-router-dev/vite/build.ts
+++ b/packages/react-router-dev/vite/build.ts
@@ -35,7 +35,13 @@ export async function build(root: string, viteBuildOptions: ViteBuildOptions) {
await preloadVite();
let vite = getVite();
- let configResult = await loadConfig({ rootDirectory: root });
+ let configResult = await loadConfig({
+ rootDirectory: root,
+ mode: viteBuildOptions.mode ?? "production",
+ // In this scope we only need future flags, so we can skip evaluating
+ // routes.ts until we're within the Vite build context
+ skipRoutes: true,
+ });
if (!configResult.ok) {
throw new Error(configResult.error);
diff --git a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
index 106fe97c74..9d37690bf1 100644
--- a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
+++ b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts
@@ -57,7 +57,7 @@ export const cloudflareDevProxyVitePlugin = (
return {
name: PLUGIN_NAME,
- config: async (config) => {
+ config: async (config, configEnv) => {
await preloadVite();
const vite = getVite();
// This is a compatibility layer for Vite 5. Default conditions were
@@ -74,6 +74,7 @@ export const cloudflareDevProxyVitePlugin = (
let configResult = await loadConfig({
rootDirectory: config.root ?? process.cwd(),
+ mode: configEnv.mode,
});
if (!configResult.ok) {
diff --git a/packages/react-router-dev/vite/node-adapter.ts b/packages/react-router-dev/vite/node-adapter.ts
index 8803e34ce1..8f2bc29b78 100644
--- a/packages/react-router-dev/vite/node-adapter.ts
+++ b/packages/react-router-dev/vite/node-adapter.ts
@@ -86,7 +86,12 @@ export function fromNodeRequest(
// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185
export async function toNodeRequest(res: Response, nodeRes: ServerResponse) {
nodeRes.statusCode = res.status;
- nodeRes.statusMessage = res.statusText;
+
+ // HTTP/2 doesn't support status messages
+ // https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.4
+ if (!nodeRes.req || nodeRes.req.httpVersionMajor < 2) {
+ nodeRes.statusMessage = res.statusText;
+ }
let cookiesStrings = [];
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index f93d43a4f5..8a290a1922 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -39,7 +39,11 @@ import type { Cache } from "./cache";
import { generate, parse } from "./babel";
import type { NodeRequestHandler } from "./node-adapter";
import { fromNodeRequest, toNodeRequest } from "./node-adapter";
-import { getStylesForPathname, isCssModulesFile } from "./styles";
+import {
+ getCssStringFromViteDevModuleCode,
+ getStylesForPathname,
+ isCssModulesFile,
+} from "./styles";
import * as VirtualModule from "./virtual-module";
import { resolveFileUrl } from "./resolve-file-url";
import { combineURLs } from "./combine-urls";
@@ -136,10 +140,7 @@ exports are only ever used on the server. Without this optimization we can't
tree-shake any unused custom exports because routes are entry points. */
const BUILD_CLIENT_ROUTE_QUERY_STRING = "?__react-router-build-client-route";
-export type EnvironmentName =
- | "client"
- | SsrEnvironmentName
- | CssDevHelperEnvironmentName;
+export type EnvironmentName = "client" | SsrEnvironmentName;
const SSR_BUNDLE_PREFIX = "ssrBundle_";
type SsrBundleEnvironmentName = `${typeof SSR_BUNDLE_PREFIX}${string}`;
@@ -151,16 +152,6 @@ function isSsrBundleEnvironmentName(
return name.startsWith(SSR_BUNDLE_PREFIX);
}
-// We use a separate environment for loading the critical CSS during
-// development. This is because "ssrLoadModule" isn't available if the "ssr"
-// environment has been defined by another plugin (e.g.
-// vite-plugin-cloudflare) as a custom Vite.DevEnvironment rather than a
-// Vite.RunnableDevEnvironment:
-// https://vite.dev/guide/api-environment-frameworks.html#runtime-agnostic-ssr
-const CSS_DEV_HELPER_ENVIRONMENT_NAME =
- "__react_router_css_dev_helper__" as const;
-type CssDevHelperEnvironmentName = typeof CSS_DEV_HELPER_ENVIRONMENT_NAME;
-
type EnvironmentOptions = Pick;
type EnvironmentOptionsResolver = (options: {
@@ -742,6 +733,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
export const ssr = ${ctx.reactRouterConfig.ssr};
export const isSpaMode = ${isSpaMode};
export const prerender = ${JSON.stringify(prerenderPaths)};
+ export const routeDiscovery = ${JSON.stringify(
+ ctx.reactRouterConfig.routeDiscovery
+ )};
export const publicPath = ${JSON.stringify(ctx.publicPath)};
export const entry = { module: entryServer };
export const routes = {
@@ -1118,40 +1112,20 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
return cssModulesManifest[dep.file];
}
- const vite = getVite();
- const viteMajor = parseInt(vite.version.split(".")[0], 10);
-
- const url =
- viteMajor >= 6
- ? // We need the ?inline query in Vite v6 when loading CSS in SSR
- // since it does not expose the default export for CSS in a
- // server environment. This is to align with non-SSR
- // environments. For backwards compatibility with v5 we keep
- // using the URL without ?inline query because the HMR code was
- // relying on the implicit SSR-client module graph relationship.
- injectQuery(dep.url, "inline")
- : dep.url;
-
- let cssMod: unknown;
- if (ctx.reactRouterConfig.future.unstable_viteEnvironmentApi) {
- const cssDevHelperEnvironment =
- viteDevServer.environments[CSS_DEV_HELPER_ENVIRONMENT_NAME];
- invariant(cssDevHelperEnvironment, "Missing CSS dev helper environment");
- invariant(vite.isRunnableDevEnvironment(cssDevHelperEnvironment));
- cssMod = await cssDevHelperEnvironment.runner.import(url);
- } else {
- cssMod = await viteDevServer.ssrLoadModule(url);
- }
-
+ let transformedCssCode = (await viteDevServer.transformRequest(dep.url))
+ ?.code;
invariant(
- typeof cssMod === "object" &&
- cssMod !== null &&
- "default" in cssMod &&
- typeof cssMod.default === "string",
+ transformedCssCode,
`Failed to load CSS for ${dep.file ?? dep.url}`
);
- return cssMod.default;
+ let cssString = getCssStringFromViteDevModuleCode(transformedCssCode);
+ invariant(
+ typeof cssString === "string",
+ `Failed to extract CSS for ${dep.file ?? dep.url}`
+ );
+
+ return cssString;
};
return [
@@ -1187,8 +1161,11 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
rootDirectory =
viteUserConfig.root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd();
+ let mode = viteConfigEnv.mode;
+
if (viteCommand === "serve") {
typegenWatcherPromise = Typegen.watch(rootDirectory, {
+ mode,
// ignore `info` logs from typegen since they are redundant when Vite plugin logs are active
logger: vite.createLogger("warn", { prefix: "[react-router]" }),
});
@@ -1196,6 +1173,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
reactRouterConfigLoader = await createConfigLoader({
rootDirectory,
+ mode,
watch: viteCommand === "serve",
});
@@ -1552,7 +1530,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
reactRouterConfigLoader.onChange(
async ({
result,
- configCodeUpdated,
+ configCodeChanged,
+ routeConfigCodeChanged,
configChanged,
routeConfigChanged,
}) => {
@@ -1565,21 +1544,22 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
return;
}
- if (routeConfigChanged) {
- logger.info(colors.green("Route config changed."), {
- clear: true,
- timestamp: true,
- });
- } else if (configCodeUpdated) {
- logger.info(colors.green("Config updated."), {
- clear: true,
- timestamp: true,
- });
- }
+ // prettier-ignore
+ let message =
+ configChanged ? "Config changed." :
+ routeConfigChanged ? "Route config changed." :
+ configCodeChanged ? "Config saved." :
+ routeConfigCodeChanged ? " Route config saved." :
+ "Config saved";
+
+ logger.info(colors.green(message), {
+ clear: true,
+ timestamp: true,
+ });
await updatePluginContext();
- if (configChanged) {
+ if (configChanged || routeConfigChanged) {
invalidateVirtualModules(viteDevServer);
}
}
@@ -3538,10 +3518,14 @@ export async function getEnvironmentOptionsResolvers(
let routeChunkSuffix = routeChunkName
? `-${kebabCase(routeChunkName)}`
: "";
- return path.posix.join(
+ let assetsDir =
(ctx.reactRouterConfig.future.unstable_viteEnvironmentApi
? viteUserConfig?.environments?.client?.build?.assetsDir
- : viteUserConfig?.build?.assetsDir) ?? "assets",
+ : null) ??
+ viteUserConfig?.build?.assetsDir ??
+ "assets";
+ return path.posix.join(
+ assetsDir,
`[name]${routeChunkSuffix}-[hash].js`
);
},
@@ -3580,13 +3564,6 @@ export async function getEnvironmentOptionsResolvers(
});
}
- if (
- ctx.reactRouterConfig.future.unstable_viteEnvironmentApi &&
- viteCommand === "serve"
- ) {
- environmentOptionsResolvers[CSS_DEV_HELPER_ENVIRONMENT_NAME] = () => ({});
- }
-
return environmentOptionsResolvers;
}
diff --git a/packages/react-router-dev/vite/styles.ts b/packages/react-router-dev/vite/styles.ts
index 6ca3fcb415..76dbfe28f5 100644
--- a/packages/react-router-dev/vite/styles.ts
+++ b/packages/react-router-dev/vite/styles.ts
@@ -6,6 +6,7 @@ import type { ResolvedReactRouterConfig } from "../config/config";
import type { RouteManifest, RouteManifestEntry } from "../config/routes";
import type { LoadCssContents } from "./plugin";
import { resolveFileUrl } from "./resolve-file-url";
+import * as babel from "./babel";
// Style collection logic adapted from solid-start: https://github.com/solidjs/solid-start
@@ -248,3 +249,26 @@ export const getStylesForPathname = async ({
return styles;
};
+
+export const getCssStringFromViteDevModuleCode = (
+ code: string
+): string | undefined => {
+ let cssContent = undefined;
+
+ const ast = babel.parse(code, { sourceType: "module" });
+ babel.traverse(ast, {
+ VariableDeclaration(path) {
+ const declaration = path.node.declarations[0];
+ if (
+ declaration?.id?.type === "Identifier" &&
+ declaration.id.name === "__vite__css" &&
+ declaration.init?.type === "StringLiteral"
+ ) {
+ cssContent = declaration.init.value;
+ path.stop();
+ }
+ },
+ });
+
+ return cssContent;
+};
diff --git a/packages/react-router-dev/vite/vite-node.ts b/packages/react-router-dev/vite/vite-node.ts
index ed2adb872c..62fb96e8f1 100644
--- a/packages/react-router-dev/vite/vite-node.ts
+++ b/packages/react-router-dev/vite/vite-node.ts
@@ -15,9 +15,11 @@ export type Context = {
export async function createContext({
root,
mode,
+ customLogger,
}: {
root: Vite.UserConfig["root"];
mode: Vite.ConfigEnv["mode"];
+ customLogger: Vite.UserConfig["customLogger"];
}): Promise {
await preloadVite();
const vite = getVite();
@@ -25,6 +27,7 @@ export async function createContext({
const devServer = await vite.createServer({
root,
mode,
+ customLogger,
server: {
preTransformRequests: false,
hmr: false,
@@ -36,6 +39,15 @@ export async function createContext({
optimizeDeps: {
noDiscovery: true,
},
+ css: {
+ // This empty PostCSS config object prevents the PostCSS config file from
+ // being loaded. We don't need it in a React Router config context, and
+ // there's also an issue in Vite 5 when using a .ts PostCSS config file in
+ // an ESM project: https://github.com/vitejs/vite/issues/15869. Consumers
+ // can work around this in their own Vite config file, but they can't
+ // configure this internal usage of vite-node.
+ postcss: {},
+ },
configFile: false,
envFile: false,
plugins: [],
diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts
index f4a8c0f8ff..3aabc89bdf 100644
--- a/packages/react-router-dev/vite/with-props.ts
+++ b/packages/react-router-dev/vite/with-props.ts
@@ -17,6 +17,9 @@ export const plugin: Plugin = {
},
async load(id) {
if (id !== vmod.resolvedId) return;
+
+ // Note: If you make changes to these implementations, please also update
+ // the corresponding functions in packages/react-router/lib/dom/ssr/routes-test-stub.tsx
return dedent`
import { createElement as h } from "react";
import { useActionData, useLoaderData, useMatches, useParams, useRouteError } from "react-router";
diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md
index 2d946d2aa7..3956f78efa 100644
--- a/packages/react-router-dom/CHANGELOG.md
+++ b/packages/react-router-dom/CHANGELOG.md
@@ -1,5 +1,12 @@
# react-router-dom
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json
index ccb072d942..6a895d3609 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.5.3",
+ "version": "7.6.0",
"description": "Declarative routing for React web applications",
"keywords": [
"react",
diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md
index 255b5217e4..e2152ec50f 100644
--- a/packages/react-router-express/CHANGELOG.md
+++ b/packages/react-router-express/CHANGELOG.md
@@ -1,5 +1,13 @@
# `@react-router/express`
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+ - `@react-router/node@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json
index 95584c1efe..1e16a11343 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.5.3",
+ "version": "7.6.0",
"description": "Express server request handler for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md
index b0c9a26957..a83919bcfe 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.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@react-router/dev@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json
index 1006fe00b9..090a3e8990 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.5.3",
+ "version": "7.6.0",
"description": "File system routing conventions for React Router, for use within routes.ts",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md
index 735cb8addf..a45ad8ded2 100644
--- a/packages/react-router-node/CHANGELOG.md
+++ b/packages/react-router-node/CHANGELOG.md
@@ -1,5 +1,12 @@
# `@react-router/node`
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json
index 41fc9fbba3..cf8da88288 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.5.3",
+ "version": "7.6.0",
"description": "Node.js platform abstractions for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md
index e222d94829..a5184a450c 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.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@react-router/dev@7.6.0`
+
## 7.5.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 eb46152db8..f927a97c87 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.5.3",
+ "version": "7.6.0",
"description": "Adapter for Remix's \"routes\" config option, for use within routes.ts",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md
index 64a4ef30e1..001f856b2e 100644
--- a/packages/react-router-serve/CHANGELOG.md
+++ b/packages/react-router-serve/CHANGELOG.md
@@ -1,5 +1,14 @@
# `@react-router/serve`
+## 7.6.0
+
+### Patch Changes
+
+- Updated dependencies:
+ - `react-router@7.6.0`
+ - `@react-router/node@7.6.0`
+ - `@react-router/express@7.6.0`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json
index 401a5fb3e9..b99aa29097 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.5.3",
+ "version": "7.6.0",
"description": "Production application server for React Router",
"bugs": {
"url": "https://github.com/remix-run/react-router/issues"
diff --git a/packages/react-router/.eslintrc.js b/packages/react-router/.eslintrc.js
index a634ea0440..6af77a3cad 100644
--- a/packages/react-router/.eslintrc.js
+++ b/packages/react-router/.eslintrc.js
@@ -7,7 +7,6 @@ module.exports = {
},
rules: {
strict: 0,
- "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"],
"no-restricted-globals": [
"error",
{ name: "__dirname", message: restrictedGlobalsError },
diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md
index 939f41a92b..54d89abc9b 100644
--- a/packages/react-router/CHANGELOG.md
+++ b/packages/react-router/CHANGELOG.md
@@ -1,5 +1,75 @@
# `react-router`
+## 7.6.0
+
+### Minor Changes
+
+- Added a new `react-router.config.ts` `routeDiscovery` option to configure Lazy Route Discovery behavior. ([#13451](https://github.com/remix-run/react-router/pull/13451))
+
+ - By default, Lazy Route Discovery is enabled and makes manifest requests to the `/__manifest` path:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }`
+ - You can modify the manifest path used:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" }`
+ - Or you can disable this feature entirely and include all routes in the manifest on initial document load:
+ - `routeDiscovery: { mode: "initial" }`
+
+- Add support for route component props in `createRoutesStub`. This allows you to unit test your route components using the props instead of the hooks: ([#13528](https://github.com/remix-run/react-router/pull/13528))
+
+ ```tsx
+ let RoutesStub = createRoutesStub([
+ {
+ path: "/",
+ Component({ loaderData }) {
+ let data = loaderData as { message: string };
+ return
Message: {data.message}
;
+ },
+ loader() {
+ return { message: "hello" };
+ },
+ },
+ ]);
+
+ render();
+
+ await waitFor(() => screen.findByText("Message: hello"));
+ ```
+
+### Patch Changes
+
+- Fix `react-router` module augmentation for `NodeNext` ([#13498](https://github.com/remix-run/react-router/pull/13498))
+
+- Don't bundle `react-router` in `react-router/dom` CJS export ([#13497](https://github.com/remix-run/react-router/pull/13497))
+
+- Fix bug where a submitting `fetcher` would get stuck in a `loading` state if a revalidating `loader` redirected ([#12873](https://github.com/remix-run/react-router/pull/12873))
+
+- Fix hydration error if a server `loader` returned `undefined` ([#13496](https://github.com/remix-run/react-router/pull/13496))
+
+- Fix initial load 404 scenarios in data mode ([#13500](https://github.com/remix-run/react-router/pull/13500))
+
+- Stabilize `useRevalidator`'s `revalidate` function ([#13542](https://github.com/remix-run/react-router/pull/13542))
+
+- Preserve status code if a `clientAction` throws a `data()` result in framework mode ([#13522](https://github.com/remix-run/react-router/pull/13522))
+
+- Be defensive against leading double slashes in paths to avoid `Invalid URL` errors from the URL constructor ([#13510](https://github.com/remix-run/react-router/pull/13510))
+
+ - Note we do not sanitize/normalize these paths - we only detect them so we can avoid the error that would be thrown by `new URL("//", window.location.origin)`
+
+- Remove `Navigator` declaration for `navigator.connection.saveData` to avoid messing with any other types beyond `saveData` in userland ([#13512](https://github.com/remix-run/react-router/pull/13512))
+
+- Fix `handleError` `params` values on `.data` requests for routes with a dynamic param as the last URL segment ([#13481](https://github.com/remix-run/react-router/pull/13481))
+
+- Don't trigger an `ErrorBoundary` UI before the reload when we detect a manifest verison mismatch in Lazy Route Discovery ([#13480](https://github.com/remix-run/react-router/pull/13480))
+
+- Inline `turbo-stream@2.4.1` dependency and fix decoding ordering of Map/Set instances ([#13518](https://github.com/remix-run/react-router/pull/13518))
+
+- Only render dev warnings in DEV mode ([#13461](https://github.com/remix-run/react-router/pull/13461))
+
+- UNSTABLE: Fix a few bugs with error bubbling in middleware use-cases ([#13538](https://github.com/remix-run/react-router/pull/13538))
+
+- Short circuit post-processing on aborted `dataStrategy` requests ([#13521](https://github.com/remix-run/react-router/pull/13521))
+
+ - This resolves non-user-facing console errors of the form `Cannot read properties of undefined (reading 'result')`
+
## 7.5.3
### Patch Changes
diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx
index abe532a8f2..14c7a85706 100644
--- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx
+++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx
@@ -1,4 +1,3 @@
-import "@testing-library/jest-dom";
import {
act,
fireEvent,
@@ -7862,6 +7861,129 @@ function testDomRouter(
]);
});
});
+
+ if (name === "") {
+ describe("DataBrowserRouter-only tests", () => {
+ it("is defensive against double slash URLs in window.location", async () => {
+ let testWindow = getWindow("http://localhost//");
+ let router = createTestRouter(
+ [
+ {
+ path: "*",
+ Component() {
+ return Go to Page;
+ },
+ },
+ {
+ path: "/page",
+ Component() {
+ return