diff --git a/.github/workflows/integration-pr-windows-macos.yml b/.github/workflows/integration-pr-windows-macos.yml index 780a81f289..a36aef144d 100644 --- a/.github/workflows/integration-pr-windows-macos.yml +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -32,6 +32,7 @@ jobs: os: "windows-latest" node_version: "[22]" browser: '["msedge"]' + timeout: 60 integration-webkit: name: "👀 Integration Test" diff --git a/.github/workflows/release-comments.yml b/.github/workflows/release-comments.yml index 80ca85b274..4ba2af6e93 100644 --- a/.github/workflows/release-comments.yml +++ b/.github/workflows/release-comments.yml @@ -19,3 +19,4 @@ jobs: with: DIRECTORY_TO_CHECK: "./packages" PACKAGE_NAME: "react-router" + ISSUE_LABELS_TO_REMOVE: "awaiting release" diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml index 2438029450..e7adb0dd47 100644 --- a/.github/workflows/shared-integration.yml +++ b/.github/workflows/shared-integration.yml @@ -18,6 +18,10 @@ on: # but we want to pass an array (browser: "['chromium', 'firefox']"), # so we'll need to manually stringify it for now type: string + timeout: + required: false + type: number + default: 30 env: CI: true @@ -62,4 +66,4 @@ jobs: - name: 👀 Run Integration Tests ${{ matrix.browser }} run: "pnpm test:integration --project=${{ matrix.browser }}" - timeout-minutes: 40 + timeout-minutes: ${{inputs.timeout}} diff --git a/CHANGELOG.md b/CHANGELOG.md index 227c73dd57..7e020d67eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,67 +13,75 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v7.5.3](#v753) + - [v7.6.0](#v760) + - [What's Changed](#whats-changed) + - [`routeDiscovery` Config Option](#routediscovery-config-option) + - [Automatic Types for Future Flags](#automatic-types-for-future-flags) + - [Minor Changes](#minor-changes) - [Patch Changes](#patch-changes) + - [Unstable Changes](#unstable-changes) + - [Changes by Package](#changes-by-package) + - [v7.5.3](#v753) + - [Patch Changes](#patch-changes-1) - [v7.5.2](#v752) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-1) - - [v7.5.1](#v751) - [Patch Changes](#patch-changes-2) - - [Unstable Changes](#unstable-changes) - - [v7.5.0](#v750) - - [What's Changed](#whats-changed) - - [`route.lazy` Object API](#routelazy-object-api) - - [Minor Changes](#minor-changes) + - [v7.5.1](#v751) - [Patch Changes](#patch-changes-3) - [Unstable Changes](#unstable-changes-1) - - [Changes by Package](#changes-by-package) - - [v7.4.1](#v741) - - [Security Notice](#security-notice-1) + - [v7.5.0](#v750) + - [What's Changed](#whats-changed-1) + - [`route.lazy` Object API](#routelazy-object-api) + - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-4) - [Unstable Changes](#unstable-changes-2) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-1) + - [Changes by Package](#changes-by-package-1) + - [v7.4.1](#v741) + - [Security Notice](#security-notice-1) - [Patch Changes](#patch-changes-5) - [Unstable Changes](#unstable-changes-3) - - [Changes by Package](#changes-by-package-1) - - [v7.3.0](#v730) + - [v7.4.0](#v740) - [Minor Changes](#minor-changes-2) - [Patch Changes](#patch-changes-6) - [Unstable Changes](#unstable-changes-4) + - [Changes by Package](#changes-by-package-2) + - [v7.3.0](#v730) + - [Minor Changes](#minor-changes-3) + - [Patch Changes](#patch-changes-7) + - [Unstable Changes](#unstable-changes-5) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) - [`unstable_SerializesTo`](#unstable_serializesto) - - [Changes by Package](#changes-by-package-2) + - [Changes by Package](#changes-by-package-3) - [v7.2.0](#v720) - - [What's Changed](#whats-changed-1) + - [What's Changed](#whats-changed-2) - [Type-safe `href` utility](#type-safe-href-utility) - [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback) - [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode) - - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-7) - - [Unstable Changes](#unstable-changes-5) + - [Minor Changes](#minor-changes-4) + - [Patch Changes](#patch-changes-8) + - [Unstable Changes](#unstable-changes-6) - [Split Route Modules (unstable)](#split-route-modules-unstable) - - [Changes by Package](#changes-by-package-3) + - [Changes by Package](#changes-by-package-4) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-8) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-9) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-10) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-11) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-12) - - [v7.1.0](#v710) - - [Minor Changes](#minor-changes-4) + - [v7.1.1](#v711) - [Patch Changes](#patch-changes-13) - - [Changes by Package](#changes-by-package-4) - - [v7.0.2](#v702) + - [v7.1.0](#v710) + - [Minor Changes](#minor-changes-5) - [Patch Changes](#patch-changes-14) - - [v7.0.1](#v701) + - [Changes by Package](#changes-by-package-5) + - [v7.0.2](#v702) - [Patch Changes](#patch-changes-15) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-16) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -89,200 +97,200 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Prerendering](#prerendering) - [Major Changes (`react-router`)](#major-changes-react-router) - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - - [Minor Changes](#minor-changes-5) - - [Patch Changes](#patch-changes-16) - - [Changes by Package](#changes-by-package-5) -- [React Router v6 Releases](#react-router-v6-releases) - - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-6) - [Patch Changes](#patch-changes-17) - - [v6.29.0](#v6290) + - [Changes by Package](#changes-by-package-6) +- [React Router v6 Releases](#react-router-v6-releases) + - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-7) - [Patch Changes](#patch-changes-18) - - [v6.28.2](#v6282) + - [v6.29.0](#v6290) + - [Minor Changes](#minor-changes-8) - [Patch Changes](#patch-changes-19) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-20) - - [v6.28.0](#v6280) - - [What's Changed](#whats-changed-2) - - [Minor Changes](#minor-changes-8) + - [v6.28.1](#v6281) - [Patch Changes](#patch-changes-21) - - [v6.27.0](#v6270) + - [v6.28.0](#v6280) - [What's Changed](#whats-changed-3) - - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-9) - [Patch Changes](#patch-changes-22) - - [v6.26.2](#v6262) + - [v6.27.0](#v6270) + - [What's Changed](#whats-changed-4) + - [Stabilized APIs](#stabilized-apis) + - [Minor Changes](#minor-changes-10) - [Patch Changes](#patch-changes-23) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-24) - - [v6.26.0](#v6260) - - [Minor Changes](#minor-changes-10) + - [v6.26.1](#v6261) - [Patch Changes](#patch-changes-25) - - [v6.25.1](#v6251) + - [v6.26.0](#v6260) + - [Minor Changes](#minor-changes-11) - [Patch Changes](#patch-changes-26) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-27) - [v6.25.0](#v6250) - - [What's Changed](#whats-changed-4) + - [What's Changed](#whats-changed-5) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-27) - - [v6.24.1](#v6241) + - [Minor Changes](#minor-changes-12) - [Patch Changes](#patch-changes-28) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-29) - [v6.24.0](#v6240) - - [What's Changed](#whats-changed-5) + - [What's Changed](#whats-changed-6) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-29) - - [v6.23.1](#v6231) + - [Minor Changes](#minor-changes-13) - [Patch Changes](#patch-changes-30) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-31) - [v6.23.0](#v6230) - - [What's Changed](#whats-changed-6) + - [What's Changed](#whats-changed-7) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - - [Minor Changes](#minor-changes-13) + - [Minor Changes](#minor-changes-14) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-31) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-32) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-33) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-34) - [v6.22.0](#v6220) - - [What's Changed](#whats-changed-7) + - [What's Changed](#whats-changed-8) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-34) - - [v6.21.3](#v6213) + - [Minor Changes](#minor-changes-15) - [Patch Changes](#patch-changes-35) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-36) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-37) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-38) - [v6.21.0](#v6210) - - [What's Changed](#whats-changed-8) + - [What's Changed](#whats-changed-9) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-38) - - [v6.20.1](#v6201) - - [Patch Changes](#patch-changes-39) - - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-16) + - [Patch Changes](#patch-changes-39) + - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-40) - - [v6.19.0](#v6190) - - [What's Changed](#whats-changed-9) - - [`unstable_flushSync` API](#unstable_flushsync-api) + - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-17) - [Patch Changes](#patch-changes-41) - - [v6.18.0](#v6180) + - [v6.19.0](#v6190) - [What's Changed](#whats-changed-10) - - [New Fetcher APIs](#new-fetcher-apis) - - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) + - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-18) - [Patch Changes](#patch-changes-42) - - [v6.17.0](#v6170) + - [v6.18.0](#v6180) - [What's Changed](#whats-changed-11) - - [View Transitions 🚀](#view-transitions-) + - [New Fetcher APIs](#new-fetcher-apis) + - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-19) - [Patch Changes](#patch-changes-43) - - [v6.16.0](#v6160) + - [v6.17.0](#v6170) + - [What's Changed](#whats-changed-12) + - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-20) - [Patch Changes](#patch-changes-44) - - [v6.15.0](#v6150) + - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-21) - [Patch Changes](#patch-changes-45) - - [v6.14.2](#v6142) + - [v6.15.0](#v6150) + - [Minor Changes](#minor-changes-22) - [Patch Changes](#patch-changes-46) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-47) - - [v6.14.0](#v6140) - - [What's Changed](#whats-changed-12) - - [JSON/Text Submissions](#jsontext-submissions) - - [Minor Changes](#minor-changes-22) + - [v6.14.1](#v6141) - [Patch Changes](#patch-changes-48) - - [v6.13.0](#v6130) + - [v6.14.0](#v6140) - [What's Changed](#whats-changed-13) - - [`future.v7_startTransition`](#futurev7_starttransition) + - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-23) - [Patch Changes](#patch-changes-49) - - [v6.12.1](#v6121) - - [Patch Changes](#patch-changes-50) - - [v6.12.0](#v6120) + - [v6.13.0](#v6130) - [What's Changed](#whats-changed-14) - - [`React.startTransition` support](#reactstarttransition-support) + - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-24) + - [Patch Changes](#patch-changes-50) + - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-51) - - [v6.11.2](#v6112) + - [v6.12.0](#v6120) + - [What's Changed](#whats-changed-15) + - [`React.startTransition` support](#reactstarttransition-support) + - [Minor Changes](#minor-changes-25) - [Patch Changes](#patch-changes-52) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-53) - - [v6.11.0](#v6110) - - [Minor Changes](#minor-changes-25) + - [v6.11.1](#v6111) - [Patch Changes](#patch-changes-54) - - [v6.10.0](#v6100) - - [What's Changed](#whats-changed-15) + - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-26) - - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - [Patch Changes](#patch-changes-55) - - [v6.9.0](#v690) + - [v6.10.0](#v6100) - [What's Changed](#whats-changed-16) - - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-27) + - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - [Patch Changes](#patch-changes-56) - - [v6.8.2](#v682) + - [v6.9.0](#v690) + - [What's Changed](#whats-changed-17) + - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) + - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) + - [Minor Changes](#minor-changes-28) - [Patch Changes](#patch-changes-57) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-58) - - [v6.8.0](#v680) - - [Minor Changes](#minor-changes-28) + - [v6.8.1](#v681) - [Patch Changes](#patch-changes-59) - - [v6.7.0](#v670) + - [v6.8.0](#v680) - [Minor Changes](#minor-changes-29) - [Patch Changes](#patch-changes-60) - - [v6.6.2](#v662) + - [v6.7.0](#v670) + - [Minor Changes](#minor-changes-30) - [Patch Changes](#patch-changes-61) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-62) - - [v6.6.0](#v660) - - [What's Changed](#whats-changed-17) - - [Minor Changes](#minor-changes-30) + - [v6.6.1](#v661) - [Patch Changes](#patch-changes-63) - - [v6.5.0](#v650) + - [v6.6.0](#v660) - [What's Changed](#whats-changed-18) - [Minor Changes](#minor-changes-31) - [Patch Changes](#patch-changes-64) - - [v6.4.5](#v645) + - [v6.5.0](#v650) + - [What's Changed](#whats-changed-19) + - [Minor Changes](#minor-changes-32) - [Patch Changes](#patch-changes-65) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-66) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-67) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-68) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-69) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-70) - [v6.4.0](#v640) - - [What's Changed](#whats-changed-19) + - [What's Changed](#whats-changed-20) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-70) + - [Patch Changes](#patch-changes-71) - [v6.3.0](#v630) - - [Minor Changes](#minor-changes-32) + - [Minor Changes](#minor-changes-33) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-71) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-72) - - [v6.2.0](#v620) - - [Minor Changes](#minor-changes-33) + - [v6.2.1](#v621) - [Patch Changes](#patch-changes-73) - - [v6.1.1](#v611) - - [Patch Changes](#patch-changes-74) - - [v6.1.0](#v610) + - [v6.2.0](#v620) - [Minor Changes](#minor-changes-34) + - [Patch Changes](#patch-changes-74) + - [v6.1.1](#v611) - [Patch Changes](#patch-changes-75) - - [v6.0.2](#v602) + - [v6.1.0](#v610) + - [Minor Changes](#minor-changes-35) - [Patch Changes](#patch-changes-76) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-77) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-78) - [v6.0.0](#v600) @@ -324,6 +332,157 @@ Date: YYYY-MM-DD **Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y) --> +## v7.6.0 + +Date: 2025-05-08 + +### What's Changed + +#### `routeDiscovery` Config Option + +We've added a new config option in `7.6.0` which grants you more control over the Lazy Route Discovery feature. You can now configure the `/__manifest` path if you're running multiple RR applications on the same server, or you can also disable the feature entirely if your application is small enough and the feature isn't necessary. + +```ts +// react-router.config.ts + +export default { + // 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" } + + // If you don't specify anything, the default config is as follows, which enables + // Lazy Route Discovery and makes manifest requests to the `/__manifest` path: + // routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" } +} satisfies Config; +``` + +#### Automatic Types for Future Flags + +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 `future.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 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. + +### Minor Changes + +- `react-router` - Added a new `routeDiscovery` option in `react-router.config.ts` to configure Lazy Route Discovery behavior ([#13451](https://github.com/remix-run/react-router/pull/13451)) +- `react-router` - Add support for route component props in `createRoutesStub` ([#13528](https://github.com/remix-run/react-router/pull/13528)) + + - This allows you to unit test your route components using the props instead of the hooks: + + ```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")); + ``` + +- `@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 ( +
{ + e.preventDefault(); + setValue(""); + if (blocker.state === "blocked") { + blocker.proceed(); + } + }} + > + setValue(e.target.value)} + /> + + + + {blocker.state === "blocked" ? ( + <> +

+ Blocked the last navigation to +

+ + + + ) : blocker.state === "proceeding" ? ( +

+ Proceeding through blocked navigation +

+ ) : ( +

+ Blocker is currently unblocked +

+ )} +
+ ); +} +``` 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 ( +

+ Post: {params.postId}, Comment: {params.commentId} +

+ ); +} ``` +### 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(`

A: A LOADER

`); + // Wait for eager discovery to kick off + await new Promise((r) => setTimeout(r, 500)); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/custom-manifest\?p=%2Fa%2Fb&version=/), + ]); + + expect(wrongManifestRequests).toEqual([]); + }); + + test.describe("routeDiscovery=initial", () => { + test("loads full manifest on initial load", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "react-router.config.ts": reactRouterConfig({ + routeDiscovery: { mode: "initial" }, + }), + "app/entry.client.tsx": js` + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + }, + }); + let appFixture = await createAppFixture(fixture); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + let app = new PlaywrightFixture(appFixture, page); + let res = await fixture.requestDocument("/"); + let html = await res.text(); + + expect(html).not.toContain("window.__reactRouterManifest = {"); + expect(html).toContain( + ' + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual([ + "root", + "routes/_index", + "routes/a", + "routes/a.b", + "routes/a.b.c", + ]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`

A: A LOADER

`); + expect(manifestRequests).toEqual([]); + }); + + test("defaults to `routeDiscovery=initial` when `ssr:false` is set", async ({ + page, + }) => { + let fixture = await createFixture({ + spaMode: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + }), + "app/root.tsx": js` + import * as React from "react"; + import { Link, Links, Meta, Outlet, Scripts } from "react-router"; + export default function Root() { + let [showLink, setShowLink] = React.useState(false); + return ( + + + + + + + Home
+ /a
+ + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + + "app/routes/a.tsx": js` + export function clientLoader({ request }) { + return { message: "A LOADER" }; + } + export default function Index({ loaderData }) { + return

A: {loaderData.message}

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + let app = new PlaywrightFixture(appFixture, page); + let res = await fixture.requestDocument("/"); + let html = await res.text(); + + expect(html).toContain('"routeDiscovery":{"mode":"initial"}'); + + await app.goto("/", true); + await page.waitForSelector("#index"); + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`

A: A LOADER

`); + expect(manifestRequests).toEqual([]); + }); + + test("Errors if you try to set routeDiscovery=lazy and ssr:false", async () => { + let ogConsole = console.error; + console.error = () => {}; + let buildStdio = new PassThrough(); + let err; + try { + await createFixture({ + buildStdio, + spaMode: true, + files: { + ...getFiles(), + "react-router.config.ts": reactRouterConfig({ + ssr: false, + routeDiscovery: { mode: "lazy" }, + }), + }, + }); + } catch (e) { + err = e; + } + + let chunks: Buffer[] = []; + let buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + + expect(err).toEqual(new Error("Build failed, check the output above")); + expect(buildOutput).toContain( + 'Error: The `routeDiscovery.mode` config cannot be set to "lazy" when setting `ssr:false`' + ); + console.error = ogConsole; + }); + }); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index bd482ba731..3f0442070b 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -303,7 +303,13 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { if (file.endsWith(".html") && !fse.existsSync(path.join(dir, file))) { file = "__spa-fallback.html"; } - res.sendFile(path.join(dir, file), next); + let filePath = path.join(dir, file); + if (fse.existsSync(filePath)) { + res.sendFile(filePath, next); + } else { + // Avoid a built-in console error from `sendFile` on 404's + res.status(404).send("Not found"); + } }); let server = app.listen(port); accept({ stop: server.close.bind(server), port }); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 305235592f..7c78a9211b 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -31,6 +31,7 @@ export const reactRouterConfig = ({ splitRouteModules, viteEnvironmentApi, middleware, + routeDiscovery, }: { ssr?: boolean; basename?: string; @@ -41,12 +42,14 @@ export const reactRouterConfig = ({ >["unstable_splitRouteModules"]; viteEnvironmentApi?: boolean; middleware?: boolean; + routeDiscovery?: Config["routeDiscovery"]; }) => { let config: Config = { ssr, basename, prerender, appDirectory, + routeDiscovery, future: { unstable_splitRouteModules: splitRouteModules, unstable_viteEnvironmentApi: viteEnvironmentApi, diff --git a/integration/middleware-test.ts b/integration/middleware-test.ts index d433ea0ac5..b59b01a114 100644 --- a/integration/middleware-test.ts +++ b/integration/middleware-test.ts @@ -2140,7 +2140,7 @@ test.describe("Middleware", () => { "app/routes/_index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link + return Link } `, "app/routes/a.tsx": js` @@ -2158,7 +2158,7 @@ test.describe("Middleware", () => { return null; } export default function Component() { - return + return } `, "app/routes/a.b.c.tsx": js` @@ -2192,6 +2192,109 @@ test.describe("Middleware", () => { expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); expect(await page.locator("pre").textContent()).toBe("broken!"); + await app.goto("/"); + await app.clickLink("/a/b/c/d"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + + appFixture.close(); + }); + + test("bubbles errors on the way down up to the deepest error boundary when loaders aren't revalidating", async ({ + page, + }) => { + let fixture = await createFixture( + { + files: { + "react-router.config.ts": reactRouterConfig({ + middleware: true, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true, minify: false }, + plugins: [reactRouter()], + }); + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return ( + <> + /a/b +
+ /a/b/c/d + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet } from 'react-router' + export default function Component() { + return + } + export function ErrorBoundary({ error }) { + return <>

A Error Boundary

{error.message}
+ } + `, + "app/routes/a.b.tsx": js` + import { Link, Outlet } from 'react-router' + export function loader() { + return { message: "DATA" }; + } + export default function Component({ loaderData }) { + return ( + <> +

AB: {loaderData.message}

+ /a/b/c/d + + + ); + } + export function shouldRevalidate() { + return false; + } + `, + "app/routes/a.b.c.tsx": js` + import { Outlet } from 'react-router' + export default function Component() { + return + } + export function ErrorBoundary({ error }) { + return <>

C Error Boundary

{error.message}
+ } + `, + "app/routes/a.b.c.d.tsx": js` + import { Outlet } from 'react-router' + export const unstable_middleware = [() => { throw new Error("broken!") }] + export const loader = () => null; + export default function Component() { + return + } + `, + }, + }, + UNSAFE_ServerMode.Development + ); + + let appFixture = await createAppFixture( + fixture, + UNSAFE_ServerMode.Development + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b"); + await page.waitForSelector("[data-ab]"); + expect(await page.locator("[data-ab]").textContent()).toBe("AB: DATA"); + + await app.clickLink("/a/b/c/d"); + await page.waitForSelector("[data-error-c]"); + expect(await page.locator("h1").textContent()).toBe("C Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + appFixture.close(); }); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index c7c25db4ef..f29a1013d0 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -316,6 +316,24 @@ test.describe("single-fetch", () => { expect(await app.getHtml("#date")).toContain(ISO_DATE); }); + test("allows SSR loaders to return undefined", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + export function loader() {} + export default function Index() { + return

Index

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + expect(await app.getHtml("h1")).toContain("Index"); + }); + test("loads proper data on client side navigation", async ({ page }) => { let fixture = await createFixture({ files, @@ -3245,6 +3263,63 @@ test.describe("single-fetch", () => { expect(urls[0].endsWith("/fetch.data?_routes=routes%2Ffetch")).toBe(true); expect(urls[1].endsWith("/parent/b.data")).toBe(true); }); + + test("Aborted fetcher loads don't cause console errors", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + import { Form, redirect, useFetcher } from "react-router"; + + export function action() { + return redirect("/other"); + } + + export default function Page() { + const fetcher = useFetcher(); + const isPending = fetcher.state !== "idle"; + + return ( + <> + +
+ +
+ + ); + } + `, + "app/routes/other.tsx": js` + export default function Component() { + return

Other

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

Worked!

; + }, + }, + ], + { + window: testWindow, + } + ); + render(); + expect(testWindow.location.pathname).toBe("//"); + expect(router.state.location.pathname).toBe("//"); + + fireEvent.click(screen.getByText("Go to Page")); + await waitFor(() => screen.getByText("Worked!")); + expect(testWindow.location.pathname).toBe("/page"); + expect(router.state.location.pathname).toBe("/page"); + }); + }); + + it("handles different-origin absolute redirect URLs", async () => { + let testWindow = getWindow("http://localhost/"); + + // jsdom is making more and more properties non-configurable, so we inject + // our own jest-friendly window + testWindow = { + ...testWindow, + addEventListener: testWindow.addEventListener.bind(testWindow), + location: { + ...testWindow.location, + assign: jest.fn(), + replace: jest.fn(), + }, + }; + + let router = createTestRouter( + [ + { + path: "/", + Component() { + return Go to Page; + }, + }, + { + path: "/page", + loader() { + return redirect("http://otherhost/parent"); + }, + Component() { + return null; + }, + }, + ], + { + window: testWindow, + } + ); + + await router.navigate("/page"); + expect(testWindow.location.assign).toHaveBeenCalledWith( + "http://otherhost/parent" + ); + }); + + it("handles different-origin protocol-less absolute redirect URLs", async () => { + let testWindow = getWindow("http://localhost/"); + + // jsdom is making more and more properties non-configurable, so we inject + // our own jest-friendly window + testWindow = { + ...testWindow, + addEventListener: testWindow.addEventListener.bind(testWindow), + location: { + ...testWindow.location, + assign: jest.fn(), + replace: jest.fn(), + }, + }; + + let router = createTestRouter( + [ + { + path: "/", + Component() { + return Go to Page; + }, + }, + { + path: "/page", + loader() { + return redirect("//otherhost/parent"); + }, + Component() { + return null; + }, + }, + ], + { + window: testWindow, + } + ); + + await router.navigate("/page"); + expect(testWindow.location.assign).toHaveBeenCalledWith( + "//otherhost/parent" + ); + }); + } }); } diff --git a/packages/react-router/__tests__/dom/dom-export-test.tsx b/packages/react-router/__tests__/dom/dom-export-test.tsx new file mode 100644 index 0000000000..2ecd4172b0 --- /dev/null +++ b/packages/react-router/__tests__/dom/dom-export-test.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +import { render, screen } from "@testing-library/react"; +import { createMemoryRouter, useParams } from "react-router"; +import { RouterProvider } from "react-router/dom"; + +describe("react-router/dom", () => { + function ShowParams() { + return
{JSON.stringify(useParams())}
; + } + + describe("Does not bundle react-router causing duplicate context issues", () => { + it("with route provider shows the url params", async () => { + const router = createMemoryRouter( + [ + { + path: "/blog/:slug", + element: , + }, + ], + { + initialEntries: ["/blog/react-router"], + } + ); + + // When react-router was bundled in CJS scenarios, this `react-router/dom` + // version of `RouterProvider` caused duplicate contexts and we would not + // find the param values + render(); + + expect(await screen.findByTestId("params")).toMatchInlineSnapshot(` +
+        {"slug":"react-router"}
+      
+ `); + }); + }); +}); diff --git a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx index 8576f535e1..9686a49b25 100644 --- a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx +++ b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx @@ -11,10 +11,10 @@ import { ScrollRestoration, createBrowserRouter, } from "../../index"; -import type { FrameworkContextObject } from "../../lib/dom/ssr/entry"; import { createMemoryRouter, redirect } from "react-router"; import { FrameworkContext, Scripts } from "../../lib/dom/ssr/components"; import "@testing-library/jest-dom/extend-expect"; +import { mockFrameworkContext } from "../utils/framework"; describe(`ScrollRestoration`, () => { it("restores the scroll position for a page when re-visited", () => { @@ -207,23 +207,7 @@ describe(`ScrollRestoration`, () => { window.scrollTo = scrollTo; }); - let context: FrameworkContextObject = { - routeModules: { root: { default: () => null } }, - manifest: { - routes: { - root: { - hasLoader: false, - hasAction: false, - hasErrorBoundary: false, - id: "root", - module: "root.js", - }, - }, - entry: { imports: [], module: "" }, - url: "", - version: "", - }, - }; + let context = mockFrameworkContext(); it("should render a