diff --git a/.github/workflows/close-no-repro-issues.yml b/.github/workflows/close-no-repro-issues.yml index f9a6e2e45f..efc77ff396 100644 --- a/.github/workflows/close-no-repro-issues.yml +++ b/.github/workflows/close-no-repro-issues.yml @@ -31,7 +31,7 @@ jobs: uses: pnpm/action-setup@v4.1.0 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: # required for --experimental-strip-types node-version: 22 diff --git a/.github/workflows/deduplicate-lock-file.yml b/.github/workflows/deduplicate-lock-file.yml index 51d6c758b4..3174fcfcbb 100644 --- a/.github/workflows/deduplicate-lock-file.yml +++ b/.github/workflows/deduplicate-lock-file.yml @@ -26,7 +26,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 86ced6e59e..1e6391dfd8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: pnpm diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 459c848d12..ff63cd21ee 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -25,7 +25,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: pnpm diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index 1b4624d7f4..373afa10ba 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -29,7 +29,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index b394521418..be5ec510e8 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -40,7 +40,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/release-stage-2-alpha.yml b/.github/workflows/release-stage-2-alpha.yml index 40157097f3..c82f906205 100644 --- a/.github/workflows/release-stage-2-alpha.yml +++ b/.github/workflows/release-stage-2-alpha.yml @@ -35,7 +35,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 366c8dcc9f..b88bb74292 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" @@ -84,7 +84,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/shared-build.yml b/.github/workflows/shared-build.yml index 345ec9c300..4a3617aa6c 100644 --- a/.github/workflows/shared-build.yml +++ b/.github/workflows/shared-build.yml @@ -17,7 +17,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml index 0454fc2cca..782507c3ac 100644 --- a/.github/workflows/shared-integration.yml +++ b/.github/workflows/shared-integration.yml @@ -44,7 +44,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node ${{ matrix.node }} - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: "pnpm" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d510f93a25..2bf0dedbe9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: uses: pnpm/action-setup@v4 - name: ⎔ Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: pnpm diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cfee7fa8..262c5fb008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,127 +13,132 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v7.9.4](#v794) + - [v7.9.5](#v795) - [What's Changed](#whats-changed) - - [`useRoute()` (unstable)](#useroute-unstable) + - [Instrumentation (unstable)](#instrumentation-unstable) - [Patch Changes](#patch-changes) - [Unstable Changes](#unstable-changes) - - [v7.9.3](#v793) + - [v7.9.4](#v794) + - [What's Changed](#whats-changed-1) + - [`useRoute()` (unstable)](#useroute-unstable) - [Patch Changes](#patch-changes-1) + - [Unstable Changes](#unstable-changes-1) + - [v7.9.3](#v793) + - [Patch Changes](#patch-changes-2) - [v7.9.2](#v792) - - [What's Changed](#whats-changed-1) + - [What's Changed](#whats-changed-2) - [RSC Framework Mode (unstable)](#rsc-framework-mode-unstable) - [Fetcher Reset (unstable)](#fetcher-reset-unstable) - - [Patch Changes](#patch-changes-2) - - [Unstable Changes](#unstable-changes-1) - - [v7.9.1](#v791) - [Patch Changes](#patch-changes-3) + - [Unstable Changes](#unstable-changes-2) + - [v7.9.1](#v791) + - [Patch Changes](#patch-changes-4) - [v7.9.0](#v790) - - [What's Changed](#whats-changed-2) + - [What's Changed](#whats-changed-3) - [Stable Middleware and Context APIs](#stable-middleware-and-context-apis) - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes-4) - - [Unstable Changes](#unstable-changes-2) - - [v7.8.2](#v782) - [Patch Changes](#patch-changes-5) - [Unstable Changes](#unstable-changes-3) - - [v7.8.1](#v781) + - [v7.8.2](#v782) - [Patch Changes](#patch-changes-6) - [Unstable Changes](#unstable-changes-4) + - [v7.8.1](#v781) + - [Patch Changes](#patch-changes-7) + - [Unstable Changes](#unstable-changes-5) - [v7.8.0](#v780) - - [What's Changed](#whats-changed-3) + - [What's Changed](#whats-changed-4) - [Consistently named `loaderData` values](#consistently-named-loaderdata-values) - [Improvements/fixes to the middleware APIs (unstable)](#improvementsfixes-to-the-middleware-apis-unstable) - [Minor Changes](#minor-changes-1) - - [Patch Changes](#patch-changes-7) - - [Unstable Changes](#unstable-changes-5) - - [Changes by Package](#changes-by-package) - - [v7.7.1](#v771) - [Patch Changes](#patch-changes-8) - [Unstable Changes](#unstable-changes-6) + - [Changes by Package](#changes-by-package) + - [v7.7.1](#v771) + - [Patch Changes](#patch-changes-9) + - [Unstable Changes](#unstable-changes-7) - [v7.7.0](#v770) - - [What's Changed](#whats-changed-4) + - [What's Changed](#whats-changed-5) - [Unstable RSC APIs](#unstable-rsc-apis) - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-9) - - [Unstable Changes](#unstable-changes-7) + - [Patch Changes](#patch-changes-10) + - [Unstable Changes](#unstable-changes-8) - [Changes by Package](#changes-by-package-1) - [v7.6.3](#v763) - - [Patch Changes](#patch-changes-10) - - [v7.6.2](#v762) - [Patch Changes](#patch-changes-11) - - [v7.6.1](#v761) + - [v7.6.2](#v762) - [Patch Changes](#patch-changes-12) - - [Unstable Changes](#unstable-changes-8) + - [v7.6.1](#v761) + - [Patch Changes](#patch-changes-13) + - [Unstable Changes](#unstable-changes-9) - [v7.6.0](#v760) - - [What's Changed](#whats-changed-5) + - [What's Changed](#whats-changed-6) - [`routeDiscovery` Config Option](#routediscovery-config-option) - [Automatic Types for Future Flags](#automatic-types-for-future-flags) - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-13) - - [Unstable Changes](#unstable-changes-9) + - [Patch Changes](#patch-changes-14) + - [Unstable Changes](#unstable-changes-10) - [Changes by Package](#changes-by-package-2) - [v7.5.3](#v753) - - [Patch Changes](#patch-changes-14) + - [Patch Changes](#patch-changes-15) - [v7.5.2](#v752) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-15) - - [v7.5.1](#v751) - [Patch Changes](#patch-changes-16) - - [Unstable Changes](#unstable-changes-10) + - [v7.5.1](#v751) + - [Patch Changes](#patch-changes-17) + - [Unstable Changes](#unstable-changes-11) - [v7.5.0](#v750) - - [What's Changed](#whats-changed-6) + - [What's Changed](#whats-changed-7) - [`route.lazy` Object API](#routelazy-object-api) - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-17) - - [Unstable Changes](#unstable-changes-11) + - [Patch Changes](#patch-changes-18) + - [Unstable Changes](#unstable-changes-12) - [Changes by Package](#changes-by-package-3) - [v7.4.1](#v741) - [Security Notice](#security-notice-1) - - [Patch Changes](#patch-changes-18) - - [Unstable Changes](#unstable-changes-12) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-5) - [Patch Changes](#patch-changes-19) - [Unstable Changes](#unstable-changes-13) + - [v7.4.0](#v740) + - [Minor Changes](#minor-changes-5) + - [Patch Changes](#patch-changes-20) + - [Unstable Changes](#unstable-changes-14) - [Changes by Package](#changes-by-package-4) - [v7.3.0](#v730) - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-20) - - [Unstable Changes](#unstable-changes-14) + - [Patch Changes](#patch-changes-21) + - [Unstable Changes](#unstable-changes-15) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) - [`unstable_SerializesTo`](#unstable_serializesto) - [Changes by Package](#changes-by-package-5) - [v7.2.0](#v720) - - [What's Changed](#whats-changed-7) + - [What's Changed](#whats-changed-8) - [Type-safe `href` utility](#type-safe-href-utility) - [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback) - [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode) - [Minor Changes](#minor-changes-7) - - [Patch Changes](#patch-changes-21) - - [Unstable Changes](#unstable-changes-15) + - [Patch Changes](#patch-changes-22) + - [Unstable Changes](#unstable-changes-16) - [Split Route Modules (unstable)](#split-route-modules-unstable) - [Changes by Package](#changes-by-package-6) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-22) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-23) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-24) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-25) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-26) + - [v7.1.1](#v711) + - [Patch Changes](#patch-changes-27) - [v7.1.0](#v710) - [Minor Changes](#minor-changes-8) - - [Patch Changes](#patch-changes-27) + - [Patch Changes](#patch-changes-28) - [Changes by Package](#changes-by-package-7) - [v7.0.2](#v702) - - [Patch Changes](#patch-changes-28) - - [v7.0.1](#v701) - [Patch Changes](#patch-changes-29) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-30) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -150,201 +155,201 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Major Changes (`react-router`)](#major-changes-react-router) - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - [Minor Changes](#minor-changes-9) - - [Patch Changes](#patch-changes-30) + - [Patch Changes](#patch-changes-31) - [Changes by Package](#changes-by-package-8) - [React Router v6 Releases](#react-router-v6-releases) - [v6.30.1](#v6301) - - [Patch Changes](#patch-changes-31) + - [Patch Changes](#patch-changes-32) - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-32) + - [Patch Changes](#patch-changes-33) - [v6.29.0](#v6290) - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-33) - - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-34) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-35) + - [v6.28.1](#v6281) + - [Patch Changes](#patch-changes-36) - [v6.28.0](#v6280) - - [What's Changed](#whats-changed-8) + - [What's Changed](#whats-changed-9) - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-36) + - [Patch Changes](#patch-changes-37) - [v6.27.0](#v6270) - - [What's Changed](#whats-changed-9) + - [What's Changed](#whats-changed-10) - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-13) - - [Patch Changes](#patch-changes-37) - - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-38) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-39) + - [v6.26.1](#v6261) + - [Patch Changes](#patch-changes-40) - [v6.26.0](#v6260) - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-40) - - [v6.25.1](#v6251) - [Patch Changes](#patch-changes-41) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-42) - [v6.25.0](#v6250) - - [What's Changed](#whats-changed-10) + - [What's Changed](#whats-changed-11) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-42) - - [v6.24.1](#v6241) - [Patch Changes](#patch-changes-43) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-44) - [v6.24.0](#v6240) - - [What's Changed](#whats-changed-11) + - [What's Changed](#whats-changed-12) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-44) - - [v6.23.1](#v6231) - [Patch Changes](#patch-changes-45) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-46) - [v6.23.0](#v6230) - - [What's Changed](#whats-changed-12) + - [What's Changed](#whats-changed-13) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - [Minor Changes](#minor-changes-17) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-46) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-47) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-48) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-49) - [v6.22.0](#v6220) - - [What's Changed](#whats-changed-13) + - [What's Changed](#whats-changed-14) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - [Minor Changes](#minor-changes-18) - - [Patch Changes](#patch-changes-49) - - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-50) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-51) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-52) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-53) - [v6.21.0](#v6210) - - [What's Changed](#whats-changed-14) + - [What's Changed](#whats-changed-15) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-53) - - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-54) + - [v6.20.1](#v6201) + - [Patch Changes](#patch-changes-55) - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-20) - - [Patch Changes](#patch-changes-55) + - [Patch Changes](#patch-changes-56) - [v6.19.0](#v6190) - - [What's Changed](#whats-changed-15) + - [What's Changed](#whats-changed-16) - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-21) - - [Patch Changes](#patch-changes-56) + - [Patch Changes](#patch-changes-57) - [v6.18.0](#v6180) - - [What's Changed](#whats-changed-16) + - [What's Changed](#whats-changed-17) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-22) - - [Patch Changes](#patch-changes-57) + - [Patch Changes](#patch-changes-58) - [v6.17.0](#v6170) - - [What's Changed](#whats-changed-17) + - [What's Changed](#whats-changed-18) - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-58) + - [Patch Changes](#patch-changes-59) - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-24) - - [Patch Changes](#patch-changes-59) + - [Patch Changes](#patch-changes-60) - [v6.15.0](#v6150) - [Minor Changes](#minor-changes-25) - - [Patch Changes](#patch-changes-60) - - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-61) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-62) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-63) - [v6.14.0](#v6140) - - [What's Changed](#whats-changed-18) + - [What's Changed](#whats-changed-19) - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-26) - - [Patch Changes](#patch-changes-63) + - [Patch Changes](#patch-changes-64) - [v6.13.0](#v6130) - - [What's Changed](#whats-changed-19) + - [What's Changed](#whats-changed-20) - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-27) - - [Patch Changes](#patch-changes-64) - - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-65) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-66) - [v6.12.0](#v6120) - - [What's Changed](#whats-changed-20) + - [What's Changed](#whats-changed-21) - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-28) - - [Patch Changes](#patch-changes-66) - - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-67) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-68) + - [v6.11.1](#v6111) + - [Patch Changes](#patch-changes-69) - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-29) - - [Patch Changes](#patch-changes-69) + - [Patch Changes](#patch-changes-70) - [v6.10.0](#v6100) - - [What's Changed](#whats-changed-21) + - [What's Changed](#whats-changed-22) - [Minor Changes](#minor-changes-30) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-70) + - [Patch Changes](#patch-changes-71) - [v6.9.0](#v690) - - [What's Changed](#whats-changed-22) + - [What's Changed](#whats-changed-23) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-31) - - [Patch Changes](#patch-changes-71) - - [v6.8.2](#v682) - [Patch Changes](#patch-changes-72) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-73) + - [v6.8.1](#v681) + - [Patch Changes](#patch-changes-74) - [v6.8.0](#v680) - [Minor Changes](#minor-changes-32) - - [Patch Changes](#patch-changes-74) + - [Patch Changes](#patch-changes-75) - [v6.7.0](#v670) - [Minor Changes](#minor-changes-33) - - [Patch Changes](#patch-changes-75) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-76) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-77) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-78) - [v6.6.0](#v660) - - [What's Changed](#whats-changed-23) + - [What's Changed](#whats-changed-24) - [Minor Changes](#minor-changes-34) - - [Patch Changes](#patch-changes-78) + - [Patch Changes](#patch-changes-79) - [v6.5.0](#v650) - - [What's Changed](#whats-changed-24) + - [What's Changed](#whats-changed-25) - [Minor Changes](#minor-changes-35) - - [Patch Changes](#patch-changes-79) - - [v6.4.5](#v645) - [Patch Changes](#patch-changes-80) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-81) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-82) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-83) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-84) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-85) - [v6.4.0](#v640) - - [What's Changed](#whats-changed-25) + - [What's Changed](#whats-changed-26) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-85) + - [Patch Changes](#patch-changes-86) - [v6.3.0](#v630) - [Minor Changes](#minor-changes-36) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-86) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-87) + - [v6.2.1](#v621) + - [Patch Changes](#patch-changes-88) - [v6.2.0](#v620) - [Minor Changes](#minor-changes-37) - - [Patch Changes](#patch-changes-88) - - [v6.1.1](#v611) - [Patch Changes](#patch-changes-89) + - [v6.1.1](#v611) + - [Patch Changes](#patch-changes-90) - [v6.1.0](#v610) - [Minor Changes](#minor-changes-38) - - [Patch Changes](#patch-changes-90) - - [v6.0.2](#v602) - [Patch Changes](#patch-changes-91) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-92) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-93) - [v6.0.0](#v600) @@ -372,6 +377,57 @@ Date: YYYY-MM-DD **Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y) --> +## v7.9.5 + +Date: 2025-10-27 + +### What's Changed + +#### Instrumentation (unstable) + +This release adds new `unstable_instrumentation` APIs that will allow you to add runtime instrumentation logic to various aspects of your application (server handler, client navigations/fetches, loaders, actions, middleware, `route.lazy`). For more information, please see the [docs](https://reactrouter.com/7.9.5/how-to/instrumentation). + +### Patch Changes + +- `react-router` - Ensure action handlers run for routes with middleware even if no loader is present ([#14443](https://github.com/remix-run/react-router/pull/14443)) +- `@react-router/dev` - Ensure route navigation doesn't remove CSS `link` elements used by dynamic imports ([#14463](https://github.com/remix-run/react-router/pull/14463)) +- `@react-router/dev` - Typegen: only register route module types for routes within the app directory ([#14439](https://github.com/remix-run/react-router/pull/14439)) + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - Move `unstable_RSCHydratedRouter` and utils to `react-router/dom` export ([#14457](https://github.com/remix-run/react-router/pull/14457)) +- `react-router` - Add a type-safe `handle` field to `unstable_useRoute()` ([#14462](https://github.com/remix-run/react-router/pull/14462)) + + For example: + + ```ts + // app/routes/admin.tsx + const handle = { hello: "world" }; + ``` + + ```ts + // app/routes/some-other-route.tsx + export default function Component() { + const admin = useRoute("routes/admin"); + if (!admin) throw new Error("Not nested within 'routes/admin'"); + console.log(admin.handle); + // ^? { hello: string } + } + ``` + +- `react-router` - Add `unstable_instrumentations` API to allow users to add observability to their apps by instrumenting route loaders, actions, middlewares, lazy, as well as server-side request handlers and client side navigations/fetches ([#14412](https://github.com/remix-run/react-router/pull/14412)) + - Framework Mode: + - `entry.server.tsx`: `export const unstable_instrumentations = [...]` + - `entry.client.tsx`: `` + - Data Mode + - `createBrowserRouter(routes, { unstable_instrumentations: [...] })` +- `react-router` - Add a new `unstable_pattern` parameter to loaders/actions/middleware which contains the un-interpolated route pattern (i.e., `/blog/:slug`) which is useful for aggregating logs/metrics by route in instrumentation code ([#14412](https://github.com/remix-run/react-router/pull/14412)) +- `@react-router/dev` - Introduce a `prerender.unstable_concurrency` option, to support running the pre-rendering concurrently, potentially speeding up the build ([#14380](https://github.com/remix-run/react-router/pull/14380)) + +**Full Changelog**: [`v7.9.4...v7.9.5`](https://github.com/remix-run/react-router/compare/react-router@7.9.4...react-router@7.9.5) + ## v7.9.4 Date: 2025-10-08 diff --git a/contributors.yml b/contributors.yml index 0b1dee6722..08dd3651a3 100644 --- a/contributors.yml +++ b/contributors.yml @@ -117,6 +117,7 @@ - ericschn - esadek - faergeek +- fernandojbf - FilipJirsak - focusotter - foxscotch @@ -211,6 +212,7 @@ - kigawas - kilavvy - kiliman +- kirillgroshkov - kkirsche - kno-raziel - knownasilya @@ -308,6 +310,7 @@ - pawelblaszczyk5 - pcattori - penx +- peterneave - petersendidit - phildl - phryneas diff --git a/decisions/0015-observability.md b/decisions/0015-observability.md new file mode 100644 index 0000000000..30fe084a50 --- /dev/null +++ b/decisions/0015-observability.md @@ -0,0 +1,283 @@ +# Title + +Date: 2025-09-22 + +Status: proposed + +## Context + +We want it to be easy to add observability to production React Router applications. This involves the ability to add logging, error reporting, and performance tracing to your application on both the server and the client. + +We always had a good story for user-facing error _display_ via `ErrorBoundary`, but until recently we only had a server-side error _reporting_ solution via the `entry.server` `handleError` export. In `7.8.2`, we shipped an `unstable_onError` client-side equivalent so it should now be possible to report on errors on the server and client pretty easily. + +We have not historically had great recommendations for the other 2 facets of observability - logging and performance tracing. Middleware, shipped in `7.3.0` and stabilized in `7.9.0` gave us a way to "wrap" request handlers at any level of the tree, which provides a good solution for logging and _some_ high-level performance tracing. But it's too coarse-grained and does not allow folks to drill down into their applications. + +This has also been raised in the (currently) 2nd-most upvoted Proposal in the past year: https://github.com/remix-run/react-router/discussions/13749. + +One way to add fine-grained logging/tracing today is to manually include it in all of your loaders and actions, but this is tedious and error-prone. + +Another way is to "instrument" the server build, which has long been our suggestion - initially to the folks at Sentry - and over time to RR users here and there in discord and github issues. but, we've never formally documented this as a recommended pattern, and it currently only works on the server and requires that you use a custom server. + +## Decision + +Adopt instrumentation as a first class API and the recommended way to implement observability in your application. + +There are 2 levels in which we want to instrument: + +- handler (server) and router (client) level + - instrument the request handler on the server + - instrument navigations and fetcher calls on the client + - singular instrumentation per operation +- route level + - instrument loaders, actions, middlewares, lazy + - multiple instrumentations per operation - multiple routes, multiple middlewares etc. + +On the server, if you are using a custom server, this is already possible by wrapping the react router request handler and walking the `build.routes` tree and wrapping the route handlers. + +To provide the same functionality when using `@react-router/serve` we need to open up a new API. Currently, I am proposing a new `instrumentations` export from `entry.server`. This will be applied to the server build in `createRequestHandler` and that way can work without a custom server. This will also allow custom-server users today to move some more code from their custom server into React Router by leveraging these new exports. + +A singular instrumentation function has the following shape: + +```tsx +function intrumentationFunction(doTheActualThing, info) { + // Do some stuff before starting the thing + + // Do the the thing + await doTheActualThing(); + + // Do some stuff after the thing finishes +} +``` + +This API allows for a few things: + +- Consistent API for instrumenting any async action - from a handler, to a navigation, to a loader, or a middleware +- By passing no arguments to `doTheActualThing()` and returning no data, this restricts the ability for instrumentation code to alter the actual runtime behavior of the app. I.e., you cannot modify arguments to loaders, nor change data returned from loaders. You can only report on the execution of loaders. +- The `info` parameter allows us to pass relevant read-only information, such as the `request`, `context`, `routeId`, etc. +- Nesting the call within a singular scope allows for contextual execution (i.e, `AsyncLocalStorage`) which enables things like nested OTEL traces to work properly + +Here's an example of this API on the server: + +```tsx +// entry.server.tsx + +export const instrumentations = [ + { + // Wrap the request handler - applies to _all_ requests handled by RR, including: + // - manifest requests + // - document requests + // - `.data` requests + // - resource route requests + handler({ instrument }) { + // Calling instrument performs the actual instrumentation + instrument({ + // Provide the instrumentation implementation for the request handler + async request(handleRequest, { request }) { + let start = Date.now(); + console.log(`Request start: ${request.method} ${request.url}`); + try { + await handleRequest(); + } finally { + let duration = Date.now() - start; + console.log( + `Request end: ${request.method} ${request.url} (${duration}ms)`, + ); + } + }, + }); + }, + // Instrument an individual route, allowing you to wrap middleware/loader/action/etc. + // This also gives you a place to do global "shouldRevalidate" which is a nice side + // effect as folks have asked for that for a long time + route({ instrument, id }) { + // `id` is the route id in case you want to instrument only some routes or + // instrument in a route-specific manner + if (id === "routes/i-dont-care") return; + + instrument({ + loader(callLoader, { request }) { + let start = Date.now(); + console.log(`Loader start: ${request.method} ${request.url}`); + try { + await callLoader(); + } finally { + let duration = Date.now() - start; + console.log( + `Loader end: ${request.method} ${request.url} (${duration}ms)`, + ); + } + }, + // action(), middleware(), lazy() + }); + }, + }, +]; +``` + +Open questions: + +- On the server we could technically do this at build time, but I don't expect this to have a large startup cost and doing it at build-time just feels a bit more magical and would differ from any examples we want to show in data mode. +- Another option for custom server folks would be to make these parameters to `createRequestHandler`, but then we'd still need a way for `react-router-server` users to use them and thus we'd still need to support them in `entry.server`, so might as well make it consistent for both. + +Client-side, it's a similar story. You could do this today at the route level in Data mode before calling `createBrowserRouter`, and you could wrap `router.navigate`/`router.fetch` after that. but there's no way to instrument the router `initialize` method without "ejecting" to using the lower level `createRouter`. And there is no way to do this in framework mode. + +I think we can open up APIs similar to those in `entry.server` but do them on `createBrowserRouter` and `HydratedRouter`: + +```tsx +// entry.client.tsx + +export const instrumentations = [{ + // Instrument router operations + router({ instrument }) { + instrument({ + async initialize(callNavigate, info) { /*...*/ }, + async navigate(callNavigate, info) { /*...*/ }, + async fetch(callNavigate, info) { /*...*/ }, + }); + }, + route({ instrument, id }) { + instrument({ + lazy(callLazy, info) { /*...*/ }, + middleware(callMiddleware, info) { /*...*/ }, + loader(callLoader, info) { /*...*/ }, + action(callAction, info) { /*...*/ }, + }); + }, +}]; + +// Data mode +let router = createBrowserRouter(routes, { instrumentations }) + +// Framework mode + +``` + +In both of these cases, we'll handle the instrumentation at the router creation level. And by passing `instrumentRoute` into the router, we can properly instrument future routes discovered via `route.lazy` or `patchRouteOnNavigation` + +### Error Handling + +It's important to note that the "handler" function will never throw. If the underlying loader/action throws, React Router will catch the error and return it out to you in case you need to perform some conditional logic in your instrumentation function - but your entire instrumentation function is thus guaranteed to run to completion even if the underlying application code errors. + +```tsx +function intrumentationFunction(doTheActualThing, info) { + let { status, error } = await doTheActualThing(); + // status is `"success" | "error"` + // `error` will only be defined if status === "error" + + if (status === "error") { + // ... + } else { + // ... + } +} +``` + +You should not be using the instrumentation logic to report errors though, that's better served by `entry.server.tsx`'s `handleError` and `HydratedRouter`/`RouterProvider` `unstable_onError` props. + +If your throw from your instrumentation function, we do not want that to impact runtime application behavior so React Router will gracefully swallow that error with a console warning and continue running as if you had returned successfully. + +In both of these examples, the handlers and all other instrumentation functions will still run: + +```tsx +// Throwing before calling the handler - we will detect this and still call the +// handler internally +function intrumentationFunction(doTheActualThing, info) { + somethingThatThrows(); + await doTheActualThing(); +} + +// Throwing after calling the handler - error will be caught internally +function intrumentationFunction2(doTheActualThing, info) { + await doTheActualThing(); + somethingThatThrows(); +} +``` + +### Composition + +Instrumentations is an array so that you can compose together multiple independent instrumentations easily: + +```tsx +let router = createBrowserRouter(routes, { + instrumentations: [logNavigations, addWindowPerfTraces, addSentryPerfTraces], +}); +``` + +### Dynamic Instrumentations + +By doing this at runtime, you should be able to enable instrumentation conditionally. + +Client side, it's trivial because it can be done on page load and avoid overhead on normal flows: + +```tsx +let enableInstrumentation = window.location.search.startsWith("?DEBUG"); +let router = createBrowserRouter(routes, { + instrumentations: enableInstrumentation ? [debuggingInstrumentations] : [], +}); +``` + +Server side, it's a bit tricker but should be doable with a custom server: + +```tsx +// Assume you export `instrumentations` from entry.server +let getBuild = () => import("virtual:react-router/server-build"); + +let instrumentedHandler = createRequestHandler({ + build: getBuild, +}); + +let unInstrumentedHandler = createRequestHandler({ + build: () => + getBuild().then((m) => ({ + ...m, + entry: { + ...m.entry, + module: { + ...m.entry.module, + unstable_instrumentations: undefined, + }, + }, + })), +}); + +app.use((req, res, next) => { + let url = new URL(req.url, `http://${req.headers.host}`); + if (url.searchParams.has("DEBUG")) { + return instrumentedHandler(req, res, next); + } + return unInstrumentedHandler(req, res, next); +}); +``` + +## Alternatives Considered + +### Events + +Originally we wanted to add an [Events API](https://github.com/remix-run/react-router/discussions/9565), but this proved to [have issues](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14135422) with the ability to "wrap" logic for easier OTEL instrumentation. These were not [insurmountable](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14421335), but the solutions didn't feel great. + +### patchRoutes + +Client side, we also considered whether this could be done via `patchRoutes`, but that's currently intended mostly to add new routes and doesn't work for `route.lazy` routes. In some RSC-use cases it can update parts of an existing route, but it only allows updates for the server-rendered RSC "elements," and doesn't walk the entire child tree to update children routes so it's not an ideal solution for updating loaders in the entire tree. + +### Naive Function wrapping + +The original implementation of this proposal was a naive simple wrapping of functions, but we moved away from this because by putting the wrapped function arguments (i.e., loader) in control of the user, they could potentially modify them and abuse the API to change runtime behavior instead of just instrument/observe. We want instrumentation to be limited to that - and it should not be able to change app behavior. + +```tsx +function instrumentRoute(route: RouteModule): RequestHandler { + let { loader } = route; + let newRoute = { ...route }; + if (loader) { + newRoute.loader = (args) => { + console.log("Loader start"); + try { + // ⚠️ The user could send whatever they want into the actual loader here + return await loader(...args); + } finally { + console.log("Loader end"); + } + }; + } + return newRoute; +} +``` diff --git a/docs/api/data-routers/createHashRouter.md b/docs/api/data-routers/createHashRouter.md index 528410ec37..307944a3fa 100644 --- a/docs/api/data-routers/createHashRouter.md +++ b/docs/api/data-routers/createHashRouter.md @@ -23,7 +23,7 @@ https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/do [Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.createHashRouter.html) Create a new [data router](https://api.reactrouter.com/v7/interfaces/react_router.DataRouter.html) that manages the application -path via the URL [`hash`]https://developer.mozilla.org/en-US/docs/Web/API/URL/hash). +path via the URL [`hash`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash). ## Signature diff --git a/docs/api/rsc/RSCHydratedRouter.md b/docs/api/rsc/RSCHydratedRouter.md index 7daecb105a..2882749a75 100644 --- a/docs/api/rsc/RSCHydratedRouter.md +++ b/docs/api/rsc/RSCHydratedRouter.md @@ -28,8 +28,6 @@ to release notes for relevant changes. ## Summary -[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.unstable_RSCHydratedRouter.html) - Hydrates a server rendered [`unstable_RSCPayload`](https://api.reactrouter.com/v7/types/react_router.unstable_RSCPayload.html) in the browser. ```tsx diff --git a/docs/api/rsc/createCallServer.md b/docs/api/rsc/createCallServer.md index 4ae506c9f5..857b65d2f4 100644 --- a/docs/api/rsc/createCallServer.md +++ b/docs/api/rsc/createCallServer.md @@ -28,8 +28,6 @@ to release notes for relevant changes. ## Summary -[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.unstable_createCallServer.html) - Create a React `callServer` implementation for React Router. ```tsx diff --git a/docs/api/rsc/getRSCStream.md b/docs/api/rsc/getRSCStream.md index 71a38a7ac9..26b875df87 100644 --- a/docs/api/rsc/getRSCStream.md +++ b/docs/api/rsc/getRSCStream.md @@ -28,8 +28,6 @@ to release notes for relevant changes. ## Summary -[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.unstable_getRSCStream.html) - Get the prerendered [RSC](https://react.dev/reference/rsc/server-components) stream for hydration. Usually passed directly to your `react-server-dom-xyz/client`'s `createFromReadableStream`. diff --git a/docs/how-to/instrumentation.md b/docs/how-to/instrumentation.md new file mode 100644 index 0000000000..8bb39ef6fb --- /dev/null +++ b/docs/how-to/instrumentation.md @@ -0,0 +1,567 @@ +--- +title: Instrumentation +unstable: true +--- + +# Instrumentation + +[MODES: framework, data] + +
+
+ +The instrumentation APIs are experimental and subject to breaking changes in +minor/patch releases. Please use with caution and pay **very** close attention +to release notes for relevant changes. + +Instrumentation allows you to add logging, error reporting, and performance tracing to your React Router application without modifying your actual route handlers. This enables comprehensive observability solutions for production applications on both the server and client. + +## Overview + +With the React Router Instrumentation APIs, you provide "wrapper" functions that execute around your request handlers, router operations, route middlewares, and/or route handlers. This allows you to: + +- Monitor application performance +- Add logging +- Integrate with observability platforms (Sentry, DataDog, New Relic, etc.) +- Implement OpenTelemetry tracing +- Track user behavior and navigation patterns + +A key design principle is that instrumentation is **read-only** - you can observe what's happening but cannot modify runtime application behavior by modifying the arguments passed to, or data returned from your route handlers. + + +As with any instrumentation approach, adding additional code execution at runtime may alter the performance characteristics compared to an uninstrumented application. Keep this in mind and perform appropriate testing and/or leverage conditional instrumentation to avoid a negative UX impact in production. + + +## Quick Start (Framework Mode) + +[modes: framework] + +### 1. Server-side Instrumentation + +Add instrumentations to your `entry.server.tsx`: + +```tsx filename=app/entry.server.tsx +export const unstable_instrumentations = [ + { + // Instrument the server handler + handler(handler) { + handler.instrument({ + async request(handleRequest, { request }) { + let url = `${request.method} ${request.url}`; + console.log(`Request start: ${url}`); + await handleRequest(); + console.log(`Request end: ${url}`); + }, + }); + }, + + // Instrument individual routes + route(route) { + // Skip instrumentation for specific routes if needed + if (route.id === "root") return; + + route.instrument({ + async loader(callLoader, { request }) { + let url = `${request.method} ${request.url}`; + console.log(`Loader start: ${url} - ${route.id}`); + await callLoader(); + console.log(`Loader end: ${url} - ${route.id}`); + }, + // Other available instrumentations: + // async action() { /* ... */ }, + // async middleware() { /* ... */ }, + // async lazy() { /* ... */ }, + }); + }, + }, +]; + +export default function handleRequest(/* ... */) { + // Your existing handleRequest implementation +} +``` + +### 2. Client-side Instrumentation + +Add instrumentations to your `entry.client.tsx`: + +```tsx filename=app/entry.client.tsx +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +const unstable_instrumentations = [ + { + // Instrument router operations + router(router) { + router.instrument({ + // Instrument navigations + async navigate(callNavigate, { currentUrl, to }) { + let nav = `${currentUrl} → ${to}`; + console.log(`Navigation start: ${nav}`); + await callNavigate(); + console.log(`Navigation end: ${nav}`); + }, + // Instrument fetcher calls + async fetch( + callFetch, + { href, currentUrl, fetcherKey }, + ) { + let fetch = `${fetcherKey} → ${href}`; + console.log(`Fetcher start: ${fetch}`); + await callFetch(); + console.log(`Fetcher end: ${fetch}`); + }, + }); + }, + + // Instrument individual routes (same as server-side) + route(route) { + // Skip instrumentation for specific routes if needed + if (route.id === "root") return; + + route.instrument({ + async loader(callLoader, { request }) { + let url = `${request.method} ${request.url}`; + console.log(`Loader start: ${url} - ${route.id}`); + await callLoader(); + console.log(`Loader end: ${url} - ${route.id}`); + }, + // Other available instrumentations: + // async action() { /* ... */ }, + // async middleware() { /* ... */ }, + // async lazy() { /* ... */ }, + }); + }, + }, +]; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); +``` + +## Quick Start (Data Mode) + +[modes: data] + +In Data Mode, you add instrumentations when creating your router: + +```tsx +import { + createBrowserRouter, + RouterProvider, +} from "react-router"; + +const unstable_instrumentations = [ + { + // Instrument router operations + router(router) { + router.instrument({ + // Instrument navigations + async navigate(callNavigate, { currentUrl, to }) { + let nav = `${currentUrl} → ${to}`; + console.log(`Navigation start: ${nav}`); + await callNavigate(); + console.log(`Navigation end: ${nav}`); + }, + // Instrument fetcher calls + async fetch( + callFetch, + { href, currentUrl, fetcherKey }, + ) { + let fetch = `${fetcherKey} → ${href}`; + console.log(`Fetcher start: ${fetch}`); + await callFetch(); + console.log(`Fetcher end: ${fetch}`); + }, + }); + }, + + // Instrument individual routes (same as server-side) + route(route) { + // Skip instrumentation for specific routes if needed + if (route.id === "root") return; + + route.instrument({ + async loader(callLoader, { request }) { + let url = `${request.method} ${request.url}`; + console.log(`Loader start: ${url} - ${route.id}`); + await callLoader(); + console.log(`Loader end: ${url} - ${route.id}`); + }, + // Other available instrumentations: + // async action() { /* ... */ }, + // async middleware() { /* ... */ }, + // async lazy() { /* ... */ }, + }); + }, + }, +]; + +const router = createBrowserRouter(routes, { + unstable_instrumentations, +}); + +function App() { + return ; +} +``` + +## Core Concepts + +### Instrumentation Levels + +There are different levels at which you can instrument your application. Each instrumentation function receives a second "info" parameter containing relevant contextual information for the specific aspect being instrumented. + +#### 1. Handler Level (Server) + +[modes: framework] + +Instruments the top-level request handler that processes all requests to your server: + +```tsx filename=entry.server.tsx +export const unstable_instrumentations = [ + { + handler(handler) { + handler.instrument({ + async request(handleRequest, { request, context }) { + // Runs around ALL requests to your app + await handleRequest(); + }, + }); + }, + }, +]; +``` + +#### 2. Router Level (Client) + +[modes: framework,data] + +Instruments client-side router operations like navigations and fetcher calls: + +```tsx +export const unstable_instrumentations = [ + { + router(router) { + router.instrument({ + async navigate(callNavigate, { to, currentUrl }) { + // Runs around navigation operations + await callNavigate(); + }, + async fetch( + callFetch, + { href, currentUrl, fetcherKey }, + ) { + // Runs around fetcher operations + await callFetch(); + }, + }); + }, + }, +]; + +// Framework Mode (entry.client.tsx) +; + +// Data Mode +const router = createBrowserRouter(routes, { + unstable_instrumentations, +}); +``` + +#### 3. Route Level (Server + Client) + +[modes: framework,data] + +Instruments individual route handlers: + +```tsx +const unstable_instrumentations = [ + { + route(route) { + route.instrument({ + async loader( + callLoader, + { params, request, context, unstable_pattern }, + ) { + // Runs around loader execution + await callLoader(); + }, + async action( + callAction, + { params, request, context, unstable_pattern }, + ) { + // Runs around action execution + await callAction(); + }, + async middleware( + callMiddleware, + { params, request, context, unstable_pattern }, + ) { + // Runs around middleware execution + await callMiddleware(); + }, + async lazy(callLazy) { + // Runs around lazy route loading + await callLazy(); + }, + }); + }, + }, +]; +``` + +### Read-only Design + +Instrumentations are designed to be **observational only**. You cannot: + +- Modify arguments passed to handlers +- Change return values from handlers +- Alter application behavior + +This ensures that instrumentation is safe to add to production applications and cannot introduce bugs in your route logic. + +### Error Handling + +To ensure that instrumentation code doesn't impact the runtime application, errors are caught internally and prevented from propagating outward. This design choice shows up in 2 aspects. + +First, if a "handler" function (loader, action, request handler, navigation, etc.) throws an error, that error will not bubble out of the `callHandler` function invoked from your instrumentation. Instead, the `callHandler` function returns a discriminated union result of type `{ type: "success", error: undefined } | { type: "error", error: unknown }`. This ensures your entire instrumentation function runs without needing any try/catch/finally logic to handle application errors. + +```tsx +export const unstable_instrumentations = [ + { + route(route) { + route.instrument({ + async loader(callLoader) { + let { status, error } = await callLoader(); + + if (status === "error") { + // error case - `error` is defined + } else { + // success case - `error` is undefined + } + }, + }); + }, + }, +]; +``` + +Second, if your instrumentation function throws an error, React Router will gracefully swallow that so that it does not bubble outward and impact other instrumentations or application behavior. In both of these examples, the handlers and all other instrumentation functions will still run: + +```tsx +export const unstable_instrumentations = [ + { + route(route) { + route.instrument({ + // Throwing before calling the handler - RR will + // catch the error and still call the loader + async loader(callLoader) { + somethingThatThrows(); + await callLoader(); + }, + // Throwing after calling the handler - RR will + // catch the error internally + async action(callAction) { + await callAction(); + somethingThatThrows(); + }, + }); + }, + }, +]; +``` + +### Composition + +You can compose multiple instrumentations by providing an array: + +```tsx +export const unstable_instrumentations = [ + loggingInstrumentation, + performanceInstrumentation, + errorReportingInstrumentation, +]; +``` + +Each instrumentation wraps the previous one, creating a nested execution chain. + +### Conditional Instrumentation + +You can enable instrumentation conditionally based on environment or other factors: + +```tsx +export const unstable_instrumentations = + process.env.NODE_ENV === "production" + ? [productionInstrumentation] + : [developmentInstrumentation]; +``` + +```tsx +// Or conditionally within an instrumentation +export const unstable_instrumentations = [ + { + route(route) { + // Only instrument specific routes + if (!route.id?.startsWith("routes/admin")) return; + + // Or, only instrument if a query parameter is present + let sp = new URL(request.url).searchParams; + if (!sp.has("DEBUG")) return; + + route.instrument({ + async loader() { + /* ... */ + }, + }); + }, + }, +]; +``` + +## Common Patterns + +### Request logging (server) + +```tsx +const logging: unstable_ServerInstrumentation = { + handler({ instrument }) { + instrument({ + request: (fn, { request }) => + log(`request ${request.url}`, fn), + }); + }, + route({ instrument, id }) { + instrument({ + middleware: (fn) => log(` middleware (${id})`, fn), + loader: (fn) => log(` loader (${id})`, fn), + action: (fn) => log(` action (${id})`, fn), + }); + }, +}; + +async function log( + label: string, + cb: () => Promise, +) { + let start = Date.now(); + console.log(`➡️ ${label}`); + await cb(); + console.log(`⬅️ ${label} (${Date.now() - start}ms)`); +} + +export const unstable_instrumentations = [logging]; +``` + +### OpenTelemetry Integration + +```tsx +import { trace, SpanStatusCode } from "@opentelemetry/api"; + +const tracer = trace.getTracer("my-app"); + +const otel: unstable_ServerInstrumentation = { + handler({ instrument }) { + instrument({ + request: (fn, { request }) => + otelSpan(`request`, { url: request.url }, fn), + }); + }, + route({ instrument, id }) { + instrument({ + middleware: (fn, { unstable_pattern }) => + otelSpan( + "middleware", + { routeId: id, pattern: unstable_pattern }, + fn, + ), + loader: (fn, { unstable_pattern }) => + otelSpan( + "loader", + { routeId: id, pattern: unstable_pattern }, + fn, + ), + action: (fn, { unstable_pattern }) => + otelSpan( + "action", + { routeId: id, pattern: unstable_pattern }, + fn, + ), + }); + }, +}; + +async function otelSpan( + label: string, + attributes: Record, + cb: () => Promise, +) { + return tracer.startActiveSpan( + label, + { attributes }, + async (span) => { + let { error } = await cb(); + if (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + } + span.end(); + }, + ); +} + +export const unstable_instrumentations = [otel]; +``` + +### Client-side Performance Tracking + +```tsx +const windowPerf: unstable_ClientInstrumentation = { + router({ instrument }) { + instrument({ + navigate: (fn, { to, currentUrl }) => + measure(`navigation:${currentUrl}->${to}`, fn), + fetch: (fn, { href }) => + measure(`fetcher:${href}`, fn), + }); + }, + route({ instrument, id }) { + instrument({ + middleware: (fn) => measure(`middleware:${id}`, fn), + loader: (fn) => measure(`loader:${id}`, fn), + action: (fn) => measure(`action:${id}`, fn), + }); + }, +}; + +async function measure( + label: string, + cb: () => Promise, +) { + performance.mark(`start:${label}`); + await cb(); + performance.mark(`end:${label}`); + performance.measure( + label, + `start:${label}`, + `end:${label}`, + ); +} + +; +``` diff --git a/docs/how-to/pre-rendering.md b/docs/how-to/pre-rendering.md index 71e182c423..e0cbc6a85d 100644 --- a/docs/how-to/pre-rendering.md +++ b/docs/how-to/pre-rendering.md @@ -9,43 +9,105 @@ title: Pre-Rendering

-Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. Pre-rendering is enabled via the `prerender` config in `react-router.config.ts` and can be used in two ways based on the `ssr` config value: +Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. -- Alongside a runtime SSR server with `ssr:true` (the default value) -- Deployed to a static file server with `ssr:false` +## Configuration + +Pre-rendering is enabled via the `prerender` config in `react-router.config.ts`. + +The simplest configuration is a boolean `true` which will pre-render all off the applications static paths based on `routes.ts`: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; -## Pre-rendering with `ssr:true` +export default { + prerender: true, +} satisfies Config; +``` -### Configuration +The boolean `true` will not include any dynamic paths (i.e., `/blog/:slug`) because the parameter values are unknown. -Add the `prerender` option to your config, there are three signatures: +To configure specific paths including dynamic values, you can specify an array of paths: -```ts filename=react-router.config.ts lines=[7-8,10-11,13-21] +```ts filename=react-router.config.ts import type { Config } from "@react-router/dev/config"; +let slugs = getPostSlugs(); + export default { - // Can be omitted - defaults to true - ssr: true, + prerender: [ + "/", + "/blog", + ...slugs.map((s) => `/blog/${s}`), + ], +} satisfies Config; +``` - // all static paths (no dynamic segments like "/post/:slug") - prerender: true, +If you need to perform more complex and/or asynchronous logic to determine the paths, you can also provide a function that returns an array of paths. This function provides you with a `getStaticPaths` method you can use to avoid manually adding all of the static paths in your application: - // specific paths - prerender: ["/", "/blog", "/blog/popular-post"], +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; - // async function for dependencies like a CMS +export default { async prerender({ getStaticPaths }) { - let posts = await fakeGetPostsFromCMS(); + let slugs = await getPostSlugsFromCMS(); return [ + ...getStaticPaths(), // "/" and "/blog" + ...slugs.map((s) => `/blog/${s}`), + ]; + }, +} satisfies Config; +``` + +### Concurrency (unstable) + +This API is experimental and subject to breaking changes in +minor/patch releases. Please use with caution and pay **very** close attention +to release notes for relevant changes. + +By default, pages are pre-rendered one path at a time. You can enable concurrency to pre-render multiple paths in parallel which can speed up build times in many cases. You should experiment with the value that provides the best performance for your app. + +To specify concurrency, move your `prerender` config down into a `prerender.paths` field and you can specify the concurrency in `prerender.unstable_concurrency`: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +let slugs = getPostSlugs(); + +export default { + prerender: { + paths: [ "/", "/blog", - ...posts.map((post) => post.href), - ]; + ...slugs.map((s) => `/blog/${s}`), + ], + unstable_concurrency: 4, }, } satisfies Config; ``` -### Data Loading and Pre-rendering +## Pre-Rendering with/without a Runtime Server + +Pre-Rendering can be used in two ways based on the `ssr` config value: + +- Alongside a runtime SSR server with `ssr:true` (the default value) +- Deployed to a static file server with `ssr:false` + +### Pre-rendering with `ssr:true` + +When pre-rendering with `ssr:true`, you're indicating you will still have a runtime server but you are choosing to pre-render certain paths for quicker Response times. + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + // Can be omitted - defaults to true + ssr: true, + prerender: ["/", "/blog", "/blog/popular-post"], +} satisfies Config; +``` + +#### Data Loading and Pre-rendering There is no extra application API for pre-rendering. Routes being pre-rendered use the same route `loader` functions as server rendering: @@ -64,7 +126,7 @@ Instead of a request coming to your route on a deployed server, the build create When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual. -### Static File Output +#### Static File Output The rendered result will be written out to your `build/client` directory. You'll notice two files for each path: @@ -89,7 +151,7 @@ Prerender: Generated build/client/blog/my-first-post/index.html During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`. -## Pre-rendering with `ssr:false` +### Pre-rendering with `ssr:false` The above examples assume you are deploying a runtime server but are pre-rendering some static pages to avoid hitting the server, resulting in faster loads. @@ -108,7 +170,7 @@ If you specify `ssr:false` without a `prerender` config, React Router refers to If you want to pre-render paths with `ssr:false`, those matched routes _can_ have loaders because we'll pre-render all of the matched routes for those paths, not just the root. You cannot include `actions` or `headers` functions in any routes when `ssr:false` is set because there will be no runtime server to run them on. -### Pre-rendering with a SPA Fallback +#### Pre-rendering with a SPA Fallback If you want `ssr:false` but don't want to pre-render _all_ of your routes - that's fine too! You may have some paths where you need the performance/SEO benefits of pre-rendering, but other pages where a SPA would be fine. @@ -155,7 +217,7 @@ sirv-cli build/client --single index.html sirv-cli build/client --single __spa-fallback.html ``` -### Invalid Exports +#### Invalid Exports When pre-rendering with `ssr:false`, React Router will error at build time if you have invalid exports to help prevent some mistakes that can be easily overlooked. diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index c38fab5c87..b705f44fda 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -10,7 +10,9 @@ unstable: true

-React Server Components support is experimental and subject to breaking changes. +React Server Components support is experimental and subject to breaking changes in +minor/patch releases. Please use with caution and pay **very** close attention +to release notes for relevant changes. React Server Components (RSC) refers generally to an architecture and set of APIs provided by React since version 19. diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 37e1dbce47..5171b7bd93 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -196,3 +196,126 @@ test("allows users to pass an onError function to HydratedRouter", async ({ appFixture.close(); }); + +test("allows users to instrument the client side router via HydratedRouter", async ({ + page, +}) => { + let fixture = await createFixture({ + files: { + "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, + + + + ); + }); + `, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return Go to Page; + } + `, + "app/routes/page.tsx": js` + import { useFetcher } from "react-router"; + export function loader() { + return { data: "hello world" }; + } + export function action() { + return "OK"; + } + export default function Page({ loaderData }) { + let fetcher = useFetcher({ key: 'a' }); + return ( + <> +

{loaderData.data}

; + + {fetcher.data ?
{fetcher.data}
: null} + + ); + } + `, + }, + }); + + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + await page.click('a[href="/page"]'); + await page.waitForSelector("[data-page]"); + + expect(await app.getHtml()).toContain("hello world"); + expect(logs).toEqual([ + 'start navigate [["currentUrl","/"],["to","/page"]]', + "start loader root /page", + "start loader routes/page /page", + "end loader root /page", + "end loader routes/page /page", + 'end navigate [["currentUrl","/"],["to","/page"]]', + ]); + logs.splice(0); + + await page.click("[data-fetch]"); + await page.waitForSelector("[data-fetcher-data]"); + await expect(page.locator("[data-fetcher-data]")).toContainText("OK"); + expect(logs).toEqual([ + 'start fetch [["body",{"key":"value"}],["currentUrl","/page"],["fetcherKey","a"],["formData",null],["formEncType","application/x-www-form-urlencoded"],["formMethod","post"],["href","/page"]]', + "start action routes/page /page", + "end action routes/page /page", + "start loader root /page", + "start loader routes/page /page", + "end loader root /page", + "end loader routes/page /page", + 'end fetch [["body",{"key":"value"}],["currentUrl","/page"],["fetcherKey","a"],["formData",null],["formEncType","application/x-www-form-urlencoded"],["formMethod","post"],["href","/page"]]', + ]); + + appFixture.close(); +}); diff --git a/integration/helpers/rsc-parcel/src/browser.tsx b/integration/helpers/rsc-parcel/src/browser.tsx index d0c02387d1..4f7e3d8dfe 100644 --- a/integration/helpers/rsc-parcel/src/browser.tsx +++ b/integration/helpers/rsc-parcel/src/browser.tsx @@ -2,12 +2,12 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; -import type { unstable_RSCPayload as RSCPayload } from "react-router"; import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from "react-router"; + type unstable_RSCPayload as RSCPayload, +} from "react-router/dom"; import { createFromReadableStream, createTemporaryReferenceSet, diff --git a/integration/helpers/rsc-vite/src/entry.browser.tsx b/integration/helpers/rsc-vite/src/entry.browser.tsx index e45f8a4137..67182282f1 100644 --- a/integration/helpers/rsc-vite/src/entry.browser.tsx +++ b/integration/helpers/rsc-vite/src/entry.browser.tsx @@ -10,8 +10,8 @@ import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from "react-router"; -import type { unstable_RSCPayload as RSCPayload } from "react-router"; + type unstable_RSCPayload as RSCPayload, +} from "react-router/dom"; import { getContext } from "./config/get-context"; setServerCallback( diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts index 9ffcc5e183..01664a296a 100644 --- a/integration/typegen-test.ts +++ b/integration/typegen-test.ts @@ -456,6 +456,31 @@ test.describe("typegen", () => { await $("pnpm typecheck"); }); + test("route files with paths outside of app directory", async ({ + cwd, + edit, + $, + }) => { + await fs.mkdir(Path.join(cwd, "node_modules/external_dependency"), { + recursive: true, + }); + await edit({ + "app/routes.ts": tsx` + import { type RouteConfig, route } from "@react-router/dev/routes"; + + export default [ + route("outside/:id", "../node_modules/external_dependency/index.js"), + ] satisfies RouteConfig; + `, + "node_modules/external_dependency/index.js": tsx` + export default function Component() { + return
External Dependency
+ } + `, + }); + await $("pnpm typecheck"); + }); + test("href", async ({ edit, $ }) => { await edit({ "app/routes.ts": tsx` diff --git a/integration/use-route-test.ts b/integration/use-route-test.ts index 44b3bca2cb..14515bb1db 100644 --- a/integration/use-route-test.ts +++ b/integration/use-route-test.ts @@ -27,6 +27,7 @@ test.use({ "app/root.tsx": tsx` import { Outlet } from "react-router" + export const handle = { rootHandle: "root/handle" } export const loader = () => ({ rootLoader: "root/loader" }) export const action = () => ({ rootAction: "root/action" }) @@ -42,6 +43,7 @@ test.use({ "app/routes/parent.tsx": tsx` import { Outlet } from "react-router" + export const handle = { parentHandle: "parent/handle" } export const loader = () => ({ parentLoader: "parent/loader" }) export const action = () => ({ parentAction: "parent/action" }) @@ -59,21 +61,38 @@ test.use({ import type { Expect, Equal } from "../expect-type" + export const handle = { currentHandle: "current/handle" } export const loader = () => ({ currentLoader: "current/loader" }) export const action = () => ({ currentAction: "current/action" }) export default function Component() { const current = useRoute() - type Test1 = Expect> + type Test1 = Expect> const root = useRoute("root") - type Test2 = Expect> + type Test2 = Expect> const parent = useRoute("routes/parent") - type Test3 = Expect> + type Test3 = Expect> const other = useRoute("routes/other") - type Test4 = Expect> + type Test4 = Expect> return ( <> @@ -87,6 +106,7 @@ test.use({ } `, "app/routes/other.tsx": tsx` + export const handle = { otherHandle: "other/handle" } export const loader = () => ({ otherLoader: "other/loader" }) export const action = () => ({ otherAction: "other/action" }) diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts new file mode 100644 index 0000000000..21c444693d --- /dev/null +++ b/integration/vite-css-lazy-loading-test.ts @@ -0,0 +1,280 @@ +import { type Page, test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { + type Fixture, + type AppFixture, + createAppFixture, + createFixture, + css, + js, +} from "./helpers/create-fixture.js"; + +// Link hrefs with a trailing hash are only ever managed by React Router, to +// ensure they're forcibly unique from the Vite-injected links +const FORCIBLY_UNIQUE_HREF_SELECTOR = "[href$='#']"; +const CSS_LINK_SELECTOR = "link[rel='stylesheet']"; +const ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR = `link[rel='stylesheet']${FORCIBLY_UNIQUE_HREF_SELECTOR}`; +const CSS_COMPONENT_LINK_SELECTOR = + "link[rel='stylesheet'][href*='css-component']"; +const CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR = `link[rel='stylesheet'][href*='css-component']${FORCIBLY_UNIQUE_HREF_SELECTOR}`; + +function getColor(page: Page, selector: string) { + return page + .locator(selector) + .first() + .evaluate((el) => window.getComputedStyle(el).color); +} + +test.describe("Vite CSS lazy loading", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/components/css-component.css": css` + .css-component { + color: rgb(0, 128, 0); + font-family: sans-serif; + font-weight: bold; + } + `, + + "app/components/css-component.tsx": js` + import "./css-component.css"; + export default function CssComponent() { + return

This text should be green.

; + } + `, + + "app/components/static-only-css-component.css": css` + .static-only-css-component { + color: rgb(128, 128, 0); + font-family: sans-serif; + font-weight: bold; + } + `, + + "app/components/static-only-css-component.tsx": js` + import "./static-only-css-component.css"; + export default function StaticOnlyCssComponent() { + return

This text should be olive.

; + } + `, + + "app/components/load-lazy-css-component.tsx": js` + import { lazy, useState } from "react"; + export const LazyCssComponent = lazy(() => import("./css-component")); + export function LoadLazyCssComponent() { + const [show, setShow] = useState(false); + return ( + <> + + {show && } + + ); + } + `, + + "app/routes/_layout.tsx": js` + import { Link, Outlet } from "react-router"; + import { LoadLazyCssComponent } from "../components/load-lazy-css-component"; + export default function Layout() { + return ( + <> + + + + ); + } + `, + + "app/routes/_layout._index.tsx": js` + export default function Index() { + return

Home

; + } + `, + + "app/routes/_layout.parent-child.tsx": js` + import { Outlet } from "react-router"; + import { LoadLazyCssComponent } from "../components/load-lazy-css-component"; + export default function ParentChild() { + return ( + <> +

Parent + Child

+ + + + ); + } + `, + + "app/routes/_layout.parent-child.with-css-component.tsx": js` + import CssComponent from "../components/css-component"; + export default function RouteWithCssComponent() { + return ( + <> +

Route with CSS Component

+ + + ); + } + `, + + "app/routes/_layout.parent-child.without-css-component.tsx": js` + export default function RouteWithoutCssComponent() { + return

Route Without CSS Component

; + } + `, + + "app/routes/_layout.siblings.tsx": js` + import { Outlet } from "react-router"; + export default function Siblings() { + return ( + <> +

Siblings

+ + + ); + } + `, + + "app/routes/_layout.siblings.with-css-component.tsx": js` + import CssComponent from "../components/css-component"; + export default function SiblingsWithCssComponent() { + return ( + <> +

Route with CSS Component

+ + + ); + } + `, + + "app/routes/_layout.siblings.with-lazy-css-component.tsx": js` + import { LazyCssComponent } from "../components/load-lazy-css-component"; + export default function SiblingsWithLazyCssComponent() { + return ( + <> +

Route with Lazy CSS Component

+ + + ); + } + `, + + "app/routes/_layout.with-static-only-css-component.tsx": js` + import StaticOnlyCssComponent from "../components/static-only-css-component"; + export default function WithStaticOnlyCssComponent() { + return ( + <> +

Route with Static Only CSS Component

+ + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("retains CSS from dynamic imports in a parent route on navigation if the same CSS is a static dependency of a child route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent-child/with-css-component"); + await page.waitForSelector("[data-child-route-with-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1); + expect( + await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(), + ).toBe(1); + expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)"); + + await page.locator("[data-load-lazy-css-component]").click(); + await page.waitForSelector("[data-css-component]"); + expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(2); + expect( + await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(), + ).toBe(1); + expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)"); + + await app.clickLink("/parent-child/without-css-component"); + await page.waitForSelector("[data-child-route-without-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1); + expect( + await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(), + ).toBe(0); + expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)"); + }); + + test("supports CSS lazy loading when navigating to a sibling route if the current route has a static dependency on the same CSS", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/siblings/with-css-component"); + await page.waitForSelector("[data-sibling-route-with-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1); + expect( + await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(), + ).toBe(1); + expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)"); + + await app.clickLink("/siblings/with-lazy-css-component"); + await page.waitForSelector("[data-sibling-route-with-lazy-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1); + expect( + await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(), + ).toBe(0); + expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)"); + }); + + test("does not add a hash to the CSS link if the CSS is only ever statically imported", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/with-static-only-css-component"); + await page.waitForSelector("[data-route-with-static-only-css-component]"); + expect(await page.locator(CSS_LINK_SELECTOR).count()).toBe(1); + expect( + await page.locator(ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR).count(), + ).toBe(0); + expect(await getColor(page, "[data-static-only-css-component]")).toBe( + "rgb(128, 128, 0)", + ); + }); +}); diff --git a/integration/vite-dot-server-test.ts b/integration/vite-dot-server-test.ts index 77e749d192..6e686d6b01 100644 --- a/integration/vite-dot-server-test.ts +++ b/integration/vite-dot-server-test.ts @@ -146,7 +146,7 @@ test.describe("Vite / route / server-only module referenced by client", () => { ` But other route exports in 'app/routes/_index.tsx' depend on '${specifier}'.`, - " See https://remix.run/docs/en/main/guides/vite#splitting-up-client-and-server-code", + " See https://reactrouter.com/explanation/code-splitting#removal-of-server-code", ].forEach(expect(stderr).toMatch); }); } @@ -206,7 +206,7 @@ test.describe("Vite / non-route / server-only module referenced by client", () = ` '${specifier}' imported by 'app/reexport-server-only.ts'`, - " See https://remix.run/docs/en/main/guides/vite#splitting-up-client-and-server-code", + " See https://reactrouter.com/explanation/code-splitting#removal-of-server-code", ].forEach(expect(stderr).toMatch); }); } diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index ebbf8f62f9..2cd0c95bbe 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -577,6 +577,58 @@ test.describe("Prerendering", () => { expect(html).toMatch('

About

'); expect(html).toMatch('

About Loader Data

'); }); + + test("Permits a concurrency option", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` + export default { + prerender: { + paths: ['/', '/about'], + unstable_concurrency: 2, + }, + } + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch('

Index Loader Data

'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch('

About Loader Data

'); + }); }); test.describe("ssr: true", () => { diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 49f7ad6ac1..659c81c8fb 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,7 @@ # `create-react-router` +## 7.9.5 + ## 7.9.4 _No changes_ diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index c51639a244..07e9001fad 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.9.4", + "version": "7.9.5", "description": "Create a new React Router app", "homepage": "https://reactrouter.com", "bugs": { diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index 8dd3f96677..4b141ef806 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + - `@react-router/node@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index 2e4c1ac8bf..548429f170 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.9.4", + "version": "7.9.5", "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 46daa9383e..6e43d70bc0 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 44275aa75e..a17885a08d 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.9.4", + "version": "7.9.5", "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 980bd3bab0..e61549b084 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,18 @@ # `@react-router/dev` +## 7.9.5 + +### Patch Changes + +- Introduce a `prerender.unstable_concurrency` option, to support running the prerendering concurrently, potentially speeding up the build. ([#14380](https://github.com/remix-run/react-router/pull/14380)) +- Move RSCHydratedRouter and utils to `/dom` export. ([#14457](https://github.com/remix-run/react-router/pull/14457)) +- Ensure route navigation doesn't remove CSS `link` elements used by dynamic imports ([#14463](https://github.com/remix-run/react-router/pull/14463)) +- Typegen: only register route module types for routes within the app directory ([#14439](https://github.com/remix-run/react-router/pull/14439)) +- Updated dependencies: + - `react-router@7.9.5` + - `@react-router/node@7.9.5` + - `@react-router/serve@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 44f63824a0..3e0df29fc4 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -110,6 +110,13 @@ type BuildEndHook = (args: { viteConfig: Vite.ResolvedConfig; }) => void | Promise; +export type PrerenderPaths = + | boolean + | Array + | ((args: { + getStaticPaths: () => string[]; + }) => Array | Promise>); + /** * Config to be exported via the default export from `react-router.config.ts`. */ @@ -149,13 +156,19 @@ export type ReactRouterConfig = { /** * An array of URLs to prerender to HTML files at build time. Can also be a * function returning an array to dynamically generate URLs. + * + * `unstable_concurrency` defaults to 1, which means "no concurrency" - fully serial execution. + * Setting it to a value more than 1 enables concurrent prerendering. + * Setting it to a value higher than one can increase the speed of the build, + * but may consume more resources, and send more concurrent requests to the + * server/CMS. */ prerender?: - | boolean - | Array - | ((args: { - getStaticPaths: () => string[]; - }) => Array | Promise>); + | PrerenderPaths + | { + paths: PrerenderPaths; + unstable_concurrency?: number; + }; /** * An array of React Router plugin config presets to ease integration with * other platforms and tools. @@ -462,17 +475,35 @@ async function resolveConfig({ serverBundles = undefined; } - let isValidPrerenderConfig = - prerender == null || - typeof prerender === "boolean" || - Array.isArray(prerender) || - typeof prerender === "function"; + if (prerender) { + let isValidPrerenderPathsConfig = (p: unknown) => + typeof p === "boolean" || typeof p === "function" || Array.isArray(p); - if (!isValidPrerenderConfig) { - return err( - "The `prerender` config must be a boolean, an array of string paths, " + - "or a function returning a boolean or array of string paths", - ); + let isValidPrerenderConfig = + isValidPrerenderPathsConfig(prerender) || + (typeof prerender === "object" && + "paths" in prerender && + isValidPrerenderPathsConfig(prerender.paths)); + + if (!isValidPrerenderConfig) { + return err( + "The `prerender`/`prerender.paths` config must be a boolean, an array " + + "of string paths, or a function returning a boolean or array of string paths.", + ); + } + + let isValidConcurrencyConfig = + typeof prerender != "object" || + !("unstable_concurrency" in prerender) || + (typeof prerender.unstable_concurrency === "number" && + Number.isInteger(prerender.unstable_concurrency) && + prerender.unstable_concurrency > 0); + + if (!isValidConcurrencyConfig) { + return err( + "The `prerender.unstable_concurrency` config must be a positive integer if specified.", + ); + } } let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"]; diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index 1d85ca6e92..a1fef50d84 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -12,8 +12,8 @@ import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from "react-router"; -import type { unstable_RSCPayload as RSCPayload } from "react-router"; + type unstable_RSCPayload as RSCPayload, +} from "react-router/dom"; setServerCallback( createCallServer({ diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 2722b58c1a..c7f0bba96f 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.9.4", + "version": "7.9.5", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com", "bugs": { @@ -83,6 +83,7 @@ "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", + "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index dcab03fd27..72b5b46fb1 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -205,13 +205,15 @@ function routeModulesType(ctx: Context) { t.tsPropertySignature( t.stringLiteral(route.id), t.tsTypeAnnotation( - t.tsTypeQuery( - t.tsImportType( - t.stringLiteral( - `./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`, - ), - ), - ), + isInAppDirectory(ctx, route.file) + ? t.tsTypeQuery( + t.tsImportType( + t.stringLiteral( + `./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`, + ), + ), + ) + : t.tsUnknownKeyword(), ), ), ), diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index d409f808c0..27a5a22cae 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -36,6 +36,7 @@ import pick from "lodash/pick"; import jsesc from "jsesc"; import colors from "picocolors"; import kebabCase from "lodash/kebabCase"; +import pMap from "p-map"; import * as Typegen from "../typegen"; import type { RouteManifestEntry, RouteManifest } from "../config/routes"; @@ -79,6 +80,7 @@ import { createConfigLoader, resolveEntryFiles, configRouteToBranchRoute, + type PrerenderPaths, } from "../config/config"; import { getOptimizeDepsEntries } from "./optimize-deps-entries"; import { decorateComponentExportsWithProps } from "./with-props"; @@ -346,6 +348,7 @@ const getReactRouterManifestBuildAssets = ( ctx: ReactRouterPluginContext, viteConfig: Vite.ResolvedConfig, viteManifest: Vite.Manifest, + allDynamicCssFiles: Set, entryFilePath: string, route: RouteManifestEntry | null, ): ReactRouterManifest["entry"] & { css: string[] } => { @@ -394,7 +397,22 @@ const getReactRouterManifestBuildAssets = ( : null, chunks .flatMap((e) => e.css ?? []) - .map((href) => `${ctx.publicPath}${href}`), + .map((href) => { + let publicHref = `${ctx.publicPath}${href}`; + // If this CSS file is also dynamically imported anywhere in the + // application, we append a hash to the href so Vite ignores it when + // managing dynamic CSS injection. If we don't do this, Vite's + // dynamic import logic might hold off on inserting a new `link` + // element because it's already in the page, only for React Router + // to remove it when navigating to a new route, resulting in missing + // styles. By appending a hash, Vite doesn't detect that the CSS is + // already in the page and always manages its own `link` element. + // This means that Vite's CSS stays in the page even if the + // route-level CSS is removed from the document. We use a hash here + // because it's a unique `href` value but isn't a unique network + // request and only adds a single character. + return allDynamicCssFiles.has(href) ? `${publicHref}#` : publicHref; + }), ] .flat(1) .filter(isNonNullable), @@ -429,6 +447,59 @@ function resolveDependantChunks( return Array.from(chunks); } +function getAllDynamicCssFiles( + ctx: ReactRouterPluginContext, + viteManifest: Vite.Manifest, +): Set { + let allDynamicCssFiles = new Set(); + + for (let route of Object.values(ctx.reactRouterConfig.routes)) { + let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file); + let entryChunk = resolveChunk( + ctx, + viteManifest, + `${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`, + ); + + if (entryChunk) { + let visitedChunks = new Set(); + + function walk( + chunk: Vite.ManifestChunk, + isDynamicImportContext: boolean, + ) { + if (visitedChunks.has(chunk)) { + return; + } + + visitedChunks.add(chunk); + + if (isDynamicImportContext && chunk.css) { + for (let cssFile of chunk.css) { + allDynamicCssFiles.add(cssFile); + } + } + + if (chunk.dynamicImports) { + for (let dynamicImportKey of chunk.dynamicImports) { + walk(viteManifest[dynamicImportKey], true); + } + } + + if (chunk.imports) { + for (let importKey of chunk.imports) { + walk(viteManifest[importKey], isDynamicImportContext); + } + } + } + + walk(entryChunk, false); + } + } + + return allDynamicCssFiles; +} + function dedupe(array: T[]): T[] { return [...new Set(array)]; } @@ -886,10 +957,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { getClientBuildDirectory(ctx.reactRouterConfig), ); + let allDynamicCssFiles = getAllDynamicCssFiles(ctx, viteManifest); + let entry = getReactRouterManifestBuildAssets( ctx, viteConfig, viteManifest, + allDynamicCssFiles, ctx.entryClientFilePath, null, ); @@ -953,6 +1027,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { ctx, viteConfig, viteManifest, + allDynamicCssFiles, `${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`, route, ), @@ -2084,7 +2159,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { "", ` But other route exports in '${importerShort}' depend on '${id}'.`, "", - " See https://remix.run/docs/en/main/guides/vite#splitting-up-client-and-server-code", + " See https://reactrouter.com/explanation/code-splitting#removal-of-server-code", "", ].join("\n"), ); @@ -2096,7 +2171,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { "", ` '${id}' imported by '${importerShort}'`, "", - " See https://remix.run/docs/en/main/guides/vite#splitting-up-client-and-server-code", + " See https://reactrouter.com/explanation/code-splitting#removal-of-server-code", "", ].join("\n"), ); @@ -2658,11 +2733,12 @@ async function handlePrerender( } let buildRoutes = createPrerenderRoutes(build.routes); - for (let path of build.prerender) { + + let prerenderSinglePath = async (path: string) => { // Ensure we have a leading slash for matching let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/")); if (!matches) { - continue; + return; } // When prerendering a resource route, we don't want to pass along the // `.data` file since we want to prerender the raw Response returned from @@ -2731,7 +2807,15 @@ async function handlePrerender( : undefined, ); } + }; + + let concurrency = 1; + let { prerender } = reactRouterConfig; + if (typeof prerender === "object" && "unstable_concurrency" in prerender) { + concurrency = prerender.unstable_concurrency ?? 1; } + + await pMap(build.prerender, prerenderSinglePath, { concurrency }); } function getStaticPrerenderPaths(routes: DataRouteObject[]) { @@ -2916,33 +3000,49 @@ export async function getPrerenderPaths( routes: GenericRouteManifest, logWarning = false, ): Promise { - let prerenderPaths: string[] = []; - if (prerender != null && prerender !== false) { - let prerenderRoutes = createPrerenderRoutes(routes); - if (prerender === true) { - let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes); - if (logWarning && !ssr && paramRoutes.length > 0) { - console.warn( - colors.yellow( - [ - "⚠️ Paths with dynamic/splat params cannot be prerendered when " + - "using `prerender: true`. You may want to use the `prerender()` " + - "API to prerender the following paths:", - ...paramRoutes.map((p) => " - " + p), - ].join("\n"), - ), - ); - } - prerenderPaths = paths; - } else if (typeof prerender === "function") { - prerenderPaths = await prerender({ - getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths, - }); - } else { - prerenderPaths = prerender || ["/"]; + if (prerender == null || prerender === false) { + return []; + } + + let pathsConfig: PrerenderPaths; + + if (typeof prerender === "object" && "paths" in prerender) { + pathsConfig = prerender.paths; + } else { + pathsConfig = prerender; + } + + if (pathsConfig === false) { + return []; + } + + let prerenderRoutes = createPrerenderRoutes(routes); + + if (pathsConfig === true) { + let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes); + if (logWarning && !ssr && paramRoutes.length > 0) { + console.warn( + colors.yellow( + [ + "⚠️ Paths with dynamic/splat params cannot be prerendered when " + + "using `prerender: true`. You may want to use the `prerender()` " + + "API to prerender the following paths:", + ...paramRoutes.map((p) => " - " + p), + ].join("\n"), + ), + ); } + return paths; } - return prerenderPaths; + + if (typeof pathsConfig === "function") { + let paths = await pathsConfig({ + getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths, + }); + return paths; + } + + return pathsConfig; } // Note: Duplicated from react-router/lib/server-runtime diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 3eef443c6c..f77e6cd742 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 348dc4fb66..8d0ce97e9d 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.9.4", + "version": "7.9.5", "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 069d309d11..30ffa1b332 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + - `@react-router/node@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index fd349d39ea..89e80d26c4 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.9.4", + "version": "7.9.5", "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 0b8d38e5bb..26a9060f82 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-fs-routes/index.ts b/packages/react-router-fs-routes/index.ts index a68afc85e2..11d02ffb89 100644 --- a/packages/react-router-fs-routes/index.ts +++ b/packages/react-router-fs-routes/index.ts @@ -12,7 +12,7 @@ import { normalizeSlashes } from "./normalizeSlashes"; /** * Creates route config from the file system using a convention that matches * [Remix v2's route file - * naming](https://remix.run/docs/en/v2/file-conventions/routes-files), for use + * naming](https://v2.remix.run/docs/file-conventions/routes), for use * within `routes.ts`. */ export async function flatRoutes( diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 606c050dfe..88aaf5f9c7 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.9.4", + "version": "7.9.5", "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 60f8387a04..7079986841 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-node/__tests__/sessions-test.ts b/packages/react-router-node/__tests__/sessions-test.ts index 95446861a5..99b7c7f196 100644 --- a/packages/react-router-node/__tests__/sessions-test.ts +++ b/packages/react-router-node/__tests__/sessions-test.ts @@ -73,7 +73,7 @@ describe("File session storage", () => { session = await getSession(cookie); session.set("user", "mjackson"); expect(session.get("user")).toBe("mjackson"); - debugger; + setCookie = await commitSession(session); session = await getSession(getCookieFromSetCookie(setCookie)); expect(session.get("user")).toBeUndefined(); diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index d995c0702d..4d56e558d4 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.9.4", + "version": "7.9.5", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/sessions/fileStorage.ts b/packages/react-router-node/sessions/fileStorage.ts index 3d2c30ae41..840d463a5f 100644 --- a/packages/react-router-node/sessions/fileStorage.ts +++ b/packages/react-router-node/sessions/fileStorage.ts @@ -26,7 +26,7 @@ interface FileSessionStorageOptions { * The advantage of using this instead of cookie session storage is that * files may contain much more data than cookies. * - * @see https://remix.run/utils/sessions#createfilesessionstorage-node + * @see https://api.reactrouter.com/v7/functions/_react_router_node.createFileSessionStorage */ export function createFileSessionStorage({ cookie, diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index 73ffdbf88e..eb8210fa45 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/remix-config-routes-adapter` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/index.ts b/packages/react-router-remix-routes-option-adapter/index.ts index 77f2582068..219b7a5780 100644 --- a/packages/react-router-remix-routes-option-adapter/index.ts +++ b/packages/react-router-remix-routes-option-adapter/index.ts @@ -11,7 +11,7 @@ export type { DefineRoutesFunction, DefineRouteFunction }; /** * Adapts routes defined using [Remix's `routes` config - * option](https://remix.run/docs/en/v2/file-conventions/vite-config#routes) to + * option](https://v2.remix.run/docs/file-conventions/vite-config#routes) to * React Router's config format, for use within `routes.ts`. */ export async function remixRoutesOptionAdapter( diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 8ed145ee96..04c54023fa 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.9.4", + "version": "7.9.5", "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 48015455bd..fc24c7e195 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.9.5 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.9.5` + - `@react-router/node@7.9.5` + - `@react-router/express@7.9.5` + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 4b0ba29102..366c7438d9 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.9.4", + "version": "7.9.5", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 6ef3ba52ad..f97a0facda 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,42 @@ # `react-router` +## 7.9.5 + +### Patch Changes + +- Move RSCHydratedRouter and utils to `/dom` export. ([#14457](https://github.com/remix-run/react-router/pull/14457)) + +- useRoute: return type-safe `handle` ([#14462](https://github.com/remix-run/react-router/pull/14462)) + + For example: + + ```ts + // app/routes/admin.tsx + const handle = { hello: "world" }; + ``` + + ```ts + // app/routes/some-other-route.tsx + export default function Component() { + const admin = useRoute("routes/admin"); + if (!admin) throw new Error("Not nested within 'routes/admin'"); + console.log(admin.handle); + // ^? { hello: string } + } + ``` + +- Ensure action handlers run for routes with middleware even if no loader is present ([#14443](https://github.com/remix-run/react-router/pull/14443)) + +- Add `unstable_instrumentations` API to allow users to add observablity to their apps by instrumenting route loaders, actions, middlewares, lazy, as well as server-side request handlers and client side navigations/fetches ([#14412](https://github.com/remix-run/react-router/pull/14412)) + + - Framework Mode: + - `entry.server.tsx`: `export const unstable_instrumentations = [...]` + - `entry.client.tsx`: `` + - Data Mode + - `createBrowserRouter(routes, { unstable_instrumentations: [...] })` + + This also adds a new `unstable_pattern` parameter to loaders/actions/middleware which contains the un-interpolated route pattern (i.e., `/blog/:slug`) which is useful for aggregating performance metrics by route + ## 7.9.4 ### Patch Changes diff --git a/packages/react-router/__tests__/router/context-middleware-test.tsx b/packages/react-router/__tests__/router/context-middleware-test.tsx index cea82e235c..ab62e5c132 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.tsx +++ b/packages/react-router/__tests__/router/context-middleware-test.tsx @@ -477,6 +477,71 @@ describe("context/middleware", () => { ]); }); + it("runs middleware even if no loader exists but an action is present", async () => { + let snapshot; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "/parent", + middleware: [ + async ({ context }, next) => { + await next(); + // Grab a snapshot at the end of the upwards middleware chain + snapshot = context.get(orderContext); + }, + getOrderMiddleware(orderContext, "a"), + getOrderMiddleware(orderContext, "b"), + ], + children: [ + { + id: "child", + path: "child", + middleware: [ + getOrderMiddleware(orderContext, "c"), + getOrderMiddleware(orderContext, "d"), + ], + action({ context }) { + context.get(orderContext).push("child action"); + }, + }, + ], + }, + ], + }); + + await router.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + expect(snapshot).toEqual([ + // Action + "a middleware - before next()", + "b middleware - before next()", + "c middleware - before next()", + "d middleware - before next()", + "child action", + "d middleware - after next()", + "c middleware - after next()", + "b middleware - after next()", + "a middleware - after next()", + // Revalidation + "a middleware - before next()", + "b middleware - before next()", + "c middleware - before next()", + "d middleware - before next()", + "d middleware - after next()", + "c middleware - after next()", + "b middleware - after next()", + "a middleware - after next()", + ]); + }); + it("returns result of middleware in client side routers", async () => { let values: unknown[] = []; let consoleSpy = jest diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fca5f7a39..0fdf16c762 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -373,6 +373,7 @@ describe("fetchers", () => { request: new Request("http://localhost/foo", { signal: A.loaders.root.stub.mock.calls[0][0].request.signal, }), + unstable_pattern: expect.any(String), context: {}, }); }); @@ -3373,6 +3374,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3402,6 +3404,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3429,6 +3432,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3456,6 +3460,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3484,6 +3489,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3514,6 +3520,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -3543,6 +3550,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts new file mode 100644 index 0000000000..27374f6875 --- /dev/null +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -0,0 +1,2201 @@ +import { createMemoryRouter } from "../../lib/components"; +import type { StaticHandlerContext } from "../../lib/router/router"; +import { createStaticHandler } from "../../lib/router/router"; +import { + ErrorResponseImpl, + data, + redirect, + type ActionFunction, + type LoaderFunction, + type MiddlewareFunction, + type MiddlewareNextFunction, +} from "../../lib/router/utils"; +import { createRequestHandler } from "../../lib/server-runtime/server"; +import { mockServerBuild } from "../server-runtime/utils"; +import { cleanup, setup } from "./utils/data-router-setup"; +import { createDeferred, createFormData, tick } from "./utils/utils"; + +// Detect any failures inside the router navigate code +afterEach(() => { + cleanup(); +}); + +describe("instrumentation", () => { + describe("client-side router", () => { + it("allows instrumentation of middleware", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + middleware: [ + async (_: unknown, next: MiddlewareNextFunction) => { + spy("start middleware"); + await next(); + spy("end middleware"); + }, + ], + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy.mock.calls).toEqual([["start"], ["start middleware"]]); + await A.loaders.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([ + ["start"], + ["start middleware"], + ["end middleware"], + ["end"], + ]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of loaders", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).toHaveBeenNthCalledWith(1, "start"); + await A.loaders.page.resolve("PAGE"); + expect(spy).toHaveBeenNthCalledWith(2, "end"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of actions", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + action: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy).toHaveBeenNthCalledWith(1, "start"); + await A.actions.page.resolve("PAGE"); + expect(spy).toHaveBeenNthCalledWith(2, "end"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of lazy function", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(lazy) { + spy("start"); + await lazy(); + spy("end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page"); + await tick(); + expect(spy.mock.calls).toEqual([["start"]]); + + await lazyDfd.resolve({ loader: () => "PAGE" }); + await tick(); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of lazy function loaders", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let loaderDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page"); + expect(spy).not.toHaveBeenCalled(); + + await lazyDfd.resolve({ loader: () => loaderDfd.promise }); + expect(spy.mock.calls).toEqual([["start"]]); + + await loaderDfd.resolve("PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + + await tick(); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of lazy function actions", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let actionDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy).not.toHaveBeenCalled(); + + await lazyDfd.resolve({ action: () => actionDfd.promise }); + expect(spy.mock.calls).toEqual([["start"]]); + + await actionDfd.resolve("PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + + await tick(); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "PAGE" }, + }); + }); + + it("does not double-instrument when a static `loader` is used alongside `lazy()`", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy.mock.calls).toEqual([["start"]]); + await lazyDfd.resolve({ action: () => "ACTION", loader: () => "WRONG" }); + await A.loaders.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + spy.mockClear(); + + await t.navigate("/"); + + let C = await t.navigate("/page"); + expect(spy.mock.calls).toEqual([["start"]]); + await C.loaders.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + spy.mockClear(); + + warnSpy.mockRestore(); + }); + + it("does not double-instrument when a static `action` is used alongside `lazy()`", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + action: true, + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([["start"]]); + await lazyDfd.resolve({ action: () => "WRONG" }); + await A.actions.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "PAGE" }, + }); + spy.mockClear(); + + let B = await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([["start"]]); + await B.actions.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([["start"], ["end"]]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "PAGE" }, + }); + spy.mockClear(); + + warnSpy.mockRestore(); + }); + + it("allows instrumentation of lazy object middleware", async () => { + let spy = jest.fn(); + let middlewareDfd = createDeferred(); + let loaderDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: { + middleware: () => middlewareDfd.promise, + loader: () => Promise.resolve(() => loaderDfd.promise), + }, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + "lazy.middleware": async (middleware) => { + spy("start"); + await middleware(); + spy("end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page"); + expect(spy.mock.calls).toEqual([["start"]]); + + await middlewareDfd.resolve([ + async (_: unknown, next: MiddlewareNextFunction) => { + spy("middleware start"); + await next(); + spy("middleware end"); + }, + ]); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start"], + ["end"], + ["middleware start"], + ]); + + await loaderDfd.resolve("PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start"], + ["end"], + ["middleware start"], + ["middleware end"], + ]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of lazy object loaders", async () => { + let spy = jest.fn(); + let loaderDfd = createDeferred(); + let loaderValueDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: { + loader: () => loaderDfd.promise, + }, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + "lazy.loader": async (load) => { + spy("start"); + await load(); + spy("end"); + }, + loader: async (loader) => { + spy("loader start"); + await loader(); + spy("loader end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page"); + await tick(); + expect(spy.mock.calls).toEqual([["start"]]); + + await loaderDfd.resolve(() => loaderValueDfd.promise); + await tick(); + expect(spy.mock.calls).toEqual([["start"], ["end"], ["loader start"]]); + + await loaderValueDfd.resolve("PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start"], + ["end"], + ["loader start"], + ["loader end"], + ]); + + await tick(); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of lazy object actions", async () => { + let spy = jest.fn(); + let actionDfd = createDeferred(); + let actionValueDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: { + action: () => actionDfd.promise, + }, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + "lazy.action": async (load) => { + spy("start"); + await load(); + spy("end"); + }, + action: async (action) => { + spy("action start"); + await action(); + spy("action end"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + await tick(); + expect(spy.mock.calls).toEqual([["start"]]); + + await actionDfd.resolve(() => actionValueDfd.promise); + await tick(); + expect(spy.mock.calls).toEqual([["start"], ["end"], ["action start"]]); + + await actionValueDfd.resolve("PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start"], + ["end"], + ["action start"], + ["action end"], + ]); + + await tick(); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a statically defined route", async () => { + let spy = jest.fn(); + let middlewareDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + middleware: [ + async (_: unknown, next: MiddlewareNextFunction) => { + await middlewareDfd.promise; + return next(); + }, + ], + loader: true, + action: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([["start middleware"]]); + + await middlewareDfd.resolve(undefined); + await tick(); + expect(spy.mock.calls).toEqual([["start middleware"], ["start action"]]); + + await A.actions.page.resolve("ACTION"); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ]); + + await A.loaders.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a lazy function route", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + // Middleware can't be returned from lazy() + middleware: [(_: unknown, next: MiddlewareNextFunction) => next()], + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([["start middleware"]]); + + await lazyDfd.resolve({ + loader: () => "PAGE", + action: () => "ACTION", + }); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a lazy object route", async () => { + let spy = jest.fn(); + let middlewareDfd = createDeferred(); + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: { + middleware: () => middlewareDfd.promise, + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + }, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([]); + + await middlewareDfd.resolve([ + (_: unknown, next: MiddlewareNextFunction) => next(), + ]); + await tick(); + expect(spy.mock.calls).toEqual([["start middleware"]]); + + await actionDfd.resolve(() => "ACTION"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ]); + + await loaderDfd.resolve(() => "PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a statically defined route via patchRoutesOnNavigation", async () => { + let spy = jest.fn(); + let middlewareDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + ], + patchRoutesOnNavigation: ({ path, patch }) => { + if (path === "/page") { + patch(null, [ + { + id: "page", + path: "/page", + middleware: [ + async (_: unknown, next: MiddlewareNextFunction) => { + await middlewareDfd.promise; + return next(); + }, + ], + loader: () => "PAGE", + action: () => "ACTION", + }, + ]); + } + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + await tick(); + await tick(); + expect(spy.mock.calls).toEqual([["start middleware"]]); + + await middlewareDfd.resolve(undefined); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a lazy function route via patchRoutesOnNavigation", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + ], + patchRoutesOnNavigation: ({ path, patch }) => { + if (path === "/page") { + patch(null, [ + { + id: "page", + path: "/page", + // Middleware can't be returned from lazy() + middleware: [ + (_: unknown, next: MiddlewareNextFunction) => next(), + ], + lazy: () => lazyDfd.promise, + }, + ]); + } + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([]); + + await lazyDfd.resolve({ + loader: () => "PAGE", + action: () => "ACTION", + }); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of everything for a lazy object route via patchRoutesOnNavigation", async () => { + let spy = jest.fn(); + let middlewareDfd = createDeferred(); + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let t = setup({ + routes: [ + { + index: true, + }, + ], + patchRoutesOnNavigation: ({ path, patch }) => { + if (path === "/page") { + patch(null, [ + { + id: "page", + path: "/page", + lazy: { + middleware: () => middlewareDfd.promise, + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + }, + }, + ]); + } + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); + }, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); + }, + }, + ], + }); + + await t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy.mock.calls).toEqual([]); + + await middlewareDfd.resolve([ + (_: unknown, next: MiddlewareNextFunction) => next(), + ]); + await tick(); + expect(spy.mock.calls).toEqual([["start middleware"]]); + + await actionDfd.resolve(() => "ACTION"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ]); + + await loaderDfd.resolve(() => "PAGE"); + await tick(); + expect(spy.mock.calls).toEqual([ + ["start middleware"], + ["start action"], + ["end action"], + ["end middleware"], + ["start middleware"], + ["start loader"], + ["end loader"], + ["end middleware"], + ]); + + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { page: "ACTION" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("returns handler-thrown errors out to instrumentation implementations", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("inner-start"); + let { status, error } = await loader(); + spy(`inner-end:${status}:${(error as Error).message}`); + }, + }); + }, + }, + { + route(route) { + route.instrument({ + async loader(loader) { + spy("outer-start"); + let { status, error } = await loader(); + spy(`outer-end:${status}:${(error as Error).message}`); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).toHaveBeenNthCalledWith(1, "outer-start"); + expect(spy).toHaveBeenNthCalledWith(2, "inner-start"); + await A.loaders.page.reject(new Error("broken!")); + expect(spy).toHaveBeenNthCalledWith(3, "inner-end:error:broken!"); + expect(spy).toHaveBeenNthCalledWith(4, "outer-end:error:broken!"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: {}, + errors: { + page: new Error("broken!"), + }, + }); + }); + + it("does not return handler-thrown Responses out to instrumentation implementations", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + { + id: "target", + path: "/target", + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("inner-start"); + let { error } = await loader(); + // Go back to discriminated union + // thrown responses should not be exposed out here + if (error) { + spy("BROKEN"); + } else { + spy("inner-end"); + } + }, + }); + }, + }, + { + route(route) { + route.instrument({ + async loader(loader) { + spy("outer-start"); + let { error } = await loader(); + if (error) { + spy("BROKEN"); + } else { + spy("outer-end"); + } + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).toHaveBeenNthCalledWith(1, "outer-start"); + expect(spy).toHaveBeenNthCalledWith(2, "inner-start"); + await A.loaders.page.reject(redirect("/target")); + expect(spy).toHaveBeenNthCalledWith(3, "inner-end"); + expect(spy).toHaveBeenNthCalledWith(4, "outer-end"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/target" }, + loaderData: {}, + errors: null, + }); + }); + + it("does not return handler-thrown data() out to instrumentation implementations", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + { + id: "target", + path: "/target", + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("inner-start"); + let { error } = await loader(); + // Go back to discriminated union + // thrown responses should not be exposed out here + if (error) { + spy("BROKEN"); + } else { + spy("inner-end"); + } + }, + }); + }, + }, + { + route(route) { + route.instrument({ + async loader(loader) { + spy("outer-start"); + let { error } = await loader(); + if (error) { + spy("BROKEN"); + } else { + spy("outer-end"); + } + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).toHaveBeenNthCalledWith(1, "outer-start"); + expect(spy).toHaveBeenNthCalledWith(2, "inner-start"); + await A.loaders.page.reject(data({ message: "hello" }, { status: 418 })); + expect(spy).toHaveBeenNthCalledWith(3, "inner-end"); + expect(spy).toHaveBeenNthCalledWith(4, "outer-end"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: {}, + errors: { + page: new ErrorResponseImpl(418, "", { message: "hello" }), + }, + }); + }); + + it("swallows and console.errors if an instrumentation function throws before calling the handler", async () => { + let spy = jest.fn(); + let errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader() { + throw new Error("broken!"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).not.toHaveBeenCalled(); + await A.loaders.page.resolve("PAGE"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + errors: null, + }); + expect(errorSpy).toHaveBeenCalledWith( + "An instrumentation function threw an error:", + new Error("broken!"), + ); + errorSpy.mockRestore(); + }); + + it("swallows and warns if an instrumentation function throws after calling the handler", async () => { + let spy = jest.fn(); + let errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + throw new Error("broken!"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + expect(spy).toHaveBeenNthCalledWith(1, "start"); + await A.loaders.page.resolve("PAGE"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + errors: null, + }); + expect(errorSpy).toHaveBeenCalledWith( + "An instrumentation function threw an error:", + new Error("broken!"), + ); + errorSpy.mockRestore(); + }); + + it("waits for handler to finish if you forget to await the handler", async () => { + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + loader(); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + await A.loaders.page.resolve("PAGE"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + errors: null, + }); + expect(A.loaders.page.stub).toHaveBeenCalledTimes(1); + }); + + it("does not let you call handlers more than once", async () => { + let errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + await loader(); + await loader(); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + await A.loaders.page.resolve("PAGE"); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + errors: null, + }); + expect(A.loaders.page.stub).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + "You cannot call instrumented handlers more than once", + ); + errorSpy.mockRestore(); + }); + + it("provides read-only information to instrumentation wrappers", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "slug", + path: "/:slug", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader, info) { + spy(info); + Object.assign(info.params, { extra: "extra" }); + await loader(); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/a"); + await A.loaders.slug.resolve("A"); + let args = spy.mock.calls[0][0]; + expect(args.request.method).toBe("GET"); + expect(args.request.url).toBe("http://localhost/a"); + expect(args.request.url).toBe("http://localhost/a"); + expect(args.request.headers.get).toBeDefined(); + expect(args.request.headers.set).not.toBeDefined(); + expect(args.params).toEqual({ slug: "a", extra: "extra" }); + expect(args.unstable_pattern).toBe("/:slug"); + expect(args.context.get).toBeDefined(); + expect(args.context.set).not.toBeDefined(); + expect(t.router.state.matches[0].params).toEqual({ slug: "a" }); + }); + + it("allows composition of multiple instrumentations", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start inner"); + await loader(); + spy("end inner"); + }, + }); + route.instrument({ + async loader(loader) { + spy("start outer"); + await loader(); + spy("end outer"); + }, + }); + }, + }, + ], + }); + + let A = await t.navigate("/page"); + await A.loaders.page.resolve("PAGE"); + expect(spy.mock.calls).toEqual([ + ["start outer"], + ["start inner"], + ["end inner"], + ["end outer"], + ]); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of navigations", async () => { + let spy = jest.fn(); + let router = createMemoryRouter( + [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: () => "PAGE", + }, + ], + { + unstable_instrumentations: [ + { + router(router) { + router.instrument({ + async navigate(navigate, info) { + spy("start", info); + await navigate(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + + await router.navigate("/page"); + expect(spy.mock.calls).toEqual([ + ["start", { currentUrl: "/", to: "/page" }], + ["end", { currentUrl: "/", to: "/page" }], + ]); + expect(router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + loaderData: { page: "PAGE" }, + }); + }); + + it("allows instrumentation of fetchers", async () => { + let spy = jest.fn(); + let router = createMemoryRouter( + [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: () => "PAGE", + }, + ], + { + unstable_instrumentations: [ + { + router(router) { + router.instrument({ + async fetch(fetch, info) { + spy("start", info); + await fetch(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + + let data: unknown; + router.subscribe((state) => { + data = data ?? state.fetchers.get("key")?.data; + }); + await router.fetch("key", "0", "/page"); + expect(spy.mock.calls).toEqual([ + ["start", { href: "/page", currentUrl: "/", fetcherKey: "key" }], + ["end", { href: "/page", currentUrl: "/", fetcherKey: "key" }], + ]); + expect(router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/" }, + }); + expect(data).toBe("PAGE"); + }); + }); + + describe("static handler", () => { + it("allows instrumentation of lazy", async () => { + let spy = jest.fn(); + let { query } = createStaticHandler( + [ + { + id: "index", + index: true, + lazy: async () => { + spy("lazy"); + return { + loader: () => { + spy("loader"); + return new Response("INDEX"); + }, + }; + }, + }, + ], + { + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }, + ); + + let context = await query(new Request("http://localhost/")); + expect(spy.mock.calls).toEqual([ + ["start"], + ["lazy"], + ["end"], + ["loader"], + ]); + expect(context).toMatchObject({ + location: { pathname: "/" }, + loaderData: { index: "INDEX" }, + }); + spy.mockClear(); + + // Recreate to get a fresh execution of lazy + let { queryRoute } = createStaticHandler( + [ + { + id: "index", + index: true, + lazy: async () => { + spy("lazy"); + return { + loader: () => { + spy("loader"); + return new Response("INDEX"); + }, + }; + }, + }, + ], + { + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }, + ); + let response = await queryRoute(new Request("http://localhost/")); + expect(spy.mock.calls).toEqual([ + ["start"], + ["lazy"], + ["end"], + ["loader"], + ]); + expect(await response.text()).toBe("INDEX"); + }); + + it("allows instrumentation of middleware", async () => { + let spy = jest.fn(); + let { query, queryRoute } = createStaticHandler( + [ + { + id: "index", + index: true, + middleware: [ + async (_: unknown, next: MiddlewareNextFunction) => { + spy("middleware"); + return await next(); + }, + ], + loader: () => { + spy("loader"); + return new Response("INDEX"); + }, + }, + ], + { + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); + }, + }); + }, + }, + ], + }, + ); + + let request = new Request("http://localhost/"); + let response = (await query(request, { + async generateMiddlewareResponse(query) { + let ctx = (await query(request)) as StaticHandlerContext; + return new Response( + JSON.stringify({ + location: ctx.location, + loaderData: ctx.loaderData, + }), + ); + }, + })) as Response; + expect(spy.mock.calls).toEqual([ + ["start"], + ["middleware"], + ["loader"], + ["end"], + ]); + expect(JSON.parse(await response.text())).toMatchObject({ + location: { pathname: "/" }, + loaderData: { index: "INDEX" }, + }); + spy.mockClear(); + + response = await queryRoute(request, { + generateMiddlewareResponse: async (queryRoute) => { + return await queryRoute(request); + }, + }); + expect(spy.mock.calls).toEqual([ + ["start"], + ["middleware"], + ["loader"], + ["end"], + ]); + expect(await response.text()).toBe("INDEX"); + }); + + it("allows instrumentation of loaders", async () => { + let spy = jest.fn(); + let { query, queryRoute } = createStaticHandler( + [ + { + id: "index", + index: true, + loader: () => { + spy("loader"); + return new Response("INDEX"); + }, + }, + ], + { + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, + }, + ], + }, + ); + + let context = await query(new Request("http://localhost/")); + expect(spy.mock.calls).toEqual([["start"], ["loader"], ["end"]]); + expect(context).toMatchObject({ + location: { pathname: "/" }, + loaderData: { index: "INDEX" }, + }); + spy.mockClear(); + + let response = await queryRoute(new Request("http://localhost/")); + expect(spy.mock.calls).toEqual([["start"], ["loader"], ["end"]]); + expect(await response.text()).toBe("INDEX"); + }); + + it("allows instrumentation of actions", async () => { + let spy = jest.fn(); + let { query, queryRoute } = createStaticHandler( + [ + { + id: "index", + index: true, + action: () => { + spy("action"); + return new Response("INDEX"); + }, + }, + ], + { + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); + }, + }, + ], + }, + ); + + let context = await query( + new Request("http://localhost/", { method: "post", body: "data" }), + ); + expect(spy.mock.calls).toEqual([["start"], ["action"], ["end"]]); + expect(context).toMatchObject({ + location: { pathname: "/" }, + actionData: { index: "INDEX" }, + }); + spy.mockClear(); + + let response = await queryRoute( + new Request("http://localhost/", { method: "post", body: "data" }), + ); + expect(spy.mock.calls).toEqual([["start"], ["action"], ["end"]]); + expect(await response.text()).toBe("INDEX"); + }); + }); + + describe("request handler", () => { + it("allows instrumentation of the request handler", async () => { + let spy = jest.fn(); + let build = mockServerBuild( + { + root: { + path: "", + loader: () => { + spy("loader"); + return "ROOT"; + }, + default: () => "COMPONENT", + }, + }, + { + handleDocumentRequest(request) { + return new Response(`${request.method} ${request.url} COMPONENT`); + }, + unstable_instrumentations: [ + { + handler(handler) { + handler.instrument({ + async request(handler, info) { + spy("start", info); + await handler(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + let handler = createRequestHandler(build); + let response = await handler(new Request("http://localhost/"), {}); + + expect(await response.text()).toBe("GET http://localhost/ COMPONENT"); + expect(spy.mock.calls).toEqual([ + [ + "start", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + context: {}, + }, + ], + ["loader"], + [ + "end", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + context: {}, + }, + ], + ]); + }); + + it("allows instrumentation of middleware", async () => { + let spy = jest.fn(); + let build = mockServerBuild( + { + root: { + path: "/", + middleware: [ + (_: unknown, next: MiddlewareNextFunction) => { + spy("middleware"); + return next(); + }, + ], + loader: () => { + spy("loader"); + return "ROOT"; + }, + default: () => "COMPONENT", + }, + }, + { + future: { + v8_middleware: true, + }, + handleDocumentRequest(request) { + return new Response(`${request.method} ${request.url} COMPONENT`); + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware, info) { + spy("start", info); + await middleware(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + let handler = createRequestHandler(build); + let response = await handler(new Request("http://localhost/")); + + expect(await response.text()).toBe("GET http://localhost/ COMPONENT"); + expect(spy.mock.calls).toEqual([ + [ + "start", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ["middleware"], + ["loader"], + [ + "end", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ]); + }); + + it("allows instrumentation of loaders", async () => { + let spy = jest.fn(); + let build = mockServerBuild( + { + root: { + path: "/", + loader: () => { + spy("loader"); + return "ROOT"; + }, + default: () => "COMPONENT", + }, + }, + { + handleDocumentRequest(request) { + return new Response(`${request.method} ${request.url} COMPONENT`); + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader, info) { + spy("start", info); + await loader(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + let handler = createRequestHandler(build); + let response = await handler(new Request("http://localhost/")); + + expect(await response.text()).toBe("GET http://localhost/ COMPONENT"); + expect(spy.mock.calls).toEqual([ + [ + "start", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: {}, + }, + ], + ["loader"], + [ + "end", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: {}, + }, + ], + ]); + }); + + it("allows instrumentation of actions", async () => { + let spy = jest.fn(); + let build = mockServerBuild( + { + root: { + path: "/", + action: () => { + spy("action"); + return "ROOT"; + }, + default: () => "COMPONENT", + }, + }, + { + handleDocumentRequest(request) { + return new Response(`${request.method} ${request.url} COMPONENT`); + }, + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action, info) { + spy("start", info); + await action(); + spy("end", info); + }, + }); + }, + }, + ], + }, + ); + let handler = createRequestHandler(build); + let response = await handler( + new Request("http://localhost/", { method: "post", body: "data" }), + ); + + expect(await response.text()).toBe("POST http://localhost/ COMPONENT"); + expect(spy.mock.calls).toEqual([ + [ + "start", + { + request: { + method: "POST", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: {}, + }, + ], + ["action"], + [ + "end", + { + request: { + method: "POST", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + unstable_pattern: "/", + context: {}, + }, + ], + ]); + }); + }); +}); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index 704d7eff3f..df63df71b3 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -1503,6 +1503,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks", { signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal, }), + unstable_pattern: "/tasks", context: {}, }); @@ -1512,6 +1513,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks/1", { signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal, }), + unstable_pattern: "/tasks/:id", context: {}, }); @@ -1521,6 +1523,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks?foo=bar", { signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal, }), + unstable_pattern: "/tasks", context: {}, }); @@ -1532,6 +1535,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks?foo=bar", { signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal, }), + unstable_pattern: "/tasks", context: {}, }); @@ -1930,6 +1934,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: "/tasks", context: {}, }); @@ -1974,6 +1979,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); // Assert request internals, cannot do a deep comparison above since some @@ -2007,6 +2013,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/__tests__/router/submission-test.ts b/packages/react-router/__tests__/router/submission-test.ts index a89bb430e1..7cc38b1c31 100644 --- a/packages/react-router/__tests__/router/submission-test.ts +++ b/packages/react-router/__tests__/router/submission-test.ts @@ -948,6 +948,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -982,6 +983,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -1014,6 +1016,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -1118,6 +1121,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -1156,6 +1160,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); @@ -1191,6 +1196,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + unstable_pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index 0a2dc3a9cc..dffbd34c95 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -2,9 +2,9 @@ import type { InitialEntry } from "../../../lib/router/history"; import type { Fetcher, RouterFetchOptions, - HydrationState, Router, RouterNavigateOptions, + RouterInit, } from "../../../lib/router/router"; import type { AgnosticDataRouteObject, @@ -19,7 +19,6 @@ import { import type { AgnosticIndexRouteObject, AgnosticNonIndexRouteObject, - DataStrategyFunction, } from "../../../lib/router/utils"; import { matchRoutes, @@ -34,7 +33,13 @@ import { isRedirect, tick } from "./utils"; // by our test harness export type TestIndexRouteObject = Pick< AgnosticIndexRouteObject, - "id" | "index" | "path" | "shouldRevalidate" | "handle" | "lazy" + | "id" + | "index" + | "path" + | "shouldRevalidate" + | "handle" + | "lazy" + | "middleware" > & { loader?: boolean; action?: boolean; @@ -43,7 +48,13 @@ export type TestIndexRouteObject = Pick< export type TestNonIndexRouteObject = Pick< AgnosticNonIndexRouteObject, - "id" | "index" | "path" | "shouldRevalidate" | "handle" | "lazy" + | "id" + | "index" + | "path" + | "shouldRevalidate" + | "handle" + | "lazy" + | "middleware" > & { loader?: boolean; action?: boolean; @@ -134,14 +145,10 @@ export const TASK_ROUTES: TestRouteObject[] = [ }, ]; -type SetupOpts = { +type SetupOpts = Omit & { routes: TestRouteObject[]; - basename?: string; initialEntries?: InitialEntry[]; initialIndex?: number; - hydrationRouteProperties?: string[]; - hydrationData?: HydrationState; - dataStrategy?: DataStrategyFunction; }; // We use a slightly modified version of createDeferred here that includes the @@ -202,12 +209,9 @@ export function getFetcherData(router: Router) { export function setup({ routes, - basename, initialEntries, initialIndex, - hydrationRouteProperties, - hydrationData, - dataStrategy, + ...routerInit }: SetupOpts) { let guid = 0; // Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId. @@ -318,13 +322,10 @@ export function setup({ jest.spyOn(history, "push"); jest.spyOn(history, "replace"); currentRouter = createRouter({ - basename, history, routes: enhanceRoutes(routes), - hydrationRouteProperties, - hydrationData, window: testWindow, - dataStrategy: dataStrategy, + ...routerInit, }); let fetcherData = getFetcherData(currentRouter); diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts index ec5634255a..10771ca1dc 100644 --- a/packages/react-router/__tests__/server-runtime/utils.ts +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -10,7 +10,12 @@ import type { } from "../../lib/server-runtime/build"; import type { HeadersFunction } from "../../lib/dom/ssr/routeModules"; import type { EntryRoute } from "../../lib/dom/ssr/routes"; -import type { ActionFunction, LoaderFunction } from "../../lib/router/utils"; +import type { + ActionFunction, + LoaderFunction, + MiddlewareFunction, +} from "../../lib/router/utils"; +import type { unstable_ServerInstrumentation } from "../../lib/router/instrumentation"; export function mockServerBuild( routes: Record< @@ -24,12 +29,14 @@ export function mockServerBuild( action?: ActionFunction; headers?: HeadersFunction; loader?: LoaderFunction; + middleware?: MiddlewareFunction[]; } >, opts: { future?: Partial; handleError?: HandleErrorFunction; handleDocumentRequest?: HandleDocumentRequestFunction; + unstable_instrumentations?: unstable_ServerInstrumentation[]; } = {}, ): ServerBuild { return { @@ -91,6 +98,7 @@ export function mockServerBuild( ), handleDataRequest: jest.fn(async (response) => response), handleError: opts.handleError, + unstable_instrumentations: opts.unstable_instrumentations, }, }, routes: Object.entries(routes).reduce( @@ -104,8 +112,8 @@ export function mockServerBuild( default: config.default, ErrorBoundary: config.ErrorBoundary, action: config.action, - headers: config.headers, loader: config.loader, + middleware: config.middleware, }, }; return { diff --git a/packages/react-router/dom-export.ts b/packages/react-router/dom-export.ts index 4b1e0a5c4d..b5602b7026 100644 --- a/packages/react-router/dom-export.ts +++ b/packages/react-router/dom-export.ts @@ -8,3 +8,19 @@ export type { RouterProviderProps } from "./lib/dom-export/dom-router-provider"; export { RouterProvider } from "./lib/dom-export/dom-router-provider"; export type { HydratedRouterProps } from "./lib/dom-export/hydrated-router"; export { HydratedRouter } from "./lib/dom-export/hydrated-router"; + +// RSC +export { + createCallServer as unstable_createCallServer, + RSCHydratedRouter as unstable_RSCHydratedRouter, +} from "./lib/rsc/browser"; +export { getRSCStream as unstable_getRSCStream } from "./lib/rsc/html-stream/browser"; + +export type { + DecodeActionFunction as unstable_DecodeActionFunction, + DecodeFormStateFunction as unstable_DecodeFormStateFunction, + DecodeReplyFunction as unstable_DecodeReplyFunction, + RSCManifestPayload as unstable_RSCManifestPayload, + RSCPayload as unstable_RSCPayload, + RSCRenderPayload as unstable_RSCRenderPayload, +} from "./lib/rsc/server.rsc"; diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index fe3c25cea6..dc5c81704a 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -61,6 +61,14 @@ export { createPath, parsePath, } from "./lib/router/history"; +export type { + unstable_ServerInstrumentation, + unstable_ClientInstrumentation, + unstable_InstrumentRequestHandlerFunction, + unstable_InstrumentRouterFunction, + unstable_InstrumentRouteFunction, + unstable_InstrumentationHandlerResult, +} from "./lib/router/instrumentation"; export { IDLE_NAVIGATION, IDLE_FETCHER, @@ -297,10 +305,6 @@ export type { EncodeReplyFunction as unstable_EncodeReplyFunction, RSCHydratedRouterProps as unstable_RSCHydratedRouterProps, } from "./lib/rsc/browser"; -export { - createCallServer as unstable_createCallServer, - RSCHydratedRouter as unstable_RSCHydratedRouter, -} from "./lib/rsc/browser"; export type { SSRCreateFromReadableStreamFunction as unstable_SSRCreateFromReadableStreamFunction, RSCStaticRouterProps as unstable_RSCStaticRouterProps, @@ -309,7 +313,6 @@ export { routeRSCServerRequest as unstable_routeRSCServerRequest, RSCStaticRouter as unstable_RSCStaticRouter, } from "./lib/rsc/server.ssr"; -export { getRSCStream as unstable_getRSCStream } from "./lib/rsc/html-stream/browser"; export { RSCDefaultRootErrorBoundary as UNSAFE_RSCDefaultRootErrorBoundary } from "./lib/rsc/errorBoundaries"; // Re-export of RSC types diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 6edaaf0ab8..1f20af4099 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -68,6 +68,12 @@ import { } from "./hooks"; import type { ViewTransition } from "./dom/global"; import { warnOnce } from "./server-runtime/warnings"; +import type { + unstable_ClientInstrumentation, + unstable_InstrumentRouteFunction, + unstable_InstrumentRouterFunction, +} from "./router/instrumentation"; +import { instrumentClientSideRouter } from "./router/instrumentation"; export function mapRouteProperties(route: RouteObject) { let updates: Partial & { hasErrorBoundary: boolean } = { @@ -168,6 +174,55 @@ export interface MemoryRouterOpts { * Index of `initialEntries` the application should initialize to */ initialIndex?: number; + /** + * Array of instrumentation objects allowing you to instrument the router and + * individual routes prior to router initialization (and on any subsequently + * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is + * mostly useful for observability such as wrapping navigations, fetches, + * as well as route loaders/actions/middlewares with logging and/or performance + * tracing. + * + * ```tsx + * let router = createBrowserRouter(routes, { + * unstable_instrumentations: [logging] + * }); + * + * + * let logging = { + * router({ instrument }) { + * instrument({ + * navigate: (impl, info) => logExecution(`navigate ${info.to}`, impl), + * fetch: (impl, info) => logExecution(`fetch ${info.to}`, impl) + * }); + * }, + * route({ instrument, id }) { + * instrument({ + * middleware: (impl, info) => logExecution( + * `middleware ${info.request.url} (route ${id})`, + * impl + * ), + * loader: (impl, info) => logExecution( + * `loader ${info.request.url} (route ${id})`, + * impl + * ), + * action: (impl, info) => logExecution( + * `action ${info.request.url} (route ${id})`, + * impl + * ), + * }) + * } + * }; + * + * async function logExecution(label: string, impl: () => Promise) { + * let start = performance.now(); + * console.log(`start ${label}`); + * await impl(); + * let duration = Math.round(performance.now() - start); + * console.log(`end ${label} (${duration}ms)`); + * } + * ``` + */ + unstable_instrumentations?: unstable_ClientInstrumentation[]; /** * Override the default data strategy of loading in parallel. * Only intended for advanced usage. @@ -196,6 +251,7 @@ export interface MemoryRouterOpts { * @param {MemoryRouterOpts.hydrationData} opts.hydrationData n/a * @param {MemoryRouterOpts.initialEntries} opts.initialEntries n/a * @param {MemoryRouterOpts.initialIndex} opts.initialIndex n/a + * @param {MemoryRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a * @param {MemoryRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a * @returns An initialized {@link DataRouter} to pass to {@link RouterProvider | ``} */ @@ -217,6 +273,7 @@ export function createMemoryRouter( mapRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, + unstable_instrumentations: opts?.unstable_instrumentations, }).initialize(); } diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index c6429c82f1..ba2c81012c 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -27,6 +27,7 @@ import { } from "react-router"; import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; +import type { unstable_ClientInstrumentation } from "../router/instrumentation"; type SSRInfo = { context: NonNullable<(typeof window)["__reactRouterContext"]>; @@ -78,8 +79,10 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, + unstable_instrumentations, }: { getContext?: RouterInit["getContext"]; + unstable_instrumentations?: unstable_ClientInstrumentation[]; }): DataRouter { initSsrInfo(); @@ -172,6 +175,7 @@ function createHydratedRouter({ getContext, hydrationData, hydrationRouteProperties, + unstable_instrumentations, mapRouteProperties, future: { middleware: ssrInfo.context.future.v8_middleware, @@ -192,6 +196,7 @@ function createHydratedRouter({ ssrInfo.context.basename, ), }); + ssrInfo.router = router; // We can call initialize() immediately if the router doesn't have any @@ -217,12 +222,64 @@ function createHydratedRouter({ */ export interface HydratedRouterProps { /** - * Context object to be passed through to {@link createBrowserRouter} and made - * available to + * Context factory function to be passed through to {@link createBrowserRouter}. + * This function will be called to create a fresh `context` instance on each + * navigation/fetch and made available to * [`clientAction`](../../start/framework/route-module#clientAction)/[`clientLoader`](../../start/framework/route-module#clientLoader) - * functions + * functions. */ getContext?: RouterInit["getContext"]; + /** + * Array of instrumentation objects allowing you to instrument the router and + * individual routes prior to router initialization (and on any subsequently + * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is + * mostly useful for observability such as wrapping navigations, fetches, + * as well as route loaders/actions/middlewares with logging and/or performance + * tracing. + * + * ```tsx + * startTransition(() => { + * hydrateRoot( + * document, + * + * ); + * }); + * + * const logging = { + * router({ instrument }) { + * instrument({ + * navigate: (impl, { to }) => logExecution(`navigate ${to}`, impl), + * fetch: (impl, { to }) => logExecution(`fetch ${to}`, impl) + * }); + * }, + * route({ instrument, id }) { + * instrument({ + * middleware: (impl, { request }) => logExecution( + * `middleware ${request.url} (route ${id})`, + * impl + * ), + * loader: (impl, { request }) => logExecution( + * `loader ${request.url} (route ${id})`, + * impl + * ), + * action: (impl, { request }) => logExecution( + * `action ${request.url} (route ${id})`, + * impl + * ), + * }) + * } + * }; + * + * async function logExecution(label: string, impl: () => Promise) { + * let start = performance.now(); + * console.log(`start ${label}`); + * await impl(); + * let duration = Math.round(performance.now() - start); + * console.log(`end ${label} (${duration}ms)`); + * } + * ``` + */ + unstable_instrumentations?: unstable_ClientInstrumentation[]; /** * An error handler function that will be called for any loader/action/render * errors that are encountered in your application. This is useful for @@ -259,6 +316,7 @@ export function HydratedRouter(props: HydratedRouterProps) { if (!router) { router = createHydratedRouter({ getContext: props.getContext, + unstable_instrumentations: props.unstable_instrumentations, }); } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index ecbe52374e..cff63d829d 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -95,6 +95,7 @@ import { useRouteId, } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; +import type { unstable_ClientInstrumentation } from "../router/instrumentation"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff @@ -235,6 +236,55 @@ export interface DOMRouterOpts { * ``` */ hydrationData?: HydrationState; + /** + * Array of instrumentation objects allowing you to instrument the router and + * individual routes prior to router initialization (and on any subsequently + * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is + * mostly useful for observability such as wrapping navigations, fetches, + * as well as route loaders/actions/middlewares with logging and/or performance + * tracing. + * + * ```tsx + * let router = createBrowserRouter(routes, { + * unstable_instrumentations: [logging] + * }); + * + * + * let logging = { + * router({ instrument }) { + * instrument({ + * navigate: (impl, info) => logExecution(`navigate ${info.to}`, impl), + * fetch: (impl, info) => logExecution(`fetch ${info.to}`, impl) + * }); + * }, + * route({ instrument, id }) { + * instrument({ + * middleware: (impl, info) => logExecution( + * `middleware ${info.request.url} (route ${id})`, + * impl + * ), + * loader: (impl, info) => logExecution( + * `loader ${info.request.url} (route ${id})`, + * impl + * ), + * action: (impl, info) => logExecution( + * `action ${info.request.url} (route ${id})`, + * impl + * ), + * }) + * } + * }; + * + * async function logExecution(label: string, impl: () => Promise) { + * let start = performance.now(); + * console.log(`start ${label}`); + * await impl(); + * let duration = Math.round(performance.now() - start); + * console.log(`end ${label} (${duration}ms)`); + * } + * ``` + */ + unstable_instrumentations?: unstable_ClientInstrumentation[]; /** * Override the default data strategy of running loaders in parallel. * See {@link DataStrategyFunction}. @@ -741,6 +791,7 @@ export interface DOMRouterOpts { * @param {DOMRouterOpts.future} opts.future n/a * @param {DOMRouterOpts.getContext} opts.getContext n/a * @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a + * @param {DOMRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a * @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a * @param {DOMRouterOpts.window} opts.window n/a * @returns An initialized {@link DataRouter| data router} to pass to {@link RouterProvider | ``} @@ -761,12 +812,13 @@ export function createBrowserRouter( dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, + unstable_instrumentations: opts?.unstable_instrumentations, }).initialize(); } /** * Create a new {@link DataRouter| data router} that manages the application - * path via the URL [`hash`]https://developer.mozilla.org/en-US/docs/Web/API/URL/hash). + * path via the URL [`hash`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash). * * @public * @category Data Routers @@ -777,6 +829,7 @@ export function createBrowserRouter( * @param {DOMRouterOpts.future} opts.future n/a * @param {DOMRouterOpts.getContext} opts.getContext n/a * @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a + * @param {DOMRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a * @param {DOMRouterOpts.dataStrategy} opts.dataStrategy n/a * @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a * @param {DOMRouterOpts.window} opts.window n/a @@ -798,6 +851,7 @@ export function createHashRouter( dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, + unstable_instrumentations: opts?.unstable_instrumentations, }).initialize(); } diff --git a/packages/react-router/lib/dom/ssr/routeModules.ts b/packages/react-router/lib/dom/ssr/routeModules.ts index c06e8b617c..e8926d46b4 100644 --- a/packages/react-router/lib/dom/ssr/routeModules.ts +++ b/packages/react-router/lib/dom/ssr/routeModules.ts @@ -119,7 +119,7 @@ export type LayoutComponent = ComponentType<{ * A function that defines `` tags to be inserted into the `` of * the document on route transitions. * - * @see https://remix.run/route/meta + * @see https://reactrouter.com/start/framework/route-module#meta */ export interface LinksFunction { (): LinkDescriptor[]; @@ -267,7 +267,7 @@ export type RouteComponent = ComponentType<{}>; /** * An arbitrary object that is associated with a route. * - * @see https://remix.run/route/handle + * @see https://reactrouter.com/how-to/using-handle */ export type RouteHandle = unknown; diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 7c1f773a6d..577cdf0624 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -340,7 +340,7 @@ export function createClientRoutes( (routeModule.clientLoader?.hydrate === true || !route.hasLoader); dataRoute.loader = async ( - { request, params, context }: LoaderFunctionArgs, + { request, params, context, unstable_pattern }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -358,6 +358,7 @@ export function createClientRoutes( request, params, context, + unstable_pattern, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -393,7 +394,7 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context }: ActionFunctionArgs, + { request, params, context, unstable_pattern }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -412,6 +413,7 @@ export function createClientRoutes( request, params, context, + unstable_pattern, async serverAction() { preventInvalidServerHandlerCall("action", route); return fetchServerAction(singleFetch); diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 259f75ace4..07035f3a5c 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1855,6 +1855,9 @@ type UseRouteResult = never; type UseRoute = { + handle: RouteId extends keyof RouteModules + ? RouteModules[RouteId]["handle"] + : unknown; loaderData: RouteId extends keyof RouteModules ? GetLoaderData | undefined : unknown; @@ -1871,11 +1874,12 @@ export function useRoute( ); const id: keyof RouteModules = args[0] ?? currentRouteId; - const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); + const state = useDataRouterState(DataRouterStateHook.UseRoute); const route = state.matches.find(({ route }) => route.id === id); if (route === undefined) return undefined as UseRouteResult; return { + handle: route.route.handle, loaderData: state.loaderData[id], actionData: state.actionData?.[id], } as UseRouteResult; diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts new file mode 100644 index 0000000000..630f485b83 --- /dev/null +++ b/packages/react-router/lib/router/instrumentation.ts @@ -0,0 +1,525 @@ +import type { AppLoadContext } from "../server-runtime/data"; +import type { RequestHandler } from "../server-runtime/server"; +import type { MiddlewareEnabled } from "../types/future"; +import { createPath, invariant } from "./history"; +import type { Router } from "./router"; +import type { + ActionFunctionArgs, + AgnosticDataRouteObject, + FormEncType, + HTMLFormMethod, + LazyRouteObject, + LoaderFunctionArgs, + MaybePromise, + MiddlewareFunction, + RouterContext, + RouterContextProvider, +} from "./utils"; + +// Public APIs +export type unstable_ServerInstrumentation = { + handler?: unstable_InstrumentRequestHandlerFunction; + route?: unstable_InstrumentRouteFunction; +}; + +export type unstable_ClientInstrumentation = { + router?: unstable_InstrumentRouterFunction; + route?: unstable_InstrumentRouteFunction; +}; + +export type unstable_InstrumentRequestHandlerFunction = ( + handler: InstrumentableRequestHandler, +) => void; + +export type unstable_InstrumentRouterFunction = ( + router: InstrumentableRouter, +) => void; + +export type unstable_InstrumentRouteFunction = ( + route: InstrumentableRoute, +) => void; + +export type unstable_InstrumentationHandlerResult = + | { status: "success"; error: undefined } + | { status: "error"; error: Error }; + +// Shared +type InstrumentFunction = ( + handler: () => Promise, + info: T, +) => Promise; + +type InstrumentationInfo = + | RouteLazyInstrumentationInfo + | RouteHandlerInstrumentationInfo + | RouterNavigationInstrumentationInfo + | RouterFetchInstrumentationInfo + | RequestHandlerInstrumentationInfo; + +type ReadonlyRequest = { + method: string; + url: string; + headers: Pick; +}; + +type ReadonlyContext = MiddlewareEnabled extends true + ? Pick + : Readonly; + +// Route Instrumentation +type InstrumentableRoute = { + id: string; + index: boolean | undefined; + path: string | undefined; + instrument(instrumentations: RouteInstrumentations): void; +}; + +type RouteInstrumentations = { + lazy?: InstrumentFunction; + "lazy.loader"?: InstrumentFunction; + "lazy.action"?: InstrumentFunction; + "lazy.middleware"?: InstrumentFunction; + middleware?: InstrumentFunction; + loader?: InstrumentFunction; + action?: InstrumentFunction; +}; + +type RouteLazyInstrumentationInfo = undefined; + +type RouteHandlerInstrumentationInfo = Readonly<{ + request: ReadonlyRequest; + params: LoaderFunctionArgs["params"]; + unstable_pattern: string; + context: ReadonlyContext; +}>; + +// Router Instrumentation +type InstrumentableRouter = { + instrument(instrumentations: RouterInstrumentations): void; +}; + +type RouterInstrumentations = { + navigate?: InstrumentFunction; + fetch?: InstrumentFunction; +}; + +type RouterNavigationInstrumentationInfo = Readonly<{ + to: string | number; + currentUrl: string; + formMethod?: HTMLFormMethod; + formEncType?: FormEncType; + formData?: FormData; + body?: any; +}>; + +type RouterFetchInstrumentationInfo = Readonly<{ + href: string; + currentUrl: string; + fetcherKey: string; + formMethod?: HTMLFormMethod; + formEncType?: FormEncType; + formData?: FormData; + body?: any; +}>; + +// Request Handler Instrumentation +type InstrumentableRequestHandler = { + instrument(instrumentations: RequestHandlerInstrumentations): void; +}; + +type RequestHandlerInstrumentations = { + request?: InstrumentFunction; +}; + +type RequestHandlerInstrumentationInfo = Readonly<{ + request: ReadonlyRequest; + context: ReadonlyContext | undefined; +}>; + +const UninstrumentedSymbol = Symbol("Uninstrumented"); + +export function getRouteInstrumentationUpdates( + fns: unstable_InstrumentRouteFunction[], + route: Readonly, +) { + let aggregated: { + lazy: InstrumentFunction[]; + "lazy.loader": InstrumentFunction[]; + "lazy.action": InstrumentFunction[]; + "lazy.middleware": InstrumentFunction[]; + middleware: InstrumentFunction[]; + loader: InstrumentFunction[]; + action: InstrumentFunction[]; + } = { + lazy: [], + "lazy.loader": [], + "lazy.action": [], + "lazy.middleware": [], + middleware: [], + loader: [], + action: [], + }; + + fns.forEach((fn) => + fn({ + id: route.id, + index: route.index, + path: route.path, + instrument(i) { + let keys = Object.keys(aggregated) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } + }, + }), + ); + + let updates: { + middleware?: AgnosticDataRouteObject["middleware"]; + loader?: AgnosticDataRouteObject["loader"]; + action?: AgnosticDataRouteObject["action"]; + lazy?: AgnosticDataRouteObject["lazy"]; + } = {}; + + // Instrument lazy functions + if (typeof route.lazy === "function" && aggregated.lazy.length > 0) { + let instrumented = wrapImpl(aggregated.lazy, route.lazy, () => undefined); + if (instrumented) { + updates.lazy = instrumented as AgnosticDataRouteObject["lazy"]; + } + } + + // Instrument the lazy object format + if (typeof route.lazy === "object") { + let lazyObject: LazyRouteObject = route.lazy; + (["middleware", "loader", "action"] as const).forEach((key) => { + let lazyFn = lazyObject[key]; + let instrumentations = aggregated[`lazy.${key}`]; + if (typeof lazyFn === "function" && instrumentations.length > 0) { + let instrumented = wrapImpl(instrumentations, lazyFn, () => undefined); + if (instrumented) { + updates.lazy = Object.assign(updates.lazy || {}, { + [key]: instrumented, + }); + } + } + }); + } + + // Instrument loader/action functions + (["loader", "action"] as const).forEach((key) => { + let handler = route[key]; + if (typeof handler === "function" && aggregated[key].length > 0) { + // @ts-expect-error + let original = handler[UninstrumentedSymbol] ?? handler; + let instrumented = wrapImpl(aggregated[key], original, (...args) => + getHandlerInfo(args[0] as LoaderFunctionArgs | ActionFunctionArgs), + ); + if (instrumented) { + // @ts-expect-error + instrumented[UninstrumentedSymbol] = original; + updates[key] = instrumented; + } + } + }); + + // Instrument middleware functions + if ( + route.middleware && + route.middleware.length > 0 && + aggregated.middleware.length > 0 + ) { + updates.middleware = route.middleware.map((middleware) => { + // @ts-expect-error + let original = middleware[UninstrumentedSymbol] ?? middleware; + let instrumented = wrapImpl(aggregated.middleware, original, (...args) => + getHandlerInfo(args[0] as Parameters[0]), + ); + if (instrumented) { + // @ts-expect-error + instrumented[UninstrumentedSymbol] = original; + return instrumented; + } + return middleware; + }); + } + + return updates; +} + +export function instrumentClientSideRouter( + router: Router, + fns: unstable_InstrumentRouterFunction[], +): Router { + let aggregated: { + navigate: InstrumentFunction[]; + fetch: InstrumentFunction[]; + } = { + navigate: [], + fetch: [], + }; + + fns.forEach((fn) => + fn({ + instrument(i) { + let keys = Object.keys(i) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } + }, + }), + ); + + if (aggregated.navigate.length > 0) { + // @ts-expect-error + let navigate = router.navigate[UninstrumentedSymbol] ?? router.navigate; + let instrumentedNavigate = wrapImpl( + aggregated.navigate, + navigate, + (...args) => { + let [to, opts] = args as Parameters; + return { + to: + typeof to === "number" || typeof to === "string" + ? to + : to + ? createPath(to) + : ".", + ...getRouterInfo(router, opts ?? {}), + } satisfies RouterNavigationInstrumentationInfo; + }, + ) as Router["navigate"]; + if (instrumentedNavigate) { + // @ts-expect-error + instrumentedNavigate[UninstrumentedSymbol] = navigate; + router.navigate = instrumentedNavigate; + } + } + + if (aggregated.fetch.length > 0) { + // @ts-expect-error + let fetch = router.fetch[UninstrumentedSymbol] ?? router.fetch; + let instrumentedFetch = wrapImpl(aggregated.fetch, fetch, (...args) => { + let [key, , href, opts] = args as Parameters; + return { + href: href ?? ".", + fetcherKey: key, + ...getRouterInfo(router, opts ?? {}), + } satisfies RouterFetchInstrumentationInfo; + }) as Router["fetch"]; + if (instrumentedFetch) { + // @ts-expect-error + instrumentedFetch[UninstrumentedSymbol] = fetch; + router.fetch = instrumentedFetch; + } + } + + return router; +} + +export function instrumentHandler( + handler: RequestHandler, + fns: unstable_InstrumentRequestHandlerFunction[], +): RequestHandler { + let aggregated: { + request: InstrumentFunction[]; + } = { + request: [], + }; + + fns.forEach((fn) => + fn({ + instrument(i) { + let keys = Object.keys(i) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } + }, + }), + ); + + let instrumentedHandler = handler; + + if (aggregated.request.length > 0) { + instrumentedHandler = wrapImpl(aggregated.request, handler, (...args) => { + let [request, context] = args as Parameters; + return { + request: getReadonlyRequest(request), + context: context != null ? getReadonlyContext(context) : context, + } satisfies RequestHandlerInstrumentationInfo; + }) as RequestHandler; + } + + return instrumentedHandler; +} + +function wrapImpl( + impls: InstrumentFunction[], + handler: (...args: any[]) => MaybePromise, + getInfo: (...args: unknown[]) => T, +) { + if (impls.length === 0) { + return null; + } + return async (...args: unknown[]) => { + let result = await recurseRight( + impls, + getInfo(...args), + () => handler(...args), + impls.length - 1, + ); + if (result.type === "error") { + throw result.value; + } + return result.value; + }; +} + +type RecurseResult = { type: "success" | "error"; value: unknown }; + +async function recurseRight( + impls: InstrumentFunction[], + info: T, + handler: () => MaybePromise, + index: number, +): Promise { + let impl = impls[index]; + let result: RecurseResult | undefined; + if (!impl) { + try { + let value = await handler(); + result = { type: "success", value }; + } catch (e) { + result = { type: "error", value: e }; + } + } else { + // If they forget to call the handler, or if they throw before calling the + // handler, we need to ensure the handlers still gets called + let handlerPromise: ReturnType | undefined = undefined; + let callHandler = + async (): Promise => { + if (handlerPromise) { + console.error("You cannot call instrumented handlers more than once"); + } else { + handlerPromise = recurseRight(impls, info, handler, index - 1); + } + result = await handlerPromise; + invariant(result, "Expected a result"); + if (result.type === "error" && result.value instanceof Error) { + return { status: "error", error: result.value }; + } + return { status: "success", error: undefined }; + }; + + try { + await impl(callHandler, info); + } catch (e) { + console.error("An instrumentation function threw an error:", e); + } + + if (!handlerPromise) { + await callHandler(); + } + + // If the user forgot to await the handler, we can wait for it to resolve here + await handlerPromise; + } + + if (result) { + return result; + } + + return { + type: "error", + value: new Error("No result assigned in instrumentation chain."), + }; +} + +function getHandlerInfo( + args: + | LoaderFunctionArgs + | ActionFunctionArgs + | Parameters[0], +): RouteHandlerInstrumentationInfo { + let { request, context, params, unstable_pattern } = args; + return { + request: getReadonlyRequest(request), + params: { ...params }, + unstable_pattern, + context: getReadonlyContext(context), + }; +} + +function getRouterInfo( + router: Router, + opts: NonNullable< + Parameters[1] | Parameters[3] + >, +) { + return { + currentUrl: createPath(router.state.location), + ...("formMethod" in opts ? { formMethod: opts.formMethod } : {}), + ...("formEncType" in opts ? { formEncType: opts.formEncType } : {}), + ...("formData" in opts ? { formData: opts.formData } : {}), + ...("body" in opts ? { body: opts.body } : {}), + }; +} +// Return a shallow readonly "clone" of the Request with the info they may +// want to read from during instrumentation +function getReadonlyRequest(request: Request): { + method: string; + url: string; + headers: Pick; +} { + return { + method: request.method, + url: request.url, + headers: { + get: (...args) => request.headers.get(...args), + }, + }; +} + +function getReadonlyContext( + context: MiddlewareEnabled extends true + ? RouterContextProvider + : AppLoadContext, +): MiddlewareEnabled extends true + ? Pick + : Readonly { + if (isPlainObject(context)) { + let frozen = { ...context }; + Object.freeze(frozen); + return frozen; + } else { + return { + get: (ctx: RouterContext) => + (context as unknown as RouterContextProvider).get(ctx), + }; + } +} + +// From turbo-stream-v2/flatten.ts +const objectProtoNames = Object.getOwnPropertyNames(Object.prototype) + .sort() + .join("\0"); + +function isPlainObject( + thing: unknown, +): thing is Record { + if (thing === null || typeof thing !== "object") { + return false; + } + const proto = Object.getPrototypeOf(thing); + return ( + proto === Object.prototype || + proto === null || + Object.getOwnPropertyNames(proto).sort().join("\0") === objectProtoNames + ); +} diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 4cd9d1df51..acdc6b1267 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -9,6 +9,16 @@ import { parsePath, warning, } from "./history"; +import type { + unstable_ClientInstrumentation, + unstable_InstrumentRouteFunction, + unstable_InstrumentRouterFunction, + unstable_ServerInstrumentation, +} from "./instrumentation"; +import { + getRouteInstrumentationUpdates, + instrumentClientSideRouter, +} from "./instrumentation"; import type { AgnosticDataRouteMatch, AgnosticDataRouteObject, @@ -40,7 +50,6 @@ import type { ActionFunction, MiddlewareFunction, MiddlewareNextFunction, - ErrorResponse, } from "./utils"; import { ErrorResponseImpl, @@ -58,6 +67,7 @@ import { resolveTo, stripBasename, RouterContextProvider, + getRoutePattern, } from "./utils"; //////////////////////////////////////////////////////////////////////////////// @@ -403,6 +413,7 @@ export interface RouterInit { history: History; basename?: string; getContext?: () => MaybePromise; + unstable_instrumentations?: unstable_ClientInstrumentation[]; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; hydrationRouteProperties?: string[]; @@ -866,7 +877,27 @@ export function createRouter(init: RouterInit): Router { ); let hydrationRouteProperties = init.hydrationRouteProperties || []; - let mapRouteProperties = init.mapRouteProperties || defaultMapRouteProperties; + let _mapRouteProperties = + init.mapRouteProperties || defaultMapRouteProperties; + let mapRouteProperties = _mapRouteProperties; + + // Leverage the existing mapRouteProperties logic to execute instrumentRoute + // (if it exists) on all routes in the application + if (init.unstable_instrumentations) { + let instrumentations = init.unstable_instrumentations; + + mapRouteProperties = (route: AgnosticDataRouteObject) => { + return { + ..._mapRouteProperties(route), + ...getRouteInstrumentationUpdates( + instrumentations + .map((i) => i.route) + .filter(Boolean) as unstable_InstrumentRouteFunction[], + route, + ), + }; + }; + } // Routes keyed by ID let manifest: RouteManifest = {}; @@ -3507,6 +3538,15 @@ export function createRouter(init: RouterInit): Router { }, }; + if (init.unstable_instrumentations) { + router = instrumentClientSideRouter( + router, + init.unstable_instrumentations + .map((i) => i.router) + .filter(Boolean) as unstable_InstrumentRouterFunction[], + ); + } + return router; } //#endregion @@ -3518,6 +3558,7 @@ export function createRouter(init: RouterInit): Router { export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; + unstable_instrumentations?: Pick[]; future?: {}; } @@ -3532,8 +3573,27 @@ export function createStaticHandler( let manifest: RouteManifest = {}; let basename = (opts ? opts.basename : null) || "/"; - let mapRouteProperties = + let _mapRouteProperties = opts?.mapRouteProperties || defaultMapRouteProperties; + let mapRouteProperties = _mapRouteProperties; + + // Leverage the existing mapRouteProperties logic to execute instrumentRoute + // (if it exists) on all routes in the application + if (opts?.unstable_instrumentations) { + let instrumentations = opts.unstable_instrumentations; + + mapRouteProperties = (route: AgnosticDataRouteObject) => { + return { + ..._mapRouteProperties(route), + ...getRouteInstrumentationUpdates( + instrumentations + .map((i) => i.route) + .filter(Boolean) as unstable_InstrumentRouteFunction[], + route, + ), + }; + }; + } let dataRoutes = convertRoutesToDataRoutes( routes, @@ -3660,6 +3720,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), matches, params: matches[0].params, // If we're calling middleware then it must be enabled so we can cast @@ -3891,6 +3952,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), matches, params: matches[0].params, // If we're calling middleware then it must be enabled so we can cast @@ -4283,12 +4345,14 @@ export function createStaticHandler( matches.findIndex((m) => m.route.id === pendingActionResult[0]) - 1 : undefined; + let pattern = getRoutePattern(matches.map((m) => m.route.path)); dsMatches = matches.map((match, index) => { if (maxIdx != null && index > maxIdx) { return getDataStrategyMatch( mapRouteProperties, manifest, request, + pattern, match, [], requestContext, @@ -4300,6 +4364,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + pattern, match, [], requestContext, @@ -4754,6 +4819,7 @@ function getMatchesToLoad( actionStatus, }; + let pattern = getRoutePattern(matches.map((m) => m.route.path)); let dsMatches: DataStrategyMatch[] = matches.map((match, index) => { let { route } = match; @@ -4787,6 +4853,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + pattern, match, lazyRoutePropertiesToSkip, scopedContext, @@ -4816,6 +4883,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + pattern, match, lazyRoutePropertiesToSkip, scopedContext, @@ -5577,13 +5645,18 @@ async function runMiddlewarePipeline( nextResult: { value: Result } | undefined, ) => Promise, ): Promise { - let { matches, request, params, context } = args; + let { matches, request, params, context, unstable_pattern } = args; let tuples = matches.flatMap((m) => m.route.middleware ? m.route.middleware.map((fn) => [m.route.id, fn]) : [], ) as [string, MiddlewareFunction][]; let result = await callRouteMiddleware( - { request, params, context }, + { + request, + params, + context, + unstable_pattern, + }, tuples, handler, processResult, @@ -5705,6 +5778,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], scopedContext: unknown, @@ -5753,15 +5827,19 @@ function getDataStrategyMatch( !isMutationMethod(request.method) && (lazy || loader)); - // If this match was marked `shouldLoad` due to a middleware and it - // doesn't have a `loader` to run and no `lazy` to add one, then we can - // just return undefined from the "loader" here + // For GET requests, if this match was marked `shouldLoad` due to a + // middleware and it doesn't have a `loader` to run and no `lazy` to add + // one, then we can just return undefined from the "loader" here let isMiddlewareOnlyRoute = middleware && middleware.length > 0 && !loader && !lazy; - if (callHandler && !isMiddlewareOnlyRoute) { + if ( + callHandler && + (isMutationMethod(request.method) || !isMiddlewareOnlyRoute) + ) { return callLoaderOrAction({ request, + unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, lazyRoutePromise: _lazyPromises?.route, @@ -5808,6 +5886,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, lazyRoutePropertiesToSkip, scopedContext, @@ -5835,6 +5914,7 @@ async function callDataStrategyImpl( // back out below. let dataStrategyArgs = { request, + unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), params: matches[0].params, context: scopedContext, matches, @@ -5892,6 +5972,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + unstable_pattern, match, lazyHandlerPromise, lazyRoutePromise, @@ -5899,6 +5980,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + unstable_pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; lazyRoutePromise: Promise | undefined; @@ -5932,6 +6014,7 @@ async function callLoaderOrAction({ return handler( { request, + unstable_pattern, params: match.params, context: scopedContext, }, diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 14e5e40c51..4c816a548f 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -269,6 +269,11 @@ type DefaultContext = MiddlewareEnabled extends true interface DataFunctionArgs { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read headers (like cookies, and {@link https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams URLSearchParams} from the request. */ request: Request; + /** + * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug). + * Mostly useful as a identifier to aggregate on for logging/tracing/etc. + */ + unstable_pattern: string; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -535,7 +540,7 @@ export type AgnosticPatchRoutesOnNavigationFunction< * properties from framework-agnostic properties */ export interface MapRoutePropertiesFunction { - (route: AgnosticRouteObject): { + (route: AgnosticDataRouteObject): { hasErrorBoundary: boolean; } & Record; } @@ -808,19 +813,23 @@ export function convertRoutesToDataRoutes( if (isIndexRoute(route)) { let indexRoute: AgnosticDataIndexRouteObject = { ...route, - ...mapRouteProperties(route), id, }; - manifest[id] = indexRoute; + manifest[id] = mergeRouteUpdates( + indexRoute, + mapRouteProperties(indexRoute), + ); return indexRoute; } else { let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { ...route, - ...mapRouteProperties(route), id, children: undefined, }; - manifest[id] = pathOrLayoutRoute; + manifest[id] = mergeRouteUpdates( + pathOrLayoutRoute, + mapRouteProperties(pathOrLayoutRoute), + ); if (route.children) { pathOrLayoutRoute.children = convertRoutesToDataRoutes( @@ -837,6 +846,23 @@ export function convertRoutesToDataRoutes( }); } +function mergeRouteUpdates( + route: T, + updates: ReturnType, +): T { + return Object.assign(route, { + ...updates, + ...(typeof updates.lazy === "object" && updates.lazy != null + ? { + lazy: { + ...route.lazy, + ...updates.lazy, + }, + } + : {}), + }); +} + /** * Matches the given routes to a location and returns the match data. * @@ -1995,3 +2021,7 @@ export function isRouteErrorResponse(error: any): error is ErrorResponse { "data" in error ); } + +export function getRoutePattern(paths: (string | undefined)[]) { + return paths.filter(Boolean).join("/").replace(/\/\/*/g, "/") || "/"; +} diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index 25462b1be9..c18e7e3d85 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -12,6 +12,11 @@ import type { import type { ServerRouteManifest } from "./routes"; import type { AppLoadContext } from "./data"; import type { MiddlewareEnabled } from "../types/future"; +import type { + unstable_InstrumentRequestHandlerFunction, + unstable_InstrumentRouteFunction, + unstable_ServerInstrumentation, +} from "../router/instrumentation"; type OptionalCriticalCss = CriticalCss | undefined; @@ -58,12 +63,23 @@ export interface HandleDocumentRequestFunction { export interface HandleDataRequestFunction { ( response: Response, - args: LoaderFunctionArgs | ActionFunctionArgs, + args: { + request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"]; + context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"]; + params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"]; + }, ): Promise | Response; } export interface HandleErrorFunction { - (error: unknown, args: LoaderFunctionArgs | ActionFunctionArgs): void; + ( + error: unknown, + args: { + request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"]; + context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"]; + params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"]; + }, + ): void; } /** @@ -74,5 +90,6 @@ export interface ServerEntryModule { default: HandleDocumentRequestFunction; handleDataRequest?: HandleDataRequestFunction; handleError?: HandleErrorFunction; + unstable_instrumentations?: unstable_ServerInstrumentation[]; streamTimeout?: number; } diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index a1b67be014..db680dfd78 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -26,6 +26,7 @@ export async function callRouteHandler( request: stripRoutesParam(stripIndexParam(args.request)), params: args.params, context: args.context, + unstable_pattern: args.unstable_pattern, }); // If they returned a redirect via data(), re-throw it as a Response diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 71f4d11b04..6a8091cf25 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -36,6 +36,8 @@ import { getDocumentHeaders } from "./headers"; import type { EntryRoute } from "../dom/ssr/routes"; import type { MiddlewareEnabled } from "../types/future"; import { getManifestPath } from "../dom/ssr/fog-of-war"; +import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation"; +import { instrumentHandler } from "../router/instrumentation"; export type RequestHandler = ( request: Request, @@ -55,6 +57,7 @@ function derive(build: ServerBuild, mode?: string) { let serverMode = isServerMode(mode) ? mode : ServerMode.Production; let staticHandler = createStaticHandler(dataRoutes, { basename: build.basename, + unstable_instrumentations: build.entry.module.unstable_instrumentations, }); let errorHandler = @@ -67,42 +70,8 @@ function derive(build: ServerBuild, mode?: string) { ); } }); - return { - routes, - dataRoutes, - serverMode, - staticHandler, - errorHandler, - }; -} - -export const createRequestHandler: CreateRequestHandlerFunction = ( - build, - mode, -) => { - let _build: ServerBuild; - let routes: ServerRoute[]; - let serverMode: ServerMode; - let staticHandler: StaticHandler; - let errorHandler: HandleErrorFunction; - - return async function requestHandler(request, initialContext) { - _build = typeof build === "function" ? await build() : build; - - if (typeof build === "function") { - let derived = derive(_build, mode); - routes = derived.routes; - serverMode = derived.serverMode; - staticHandler = derived.staticHandler; - errorHandler = derived.errorHandler; - } else if (!routes || !serverMode || !staticHandler || !errorHandler) { - let derived = derive(_build, mode); - routes = derived.routes; - serverMode = derived.serverMode; - staticHandler = derived.staticHandler; - errorHandler = derived.errorHandler; - } + let requestHandler: RequestHandler = async (request, initialContext) => { let params: RouteMatch["params"] = {}; let loadContext: AppLoadContext | RouterContextProvider; @@ -118,7 +87,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( }); }; - if (_build.future.v8_middleware) { + if (build.future.v8_middleware) { if ( initialContext && !(initialContext instanceof RouterContextProvider) @@ -138,7 +107,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let url = new URL(request.url); - let normalizedBasename = _build.basename || "/"; + let normalizedBasename = build.basename || "/"; let normalizedPath = url.pathname; if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { normalizedPath = normalizedBasename; @@ -158,7 +127,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( // When runtime SSR is disabled, make our dev server behave like the deployed // pre-rendered site would - if (!_build.ssr) { + if (!build.ssr) { // Decode the URL path before checking against the prerender config let decodedPath = decodeURI(normalizedPath); @@ -188,12 +157,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( // When SSR is disabled this, file can only ever run during dev because we // delete the server build at the end of the build - if (_build.prerender.length === 0) { + if (build.prerender.length === 0) { // ssr:false and no prerender config indicates "SPA Mode" isSpaMode = true; } else if ( - !_build.prerender.includes(decodedPath) && - !_build.prerender.includes(decodedPath + "/") + !build.prerender.includes(decodedPath) && + !build.prerender.includes(decodedPath + "/") ) { if (url.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests @@ -222,12 +191,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( // Manifest request for fog of war let manifestUrl = getManifestPath( - _build.routeDiscovery.manifestPath, + build.routeDiscovery.manifestPath, normalizedBasename, ); if (url.pathname === manifestUrl) { try { - let res = await handleManifestRequest(_build, routes, url); + let res = await handleManifestRequest(build, routes, url); return res; } catch (e) { handleError(e); @@ -235,7 +204,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( } } - let matches = matchServerRoutes(routes, normalizedPath, _build.basename); + let matches = matchServerRoutes(routes, normalizedPath, build.basename); if (matches && matches.length > 0) { Object.assign(params, matches[0].params); } @@ -248,12 +217,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let singleFetchMatches = matchServerRoutes( routes, handlerUrl.pathname, - _build.basename, + build.basename, ); response = await handleSingleFetchRequest( serverMode, - _build, + build, staticHandler, request, handlerUrl, @@ -265,13 +234,13 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = generateSingleFetchRedirectResponse( response, request, - _build, + build, serverMode, ); } - if (_build.entry.module.handleDataRequest) { - response = await _build.entry.module.handleDataRequest(response, { + if (build.entry.module.handleDataRequest) { + response = await build.entry.module.handleDataRequest(response, { context: loadContext, params: singleFetchMatches ? singleFetchMatches[0].params : {}, request, @@ -281,7 +250,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = generateSingleFetchRedirectResponse( response, request, - _build, + build, serverMode, ); } @@ -294,7 +263,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( ) { response = await handleResourceRequest( serverMode, - _build, + build, staticHandler, matches.slice(-1)[0].route.id, request, @@ -305,8 +274,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let { pathname } = url; let criticalCss: CriticalCss | undefined = undefined; - if (_build.unstable_getCriticalCss) { - criticalCss = await _build.unstable_getCriticalCss({ pathname }); + if (build.unstable_getCriticalCss) { + criticalCss = await build.unstable_getCriticalCss({ pathname }); } else if ( mode === ServerMode.Development && getDevServerHooks()?.getCriticalCss @@ -316,7 +285,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = await handleDocumentRequest( serverMode, - _build, + build, staticHandler, request, loadContext, @@ -336,6 +305,64 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return response; }; + + if (build.entry.module.unstable_instrumentations) { + requestHandler = instrumentHandler( + requestHandler, + build.entry.module.unstable_instrumentations + .map((i) => i.handler) + .filter(Boolean) as unstable_InstrumentRequestHandlerFunction[], + ); + } + + return { + routes, + dataRoutes, + serverMode, + staticHandler, + errorHandler, + requestHandler, + }; +} + +export const createRequestHandler: CreateRequestHandlerFunction = ( + build, + mode, +) => { + let _build: ServerBuild; + let routes: ServerRoute[]; + let serverMode: ServerMode; + let staticHandler: StaticHandler; + let errorHandler: HandleErrorFunction; + let _requestHandler: RequestHandler; + + return async function requestHandler(request, initialContext) { + _build = typeof build === "function" ? await build() : build; + + if (typeof build === "function") { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + _requestHandler = derived.requestHandler; + } else if ( + !routes || + !serverMode || + !staticHandler || + !errorHandler || + !_requestHandler + ) { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + _requestHandler = derived.requestHandler; + } + + return _requestHandler(request, initialContext); + }; }; async function handleManifestRequest( diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 058a2f5aef..52eefee088 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -92,6 +92,11 @@ export type ClientDataFunctionArgs = { * } **/ params: Params; + /** + * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug). + * Mostly useful as a identifier to aggregate on for logging/tracing/etc. + */ + unstable_pattern: string; /** * When `future.v8_middleware` is not enabled, this is undefined. * @@ -121,6 +126,11 @@ export type ServerDataFunctionArgs = { * } **/ params: Params; + /** + * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug). + * Mostly useful as a identifier to aggregate on for logging/tracing/etc. + */ + unstable_pattern: string; /** * Without `future.v8_middleware` enabled, this is the context passed in * to your server adapter's `getLoadContext` function. It's a way to bridge the diff --git a/packages/react-router/package.json b/packages/react-router/package.json index f89b50e9ba..240e926700 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.9.4", + "version": "7.9.5", "description": "Declarative routing for React", "keywords": [ "react", diff --git a/playground/rsc-parcel/src/entry.browser.tsx b/playground/rsc-parcel/src/entry.browser.tsx index 1c423855fb..a245bd4bea 100644 --- a/playground/rsc-parcel/src/entry.browser.tsx +++ b/playground/rsc-parcel/src/entry.browser.tsx @@ -6,8 +6,8 @@ import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from "react-router"; -import type { unstable_RSCPayload as RSCPayload } from "react-router"; + type unstable_RSCPayload as RSCPayload, +} from "react-router/dom"; import { createFromReadableStream, createTemporaryReferenceSet, diff --git a/playground/rsc-vite/src/entry.browser.tsx b/playground/rsc-vite/src/entry.browser.tsx index 3a9585d479..8e4f850b95 100644 --- a/playground/rsc-vite/src/entry.browser.tsx +++ b/playground/rsc-vite/src/entry.browser.tsx @@ -10,8 +10,8 @@ import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from "react-router"; -import type { unstable_RSCPayload as RSCPayload } from "react-router"; + type unstable_RSCPayload as RSCPayload, +} from "react-router/dom"; setServerCallback( createCallServer({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3e43eaea6..f1092dd7c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: version: 7.34.1(eslint@8.57.0) eslint-plugin-react-hooks: specifier: next - version: 6.1.0-canary-d15d7fd7-20250929(eslint@8.57.0) + version: 7.0.0-canary-71b3a03c-20251021(eslint@8.57.0) fast-glob: specifier: 3.2.11 version: 3.2.11 @@ -1098,6 +1098,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + p-map: + specifier: ^7.0.3 + version: 7.0.3 pathe: specifier: ^1.1.2 version: 1.1.2 @@ -6259,8 +6262,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react-hooks@6.1.0-canary-d15d7fd7-20250929: - resolution: {integrity: sha512-BeJu8hPQW+FjteWcCVdVezI2ogQs2mrHSOznrk00dbXztd8NqnyHlB7Z1wx3ZwkUVVAVHmmxrBCrRMn6UP15FA==} + eslint-plugin-react-hooks@7.0.0-canary-71b3a03c-20251021: + resolution: {integrity: sha512-7EwZO7Drxeeq1g1ZNcIu93EEm0BvfBXZmUE+psxQCvpyyVfs7096NVcTuiuxY5aQwCGFdrVrSsgrSMHAFA6TUA==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 @@ -8147,6 +8150,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-map@7.0.3: + resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} + engines: {node: '>=18'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -9943,11 +9950,11 @@ packages: zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} - zod-validation-error@3.4.0: - resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.18.0 + zod: ^3.25.0 || ^4.0.0 zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -9955,6 +9962,9 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -15135,15 +15145,14 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-plugin-react-hooks@6.1.0-canary-d15d7fd7-20250929(eslint@8.57.0): + eslint-plugin-react-hooks@7.0.0-canary-71b3a03c-20251021(eslint@8.57.0): dependencies: '@babel/core': 7.27.7 '@babel/parser': 7.27.7 - '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.27.7) eslint: 8.57.0 hermes-parser: 0.25.1 - zod: 3.24.2 - zod-validation-error: 3.4.0(zod@3.24.2) + zod: 4.1.12 + zod-validation-error: 4.0.2(zod@4.1.12) transitivePeerDependencies: - supports-color @@ -17759,6 +17768,8 @@ snapshots: p-map@2.1.0: {} + p-map@7.0.3: {} + p-try@2.2.0: {} pac-proxy-agent@7.0.2: @@ -19830,12 +19841,15 @@ snapshots: zimmerframe@1.1.2: {} - zod-validation-error@3.4.0(zod@3.24.2): + zod-validation-error@4.0.2(zod@4.1.12): dependencies: - zod: 3.24.2 + zod: 4.1.12 zod@3.22.3: {} - zod@3.24.2: {} + zod@3.24.2: + optional: true + + zod@4.1.12: {} zwitch@2.0.4: {}