diff --git a/.agents/skills/fix-bug/SKILL.md b/.agents/skills/fix-bug/SKILL.md new file mode 100644 index 0000000000..a1904c4e08 --- /dev/null +++ b/.agents/skills/fix-bug/SKILL.md @@ -0,0 +1,151 @@ +--- +name: fix-bug +description: Fix a reported bug in React Router from a GitHub issue. Use when the user provides a GitHub issue URL and asks to fix a bug, investigate an issue, or reproduce a problem. Handles the full workflow: fetching the issue, finding the reproduction, writing a failing test, and implementing the fix. +disable-model-invocation: true +--- + +# Fix React Router Bug + +Fix the bug reported in the following GitHub issue: $ARGUMENTS + +## Branching + +Bug fixes should start from a clean working tree. If there are changes, prompt me to resolve them before continuing. + +Bugs should be fixed from the `dev` branch in in a new branch using the format `{author}/{semantic-branch-name}` (i.e., `brophdawg11/fix-navigation`): + +```sh +git branch {author}/{semantic-branch-name} dev +git checkout {author}/{semantic-branch-name} +``` + +## Workflow + +### 1. Fetch and Understand the Issue + +Use `gh issue view --repo remix-run/react-router` or `WebFetch` to read the full issue. + +Extract: + +- Bug description and expected vs actual behavior +- React Router version and mode (Declarative / Data / Framework / RSC) +- Any code snippets in the issue +- Links to reproductions (StackBlitz, CodeSandbox, GitHub repo, etc.) + +### 2. Validate the Reproduction + +**If there's a StackBlitz/CodeSandbox/online sandbox link:** + +- Use `WebFetch` to read the sandbox URL and extract the relevant code +- Identify the exact sequence of events that triggers the bug + +**If there's a GitHub repository link:** + +- Use `WebFetch` to read key files (`package.json`, relevant source files) from the raw GitHub URL +- Identify the route configuration, loaders, actions, or components involved + +**If no reproduction link exists:** + +- Search the issue comments with `gh issue view --repo remix-run/react-router --comments` +- Look for code snippets in comments +- Ask the user: "No reproduction was provided. Can you share a minimal reproduction or paste the relevant code?" + +### 3. Identify the Affected Code + +Based on the bug, locate the relevant source files. Consult the key file map: + +| Area | Files | +| ---------------------- | ----------------------------------------------------------- | +| Core router logic | `packages/react-router/lib/router/router.ts` | +| React components/hooks | `packages/react-router/lib/components.tsx`, `lib/hooks.tsx` | +| DOM utilities | `packages/react-router/lib/dom/` | +| Vite/Framework plugin | `packages/react-router-dev/vite/plugin.ts` | +| RSC | `packages/react-router/lib/rsc/` | + +Use `Grep` and `Glob` to trace the relevant code paths. + +### 4. Write a Failing Test + +**Unit test** (for router logic, hooks, pure component behavior — no build needed): + +- Location: `packages/react-router/__tests__/` +- Use Jest; run with: `pnpm test packages/react-router/__tests__/` +- Match the style of nearby test files (describe/it blocks, `createStaticHandler`, `createMemoryRouter`, `render`, `screen`, etc.) + +**Integration test** (for Vite plugin, SSR, hydration, Framework Mode): + +- Location: `integration/` +- Use Playwright with `createFixture()` → `createAppFixture()` → `PlaywrightFixture` +- Run with: `pnpm test:integration:run --project chromium integration/` +- Build first if needed: `pnpm test:integration --project chromium` + +Write the test to **reproduce the bug exactly** — it must fail before the fix. + +Run it and confirm it fails: + +```bash +pnpm test packages/react-router/__tests__/ # unit +# or +pnpm test:integration:run --project chromium integration/ # integration +``` + +### 5. Implement the Fix + +- Make the minimal change needed to fix the bug +- Do not refactor unrelated code +- Confirm the fix addresses the root cause, not just the symptom +- Consider all five modes: does this fix break anything in Declarative / Data / Framework / RSC? + +Run the failing test again — it must now pass: + +```bash +pnpm test packages/react-router/__tests__/ +``` + +Run the broader test suite to check for regressions: + +```bash +pnpm test packages/react-router/ +``` + +If the fix touches Framework/Vite code, run integration tests too: + +```bash +pnpm test:integration:run --project chromium +``` + +Confirm linting and typechecking pass: + +```bash +pnpm lint +pnpm typecheck +``` + +### 6. Create a Changeset + +Create `.changeset/.md`: + +```markdown +--- +"react-router": patch +--- + +fix: +``` + +Use `patch` for bug fixes. Only include packages in the frontmatter that were actually changed. + +### 7. Report Results + +Summarize: + +- What the bug was and why it happened +- What code was changed and why +- That the test now passes +- Any edge cases or related issues noticed + +Ask me to review the changes and iterate based on any feedback. + +### 8. Open PR + +Once I approve the fix, commit the changes and open a PR to `dev`. Include a `Closes #NNNN` in the description to link the PR to the original issue. Also link the issue in the `Development` sidebar diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000000..40d45785d8 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,78 @@ +# Create "installable" preview branches +# +# Commits to `dev` push builds to a `preview/dev` branch: +# pnpm install "remix-run/react-router#preview/dev&path:packages/react-router" +# +# Can also be dispatched manually with base/installable branches to provide +# `experimental` branches from PRs or otherwise. + +name: Preview Build + +on: + push: + branches: + - dev + workflow_dispatch: + inputs: + baseBranch: + description: Base Branch + required: true + installableBranch: + description: Installable Branch + required: true + +concurrency: + # Include `event_name` here because when a pull_request is merged (closed), the + # `github.ref` goes back to `ref/heads/dev` which will conflict with the run on + # `dev` from the merged PR + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preview: + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + steps: + - name: Checkout (push) + if: github.event_name == 'push' + uses: actions/checkout@v4 + + - name: Checkout (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.baseBranch }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup git + run: | + git config --local user.email "hello@remix.run" + git config --local user.name "Remix Run Bot" + + # Build and force push over the preview/dev branch + - name: Build/push branch (push) + if: github.event_name == 'push' + run: | + pnpm run setup-installable-branch preview/dev + git push --force --set-upstream origin preview/dev + echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" + + # Build and normal push for experimental releases to avoid unintended force + # pushes over remote branches in case of a branch name collision + - name: Build/push branch (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + pnpm run setup-installable-branch ${{ inputs.installableBranch }} + git push --set-upstream origin ${{ inputs.installableBranch }} + echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" diff --git a/.gitignore b/.gitignore index 9209f20a17..212db6e711 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ worker-configuration.d.ts # v7 reference docs /public + +.claude/settings.local.json +.claude/skills diff --git a/CHANGELOG.md b/CHANGELOG.md index c5bc2b8116..5ca0f73844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,170 +13,176 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v7.13.1](#v7131) - - [What's Changed](#whats-changed) - - [URL Masking (unstable)](#url-masking-unstable) + - [v7.13.2](#v7132) + - [What's Changed](#whats-changed) + - [Pass-through Requests (unstable)](#pass-through-requests-unstable) + - [Route handlers/middleware `unstable_url` parameter](#route-handlersmiddleware-unstable_url-parameter) - [Patch Changes](#patch-changes) - [Unstable Changes](#unstable-changes) + - [v7.13.1](#v7131) + - [What's Changed](#whats-changed-1) + - [URL Masking (unstable)](#url-masking-unstable) + - [Patch Changes](#patch-changes-1) + - [Unstable Changes](#unstable-changes-1) - [v7.13.0](#v7130) - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes-1) + - [Patch Changes](#patch-changes-2) - [v7.12.0](#v7120) - [Security Notice](#security-notice) - [Minor Changes](#minor-changes-1) - - [Patch Changes](#patch-changes-2) - - [Unstable Changes](#unstable-changes-1) + - [Patch Changes](#patch-changes-3) + - [Unstable Changes](#unstable-changes-2) - [v7.11.0](#v7110) - - [What's Changed](#whats-changed-1) + - [What's Changed](#whats-changed-2) - [`vite preview` Support](#vite-preview-support) - [Stabilized Client-side `onError`](#stabilized-client-side-onerror) - [Call-site Revalidation Opt-out (unstable)](#call-site-revalidation-opt-out-unstable) - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-3) - - [Unstable Changes](#unstable-changes-2) - - [v7.10.1](#v7101) - [Patch Changes](#patch-changes-4) + - [Unstable Changes](#unstable-changes-3) + - [v7.10.1](#v7101) + - [Patch Changes](#patch-changes-5) - [v7.10.0](#v7100) - - [What's Changed](#whats-changed-2) + - [What's Changed](#whats-changed-3) - [Stabilized `future.v8_splitRouteModules`](#stabilized-futurev8_splitroutemodules) - [Stabilized `future.v8_viteEnvironmentApi`](#stabilized-futurev8_viteenvironmentapi) - [Stabilized `fetcher.reset()`](#stabilized-fetcherreset) - [Stabilized `DataStrategyMatch.shouldCallHandler()`](#stabilized-datastrategymatchshouldcallhandler) - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-5) - - [Unstable Changes](#unstable-changes-3) - - [v7.9.6](#v796) - - [Security Notice](#security-notice-1) - [Patch Changes](#patch-changes-6) - [Unstable Changes](#unstable-changes-4) - - [v7.9.5](#v795) - - [What's Changed](#whats-changed-3) - - [Instrumentation (unstable)](#instrumentation-unstable) + - [v7.9.6](#v796) + - [Security Notice](#security-notice-1) - [Patch Changes](#patch-changes-7) - [Unstable Changes](#unstable-changes-5) - - [v7.9.4](#v794) - - [Security Notice](#security-notice-2) + - [v7.9.5](#v795) - [What's Changed](#whats-changed-4) - - [`useRoute()` (unstable)](#useroute-unstable) + - [Instrumentation (unstable)](#instrumentation-unstable) - [Patch Changes](#patch-changes-8) - [Unstable Changes](#unstable-changes-6) - - [v7.9.3](#v793) + - [v7.9.4](#v794) + - [Security Notice](#security-notice-2) + - [What's Changed](#whats-changed-5) + - [`useRoute()` (unstable)](#useroute-unstable) - [Patch Changes](#patch-changes-9) + - [Unstable Changes](#unstable-changes-7) + - [v7.9.3](#v793) + - [Patch Changes](#patch-changes-10) - [v7.9.2](#v792) - - [What's Changed](#whats-changed-5) + - [What's Changed](#whats-changed-6) - [RSC Framework Mode (unstable)](#rsc-framework-mode-unstable) - [Fetcher Reset (unstable)](#fetcher-reset-unstable) - - [Patch Changes](#patch-changes-10) - - [Unstable Changes](#unstable-changes-7) - - [v7.9.1](#v791) - [Patch Changes](#patch-changes-11) + - [Unstable Changes](#unstable-changes-8) + - [v7.9.1](#v791) + - [Patch Changes](#patch-changes-12) - [v7.9.0](#v790) - [Security Notice](#security-notice-3) - - [What's Changed](#whats-changed-6) + - [What's Changed](#whats-changed-7) - [Stable Middleware and Context APIs](#stable-middleware-and-context-apis) - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-12) - - [Unstable Changes](#unstable-changes-8) - - [v7.8.2](#v782) - [Patch Changes](#patch-changes-13) - [Unstable Changes](#unstable-changes-9) - - [v7.8.1](#v781) + - [v7.8.2](#v782) - [Patch Changes](#patch-changes-14) - [Unstable Changes](#unstable-changes-10) + - [v7.8.1](#v781) + - [Patch Changes](#patch-changes-15) + - [Unstable Changes](#unstable-changes-11) - [v7.8.0](#v780) - - [What's Changed](#whats-changed-7) + - [What's Changed](#whats-changed-8) - [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-5) - - [Patch Changes](#patch-changes-15) - - [Unstable Changes](#unstable-changes-11) - - [Changes by Package](#changes-by-package) - - [v7.7.1](#v771) - [Patch Changes](#patch-changes-16) - [Unstable Changes](#unstable-changes-12) + - [Changes by Package](#changes-by-package) + - [v7.7.1](#v771) + - [Patch Changes](#patch-changes-17) + - [Unstable Changes](#unstable-changes-13) - [v7.7.0](#v770) - - [What's Changed](#whats-changed-8) + - [What's Changed](#whats-changed-9) - [Unstable RSC APIs](#unstable-rsc-apis) - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-17) - - [Unstable Changes](#unstable-changes-13) + - [Patch Changes](#patch-changes-18) + - [Unstable Changes](#unstable-changes-14) - [Changes by Package](#changes-by-package-1) - [v7.6.3](#v763) - - [Patch Changes](#patch-changes-18) - - [v7.6.2](#v762) - [Patch Changes](#patch-changes-19) - - [v7.6.1](#v761) + - [v7.6.2](#v762) - [Patch Changes](#patch-changes-20) - - [Unstable Changes](#unstable-changes-14) + - [v7.6.1](#v761) + - [Patch Changes](#patch-changes-21) + - [Unstable Changes](#unstable-changes-15) - [v7.6.0](#v760) - - [What's Changed](#whats-changed-9) + - [What's Changed](#whats-changed-10) - [`routeDiscovery` Config Option](#routediscovery-config-option) - [Automatic Types for Future Flags](#automatic-types-for-future-flags) - [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) - [Changes by Package](#changes-by-package-2) - [v7.5.3](#v753) - - [Patch Changes](#patch-changes-22) + - [Patch Changes](#patch-changes-23) - [v7.5.2](#v752) - [Security Notice](#security-notice-4) - - [Patch Changes](#patch-changes-23) - - [v7.5.1](#v751) - [Patch Changes](#patch-changes-24) - - [Unstable Changes](#unstable-changes-16) + - [v7.5.1](#v751) + - [Patch Changes](#patch-changes-25) + - [Unstable Changes](#unstable-changes-17) - [v7.5.0](#v750) - - [What's Changed](#whats-changed-10) + - [What's Changed](#whats-changed-11) - [`route.lazy` Object API](#routelazy-object-api) - [Minor Changes](#minor-changes-8) - - [Patch Changes](#patch-changes-25) - - [Unstable Changes](#unstable-changes-17) + - [Patch Changes](#patch-changes-26) + - [Unstable Changes](#unstable-changes-18) - [Changes by Package](#changes-by-package-3) - [v7.4.1](#v741) - [Security Notice](#security-notice-5) - - [Patch Changes](#patch-changes-26) - - [Unstable Changes](#unstable-changes-18) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-9) - [Patch Changes](#patch-changes-27) - [Unstable Changes](#unstable-changes-19) + - [v7.4.0](#v740) + - [Minor Changes](#minor-changes-9) + - [Patch Changes](#patch-changes-28) + - [Unstable Changes](#unstable-changes-20) - [Changes by Package](#changes-by-package-4) - [v7.3.0](#v730) - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-28) - - [Unstable Changes](#unstable-changes-20) + - [Patch Changes](#patch-changes-29) + - [Unstable Changes](#unstable-changes-21) - [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-11) + - [What's Changed](#whats-changed-12) - [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-11) - - [Patch Changes](#patch-changes-29) - - [Unstable Changes](#unstable-changes-21) + - [Patch Changes](#patch-changes-30) + - [Unstable Changes](#unstable-changes-22) - [Split Route Modules (unstable)](#split-route-modules-unstable) - [Changes by Package](#changes-by-package-6) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-30) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-31) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-32) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-33) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-34) + - [v7.1.1](#v711) + - [Patch Changes](#patch-changes-35) - [v7.1.0](#v710) - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-35) + - [Patch Changes](#patch-changes-36) - [Changes by Package](#changes-by-package-7) - [v7.0.2](#v702) - - [Patch Changes](#patch-changes-36) - - [v7.0.1](#v701) - [Patch Changes](#patch-changes-37) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-38) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -193,207 +199,207 @@ 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-13) - - [Patch Changes](#patch-changes-38) + - [Patch Changes](#patch-changes-39) - [Changes by Package](#changes-by-package-8) - [React Router v6 Releases](#react-router-v6-releases) - [v6.30.3](#v6303) - [Security Notice](#security-notice-6) - - [Patch Changes](#patch-changes-39) + - [Patch Changes](#patch-changes-40) - [v6.30.2](#v6302) - [Security Notice](#security-notice-7) - - [Patch Changes](#patch-changes-40) - - [v6.30.1](#v6301) - [Patch Changes](#patch-changes-41) + - [v6.30.1](#v6301) + - [Patch Changes](#patch-changes-42) - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-42) + - [Patch Changes](#patch-changes-43) - [v6.29.0](#v6290) - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-43) - - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-44) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-45) + - [v6.28.1](#v6281) + - [Patch Changes](#patch-changes-46) - [v6.28.0](#v6280) - - [What's Changed](#whats-changed-12) + - [What's Changed](#whats-changed-13) - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-46) + - [Patch Changes](#patch-changes-47) - [v6.27.0](#v6270) - - [What's Changed](#whats-changed-13) + - [What's Changed](#whats-changed-14) - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-17) - - [Patch Changes](#patch-changes-47) - - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-48) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-49) + - [v6.26.1](#v6261) + - [Patch Changes](#patch-changes-50) - [v6.26.0](#v6260) - [Minor Changes](#minor-changes-18) - - [Patch Changes](#patch-changes-50) - - [v6.25.1](#v6251) - [Patch Changes](#patch-changes-51) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-52) - [v6.25.0](#v6250) - - [What's Changed](#whats-changed-14) + - [What's Changed](#whats-changed-15) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-52) - - [v6.24.1](#v6241) - [Patch Changes](#patch-changes-53) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-54) - [v6.24.0](#v6240) - - [What's Changed](#whats-changed-15) + - [What's Changed](#whats-changed-16) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - [Minor Changes](#minor-changes-20) - - [Patch Changes](#patch-changes-54) - - [v6.23.1](#v6231) - [Patch Changes](#patch-changes-55) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-56) - [v6.23.0](#v6230) - - [What's Changed](#whats-changed-16) + - [What's Changed](#whats-changed-17) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - [Minor Changes](#minor-changes-21) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-56) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-57) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-58) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-59) - [v6.22.0](#v6220) - - [What's Changed](#whats-changed-17) + - [What's Changed](#whats-changed-18) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - [Minor Changes](#minor-changes-22) - - [Patch Changes](#patch-changes-59) - - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-60) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-61) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-62) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-63) - [v6.21.0](#v6210) - - [What's Changed](#whats-changed-18) + - [What's Changed](#whats-changed-19) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-63) - - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-64) + - [v6.20.1](#v6201) + - [Patch Changes](#patch-changes-65) - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-24) - - [Patch Changes](#patch-changes-65) + - [Patch Changes](#patch-changes-66) - [v6.19.0](#v6190) - - [What's Changed](#whats-changed-19) + - [What's Changed](#whats-changed-20) - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-25) - - [Patch Changes](#patch-changes-66) + - [Patch Changes](#patch-changes-67) - [v6.18.0](#v6180) - - [What's Changed](#whats-changed-20) + - [What's Changed](#whats-changed-21) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-26) - - [Patch Changes](#patch-changes-67) + - [Patch Changes](#patch-changes-68) - [v6.17.0](#v6170) - - [What's Changed](#whats-changed-21) + - [What's Changed](#whats-changed-22) - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-27) - - [Patch Changes](#patch-changes-68) + - [Patch Changes](#patch-changes-69) - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-28) - - [Patch Changes](#patch-changes-69) + - [Patch Changes](#patch-changes-70) - [v6.15.0](#v6150) - [Minor Changes](#minor-changes-29) - - [Patch Changes](#patch-changes-70) - - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-71) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-72) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-73) - [v6.14.0](#v6140) - - [What's Changed](#whats-changed-22) + - [What's Changed](#whats-changed-23) - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-30) - - [Patch Changes](#patch-changes-73) + - [Patch Changes](#patch-changes-74) - [v6.13.0](#v6130) - - [What's Changed](#whats-changed-23) + - [What's Changed](#whats-changed-24) - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-31) - - [Patch Changes](#patch-changes-74) - - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-75) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-76) - [v6.12.0](#v6120) - - [What's Changed](#whats-changed-24) + - [What's Changed](#whats-changed-25) - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-32) - - [Patch Changes](#patch-changes-76) - - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-77) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-78) + - [v6.11.1](#v6111) + - [Patch Changes](#patch-changes-79) - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-33) - - [Patch Changes](#patch-changes-79) + - [Patch Changes](#patch-changes-80) - [v6.10.0](#v6100) - - [What's Changed](#whats-changed-25) + - [What's Changed](#whats-changed-26) - [Minor Changes](#minor-changes-34) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-80) + - [Patch Changes](#patch-changes-81) - [v6.9.0](#v690) - - [What's Changed](#whats-changed-26) + - [What's Changed](#whats-changed-27) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-35) - - [Patch Changes](#patch-changes-81) - - [v6.8.2](#v682) - [Patch Changes](#patch-changes-82) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-83) + - [v6.8.1](#v681) + - [Patch Changes](#patch-changes-84) - [v6.8.0](#v680) - [Minor Changes](#minor-changes-36) - - [Patch Changes](#patch-changes-84) + - [Patch Changes](#patch-changes-85) - [v6.7.0](#v670) - [Minor Changes](#minor-changes-37) - - [Patch Changes](#patch-changes-85) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-86) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-87) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-88) - [v6.6.0](#v660) - - [What's Changed](#whats-changed-27) + - [What's Changed](#whats-changed-28) - [Minor Changes](#minor-changes-38) - - [Patch Changes](#patch-changes-88) + - [Patch Changes](#patch-changes-89) - [v6.5.0](#v650) - - [What's Changed](#whats-changed-28) + - [What's Changed](#whats-changed-29) - [Minor Changes](#minor-changes-39) - - [Patch Changes](#patch-changes-89) - - [v6.4.5](#v645) - [Patch Changes](#patch-changes-90) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-91) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-92) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-93) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-94) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-95) - [v6.4.0](#v640) - - [What's Changed](#whats-changed-29) + - [What's Changed](#whats-changed-30) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-95) + - [Patch Changes](#patch-changes-96) - [v6.3.0](#v630) - [Minor Changes](#minor-changes-40) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-96) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-97) + - [v6.2.1](#v621) + - [Patch Changes](#patch-changes-98) - [v6.2.0](#v620) - [Minor Changes](#minor-changes-41) - - [Patch Changes](#patch-changes-98) - - [v6.1.1](#v611) - [Patch Changes](#patch-changes-99) + - [v6.1.1](#v611) + - [Patch Changes](#patch-changes-100) - [v6.1.0](#v610) - [Minor Changes](#minor-changes-42) - - [Patch Changes](#patch-changes-100) - - [v6.0.2](#v602) - [Patch Changes](#patch-changes-101) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-102) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-103) - [v6.0.0](#v600) @@ -421,6 +427,68 @@ 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.13.2 + +Date: 2026-03-23 + +### What's Changed + +#### Pass-through Requests (unstable) + +By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details (`.data` suffixes, `index` + `_routes` query params). This release introduces a new `future.unstable_passThroughRequests` flag to disable this normalization and pass the raw HTTP `request` instance to your handlers. + +In addition to reducing server-side overhead by eliminating multiple `new Request()` calls on the critical path, this also provides additional visibility to your route handlers/instrumentations allowing you to differentiate document from data requests. + +If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location: + +```tsx +// ❌ Before: you could assume there was no `.data` suffix in `request.url` +export async function loader({ request }: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check will fail with the flag enabled because the `.data` suffix will + // exist on data requests + } +} + +// ✅ After: use `unstable_url` for normalized routing logic and `request.url` +// for raw routing logic +export async function loader({ request, unstable_url }: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL(request.url).pathname.endsWith(".data"); +} +``` + +#### Route handlers/middleware `unstable_url` parameter + +We have added a new `unstable_url: URL` parameter to route handler methods (`loader`, `action`, `middleware`, etc.) that contains the normalized URL the application is navigating to or fetching with React Router implementation details removed (`.data`suffix, `index`/`_routes` query params). + +This parameter is primarily needed when adopting the new `future.unstable_passthroughRequests` future flag as a way to continue accessing the normalized URL. If you don't have the flag enabled, then `unstable_url` will match `request.url`. + +### Patch Changes + +- `react-router` - Fix `clientLoader.hydrate` when an ancestor route is also hydrating a `clientLoader` ([#14835](https://github.com/remix-run/react-router/pull/14835)) +- `react-router` - Fix type error when passing Framework Mode route components using `Route.ComponentProps` to `createRoutesStub` ([#14892](https://github.com/remix-run/react-router/pull/14892)) +- `react-router` - Fix percent encoding in relative path navigation ([#14786](https://github.com/remix-run/react-router/pull/14786)) +- `react-router` - Internal refactor to consolidate framework-agnostic/React-specific route type layers - no public API changes ([#14765](https://github.com/remix-run/react-router/pull/14765)) +- `@react-router/dev` - Fix `react-router dev` crash when Unix socket files exist in the project root ([#14854](https://github.com/remix-run/react-router/pull/14854)) +- `@react-router/dev` - Escape redirect locations in pre-rendered redirect HTML ([#14880](https://github.com/remix-run/react-router/pull/14880)) +- `create-react-router` - replace `chalk` with `picocolors` ([#14837](https://github.com/remix-run/react-router/pull/14837)) + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - Sync protocol validation to RSC flows ([#14882](https://github.com/remix-run/react-router/pull/14882)) +- `react-router` - Add `future.unstable_passThroughRequests` flag ([#14775](https://github.com/remix-run/react-router/pull/14775)) +- `react-router` - Add a new `unstable_url: URL` parameter to route handler methods (`loader`, `action`, `middleware`, etc.) representing the normalized URL the application is navigating to or fetching, with React Router implementation details removed (`.data`suffix, `index`/`_routes` query params) ([#14775](https://github.com/remix-run/react-router/pull/14775)) + +**Full Changelog**: [`v7.13.1...v7.13.2`](https://github.com/remix-run/react-router/compare/react-router@7.13.1...react-router@7.13.2) + ## v7.13.1 Date: 2026-02-23 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..7ee0ae35c6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# React Router Project Instructions + +## Session Start + +**REQUIRED**: Read [./AGENTS.md](./AGENTS.md) at the start of every session. It contains: + +- Project architecture and key files +- React Router modes (Declarative, Data, Framework, RSC) +- Build/test commands (Jest unit tests, Playwright integration tests with `--project chromium`) +- Testing patterns and conventions +- Documentation guidelines + +### Skills + +If there is an [`.agents/skills`](.agents/skills) directory in the repository, symlink those skills to `.claude/skills` to make sure they are made available to Claude. Refresh you list of available skills if needed. This is a git ignored directory because we want to keep the canonical skills in `.agents/skills`. + +## During Work + +**Always consult AGENTS.md** when you need to: + +- Run tests or builds +- Understand which mode(s) a feature applies to +- Find key file locations +- Understand testing patterns + +Do not guess at commands - reference AGENTS.md for the correct syntax. diff --git a/contributors.yml b/contributors.yml index 31584cfcfc..5065f1e816 100644 --- a/contributors.yml +++ b/contributors.yml @@ -27,6 +27,7 @@ - amitdahan - AmRo045 - amsal +- amtins - AnandShiva - Andarist - andreasottosson-polestar @@ -237,6 +238,7 @@ - KostiantynPopovych - KubasuIvanSakwa - KutnerUri +- kuzznicki - kylegirard - LadyTsukiko - landisdesign @@ -301,6 +303,7 @@ - mtendekuyokwa19 - mtliendo - namoscato +- Nandann018-ux - nanianlisao - ned-park - nenene3 @@ -356,6 +359,8 @@ - robbtraister - RobHannay - robinvdvleuten +- roli-lpci +- RomanBaiocco - rossipedia - rtmann - rtzll @@ -443,6 +448,7 @@ - valerii15298 - ValiantCat - vdusart +- veeceey - vesan - vezaynk - VictorElHajj diff --git a/decisions/0016-plan-remove-agnostic-types.md b/decisions/0016-plan-remove-agnostic-types.md new file mode 100644 index 0000000000..9640c42c86 --- /dev/null +++ b/decisions/0016-plan-remove-agnostic-types.md @@ -0,0 +1,368 @@ +# Plan: Remove "Agnostic" Layer from RouteObject/RouteMatch Types + +**Goal**: Consolidate the framework-agnostic and React-specific type layers into a single React-aware layer without breaking the public API. + +## Background + +React Router historically maintained a framework-agnostic layer to support multiple UI frameworks. Since we no longer support other frameworks, we can simplify our type hierarchy by removing this abstraction. + +### Current Structure + +**Agnostic Layer** (`lib/router/utils.ts`): + +- `AgnosticBaseRouteObject` - Base route properties (loader, action, etc.) +- `AgnosticIndexRouteObject` - Index route without children +- `AgnosticNonIndexRouteObject` - Route with optional children +- `AgnosticRouteObject` - Union of index/non-index routes +- `AgnosticDataIndexRouteObject` - Index route with required `id` +- `AgnosticDataNonIndexRouteObject` - Non-index route with required `id` +- `AgnosticDataRouteObject` - Data route with required `id` +- `AgnosticRouteMatch` - Match result +- `AgnosticDataRouteMatch` - Data route match result + +**React Layer** (`lib/context.ts`): + +- `IndexRouteObject` - Extends agnostic type + React fields (`element`, `Component`, `errorElement`, `ErrorBoundary`, `hydrateFallbackElement`, `HydrateFallback`) +- `NonIndexRouteObject` - Extends agnostic type + React fields +- `RouteObject` - Union of index/non-index +- `DataRouteObject` - Route with required `id` +- `RouteMatch` - Extends `AgnosticRouteMatch` +- `DataRouteMatch` - Extends `AgnosticRouteMatch` + +### Problem + +The two-layer structure adds complexity: + +1. Duplicate type definitions with subtle differences +2. Requires understanding inheritance hierarchy +3. The "agnostic" layer no longer serves a purpose +4. React types extend agnostic types just to add React-specific fields + +## Proposed Solution + +**Strategy**: Consolidate types by renaming the "agnostic" types to be the primary React-aware types, eliminating the prefix entirely. + +### Approach: Rename and Merge (Alternative 1) + +Since `Agnostic*` types are not exported from the public API, we can safely rename them to become the primary `RouteObject`, `RouteMatch`, etc. types. The React layer in `context.ts` will re-export these types, maintaining the public API contract. + +#### Step 1: Update `lib/router/utils.ts` + +Rename existing `Agnostic*` types and add React-specific fields: + +```typescript +/** + * Base RouteObject with common props shared by all types of routes + */ +type BaseRouteObject = { + caseSensitive?: boolean; + path?: string; + id?: string; + middleware?: MiddlewareFunction[]; + loader?: LoaderFunction | boolean; + action?: ActionFunction | boolean; + hasErrorBoundary?: boolean; + shouldRevalidate?: ShouldRevalidateFunction; + handle?: any; + lazy?: LazyRouteDefinition; + // React-specific fields (merged from context.ts) + element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; + errorElement?: React.ReactNode | null; + Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; +}; + +/** + * Index routes must not have children + */ +export type IndexRouteObject = BaseRouteObject & { + children?: undefined; + index: true; +}; + +/** + * Non-index routes may have children, but cannot have index + */ +export type NonIndexRouteObject = BaseRouteObject & { + children?: RouteObject[]; + index?: false; +}; + +/** + * A route object represents a logical route, with (optionally) its child + * routes organized in a tree-like structure. + */ +export type RouteObject = IndexRouteObject | NonIndexRouteObject; + +export type DataIndexRouteObject = IndexRouteObject & { + id: string; +}; + +export type DataNonIndexRouteObject = NonIndexRouteObject & { + children?: DataRouteObject[]; + id: string; +}; + +/** + * A data route object, which is just a RouteObject with a required unique ID + */ +export type DataRouteObject = DataIndexRouteObject | DataNonIndexRouteObject; + +/** + * A RouteMatch contains info about how a route matched a URL. + */ +export interface RouteMatch< + ParamKey extends string = string, + RouteObjectType extends RouteObject = RouteObject, +> { + /** + * The names and values of dynamic parameters in the URL. + */ + params: Params; + /** + * The portion of the URL pathname that was matched. + */ + pathname: string; + /** + * The portion of the URL pathname that was matched before child routes. + */ + pathnameBase: string; + /** + * The route object that was used to match. + */ + route: RouteObjectType; +} + +export interface DataRouteMatch extends RouteMatch {} +``` + +Key changes: + +- `AgnosticBaseRouteObject` → `BaseRouteObject` (add React fields) +- `AgnosticIndexRouteObject` → `IndexRouteObject` +- `AgnosticNonIndexRouteObject` → `NonIndexRouteObject` +- `AgnosticRouteObject` → `RouteObject` +- `AgnosticDataIndexRouteObject` → `DataIndexRouteObject` +- `AgnosticDataNonIndexRouteObject` → `DataNonIndexRouteObject` +- `AgnosticDataRouteObject` → `DataRouteObject` +- `AgnosticRouteMatch` → `RouteMatch` +- `AgnosticDataRouteMatch` → `DataRouteMatch` + +#### Step 2: Update `lib/context.ts` + +Convert to simple re-exports since the types now exist in `utils.ts`: + +```typescript +// Re-export route types from utils (they're now React-aware) +export type { + IndexRouteObject, + NonIndexRouteObject, + RouteObject, + DataRouteObject, + RouteMatch, + DataRouteMatch, +} from "./router/utils"; + +// PatchRoutesOnNavigationFunction types can now be simplified +export type PatchRoutesOnNavigationFunctionArgs = + AgnosticPatchRoutesOnNavigationFunctionArgs; + +export type PatchRoutesOnNavigationFunction = + AgnosticPatchRoutesOnNavigationFunction; +``` + +Remove duplicate type definitions and re-export from `utils.ts`: + +**Before:** +```typescriptimport or reference `Agnostic\*` types: + +**Files to update:** + +1. `lib/router/router.ts` - Update all `AgnosticDataRouteObject` → `DataRouteObject`, `AgnosticDataRouteMatch` → `DataRouteMatch`, `AgnosticRouteObject` → `RouteObject` +2. `lib/router/instrumentation.ts` - Update route type references +3. `lib/router/utils.ts` - Update internal function signatures, type parameters, and helper functions +4. `lib/dom/ssr/links.ts` - Update `AgnosticDataRouteMatch` → `DataRouteMatch` +5. `lib/rsc/server.rsc.ts` - Update `AgnosticDataRouteMatch` → `DataRouteMatch` +6. `lib/context.ts` - Update `AgnosticPatchRoutesOnNavigationFunctionArgs` references (or rename those too) +7. `__tests__/**/*.ts` - Update test type references (~40 files) + +**Note**: Some files may still reference `Agnostic*` types that are part of function/generic names (like `AgnosticPatchRoutesOnNavigationFunction`). These can be renamed in a follow-up or kept as-is if the name makes sense in context. + +```` + +**After:** +```typescript +// Re-export route types from utils (they're now React-aware) +export type { + IndexRouteObject, + NonIndexRouteObject, + RouteObject, + DataRouteObject, + RouteMatch, + DataRouteMatch, +} from "./router/utils"; + +// PatchRoutesOnNavigationFunction types remain the same +export type PatchRoutesOnNavigationFunctionArgs = + AgnosticPatchRoutesOnNavigationFunctionArgs; + +export type PatchRoutesOnNavigationFunction = + AgnosticPatchRoutesOnNavigationFunction; +```` + +This eliminates ~70 lines of duplicate type definitions.ction matchRoutes< +RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject + +> (...) + +// After +function matchRoutes< +RouteObjectType extends RouteObject = RouteObject + +> (...) + +```` + +#### Step 5: Update Helper Functions + +Update type guards and helper functions: + +```typescript +// Before +function isIndexRoute( + route: AgnosticRouteObject, +): route is AgnosticIndexRouteObject + +// After +function isIndexRoute( + route: RouteObject, +): route is IndexRouteObject +```` + +**No migration needed!** + +Since `Agnostic*` types are not part of the public API (not exported from `index.ts`), this is purely an internal refactoring. External consumers only use `RouteObject`, `DataRouteObject`, `RouteMatch`, and `DataRouteMatch` which remain unchanged in the public API.`RouteMatch`, `DataRouteMatch`) still exported + +**Internal API**: ⚠️ Requires updates + +- Functions using `Agnostic*` types need updating +- Tests using `Agnostic*` types need updating +- Backward compatibility aliases prevent hard breaks + +**Type Safety**: ✅ Maintained + +- All types remain strongly typed +- React-specific fields properly typed throughout +- No loss of type information + +### Migration Path for Consumers + +For any external code using the `Agnostic*` types (unlikely since they're not exported): + +1. **Short term**: Backward compatibility aliases work transparently +2. **Medium term**: Types marked `@internal` and `@deprecated` +3. **Long term**: Remove aliases in next major version + +### Testing Strategy + +1. **Type checks**: Ensure `pnpm typecheck` passes +2. **Unit tests**: Ensure `pnpm test` passes +3. **Integration tests**: Ensure `pnpm test:integration --project chromium` passes +4. **Build**: Ensure `pnpm build` succeeds +5. **Docs generation**: Ensure `pnpm docs` works correctly + +### Implementation Order + +### Implementation Order + +1. ✅ Create this plan document +2. ✅ Update `lib/router/utils.ts` - Rename `Agnostic*` types to remove prefix, add React fields to base types +3. ✅ Update `lib/context.ts` - Remove duplicate definitions, convert to re-exports +4. ✅ Update `lib/router/router.ts` - Replace all `Agnostic*` type references +5. ✅ Update `lib/router/instrumentation.ts` - Replace `Agnostic*` type references +6. ✅ Update `lib/dom/ssr/links.ts` - Replace `AgnosticDataRouteMatch` references +7. ✅ Update `lib/rsc/server.rsc.ts` - Replace `AgnosticDataRouteMatch` references +8. ✅ Update test files - Replace `Agnostic*` type references (~40 files) - No test files needed updating +9. ✅ Run full test suite (`pnpm typecheck && pnpm test && pnpm test:integration --project chromium`) - All passing +10. ✅ Update JSDoc comments if any reference "agnostic" or "framework-agnostic" +11. ✅ Create changeset + +### Risks & Mitigations + +Accidentally changing public API behavior + +- **Mitigation**: Public exports in `index.ts` remain unchanged, only importing from same location (`./lib/context`) + +**Risk**: Type inference changes in complex generic scenarios + +- **Mitigation**: All type fields remain identical, just location changes. Run full typecheck suite. + +**Risk**: Complex merge conflicts if done across multiple PRs + +- **Mitigation**: Complete in single atomic PR + +**Risk**: Missing some `Agnostic*` type references in large codebase + +- **Mitigation**: TypeScript compiler will catch all references; can also use global find/replace to identify all usages first +- **Mitigation**: Thorough testing at each step, update tests incrementally + +## Alternative Approaches Considered + +### Alternative 1: Rename to Remove "Agnostic" Prefix + +Move all types f2: In-Place Consolidation with Aliases + +Keep types in their current files but merge the layers by making the "agnostic" types React-aware and converting the React layer to aliases: + +- `AgnosticBaseRouteObject` stays in `utils.ts` but gains React fields +- React types in `context.ts` become aliases: `export type RouteObject = AgnosticRouteObject` +- Add `@deprecated` backward compatibility aliases + +**Pros**: Gradual migration path with deprecated aliases +**Cons**: Keeps confusing "Agnostic" naming, unnecessary since types aren't exported + +### Alternative 3but make React types standalone (not extending): + +- Both define all fields independently +- No inheritance relationship + +**Pros**: Minimal code changes +**Cons**: Doesn't reduce duplication, doesn't achieve goal + +### Alternative 3: Gradual Deprecation Path + +4: Gradual Deprecation Path + +Add new types, deprecate old ones, remove later: + +- Create `RouteObjectV2` with merged types +- Deprecate `AgnosticRouteObject` and React `RouteObject` +- Remove in next major + +**Pros**: Maximum safety, clear migration path +**Cons**: Temporary duplication, longer timeline, unnecessary complexity + +## Decision + +**Selected Approach**: Rename and Merge (Alternative 1, now main proposal) + +**Rationale**: + +1. ✅ Achieves goal of removing agnostic layer completely +2. ✅ **Non-breaking** - `Agnostic*` types are not in public API +3. ✅ Cleaner - removes confusing "Agnostic" prefix entirely +4. ✅ Simpler - no deprecated aliases needed since types aren't exported +5. ✅ Single atomic change is easier to review and test +6. ✅ Types live in one logical place (`utils.ts`) +7. ✅ Reduces ~70 lines of duplicate type definitions in `context.ts` + +## Follow-up Work + +After this refactoring: + +1. Consider if `RouteManifest` generic parameter is still needed +2. Evaluate if other "agnostic" patterns exist elsewhere +3. Update internal documentation about type architecture +4. Consider removing `@internal` aliases in v8 diff --git a/docs/api/components/Link.md b/docs/api/components/Link.md index 3aae2112eb..cd92fa643c 100644 --- a/docs/api/components/Link.md +++ b/docs/api/components/Link.md @@ -236,7 +236,7 @@ standard revalidation behavior. [modes: framework, data] -Masked path for for this navigation, when you want to navigate the router to +Masked path for this navigation, when you want to navigate the router to one location but display a separate location in the URL bar. This is useful for contextual navigations such as opening an image in a modal diff --git a/docs/api/hooks/useNavigate.md b/docs/api/hooks/useNavigate.md index 83aee981a3..4a1afe21cb 100644 --- a/docs/api/hooks/useNavigate.md +++ b/docs/api/hooks/useNavigate.md @@ -103,7 +103,7 @@ navigate(1); Be cautious with `navigate(number)`. If your application can load up to a route that has a button that tries to navigate forward/back, there may not be -a `[`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) +a [`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) entry to go back or forward to, or it can go somewhere you don't expect (like a different domain). diff --git a/docs/api/utils/matchRoutes.md b/docs/api/utils/matchRoutes.md index 6fd028649b..7c31862bc7 100644 --- a/docs/api/utils/matchRoutes.md +++ b/docs/api/utils/matchRoutes.md @@ -42,13 +42,11 @@ matchRoutes(routes, "/dashboard"); // [rootMatch, dashboardMatch] ## Signature ```tsx -function matchRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +function matchRoutes( routes: RouteObjectType[], locationArg: Partial | string, basename = "/", -): AgnosticRouteMatch[] | null +): RouteMatch[] | null ``` ## Params diff --git a/docs/start/framework/testing.md b/docs/start/framework/testing.md index dd2059f020..536f97edf8 100644 --- a/docs/start/framework/testing.md +++ b/docs/start/framework/testing.md @@ -80,7 +80,7 @@ test("LoginForm renders error messages", async () => { ## Using with Framework Mode Types -It's important to note that `createRoutesStub` is designed for _unit_ testing of reusable components in your application that rely on on contextual router information (i.e., `loaderData`, `actionData`, `matches`). These components usually obtain this information via the hooks (`useLoaderData`, `useActionData`, `useMatches`) or via props passed down from the ancestor route component. We **strongly** recommend limiting your usage of `createRoutesStub` to unit testing of these types of reusable components. +It's important to note that `createRoutesStub` is designed for _unit_ testing of reusable components in your application that rely on contextual router information (i.e., `loaderData`, `actionData`, `matches`). These components usually obtain this information via the hooks (`useLoaderData`, `useActionData`, `useMatches`) or via props passed down from the ancestor route component. We **strongly** recommend limiting your usage of `createRoutesStub` to unit testing of these types of reusable components. `createRoutesStub` is _not designed_ for (and is arguably incompatible with) direct testing of Route components using the [`Route.\*`](../../explanation/type-safety) types available in Framework Mode. This is because the `Route.*` types are derived from your actual application - including the real `loader`/`action` functions as well as the structure of your route tree structure (which defines the `matches` type). When you use `createRoutesStub`, you are providing stubbed values for `loaderData`, `actionData`, and even your `matches` based on the route tree you pass to `createRoutesStub`. Therefore, the types won't align with the `Route.*` types and you'll get type issues trying to use a route component in a route stub. diff --git a/docs/upgrading/future.md b/docs/upgrading/future.md index afdbbf9e61..71c265bf83 100644 --- a/docs/upgrading/future.md +++ b/docs/upgrading/future.md @@ -5,7 +5,7 @@ order: 1 # Future Flags and Deprecations -This guide walks you through the process of adopting future flags in your React Router app. By following this strategy, you will be able to upgrade to the next major version of React Router with minimal changes. To read more about future flags see [API Development Strategy](../community/api-development-strategy). +This guide walks you through the process of adopting future flags in your React Router app. By following this strategy, you will be able to upgrade to the next major version of React Router with minimal changes. To read more about future flags see [API Development Strategy][api-development-strategy]. We highly recommend you make a commit after each step and ship it instead of doing everything all at once. Most flags can be adopted in any order, with exceptions noted below. @@ -104,5 +104,75 @@ export default { No code changes are required unless you have custom Vite configuration that needs to be updated for the [Environment API][vite-environment]. Most users won't need to make any changes. +## Unstable Future Flags (Optional) + +We document some [unstable] flags here as a reference for folks contributing to the project via beta testing, but they are not generally recommended for production use and may having breaking changes patch/minor releases - adopt with caution! + +### future.unstable_passThroughRequests + +[MODES: framework] + +
+
+ +**Background** + +By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details. Specifically, it removes `.data` suffixes and internal search parameters like `?index` and `?_routes`. + +This flag eliminates that normalization and passes the raw HTTP `request` instance to your handlers. This provides a few benefits: + +- Reduces server-side overhead by eliminating multiple `new Request()` calls on the critical path +- Allows you to distinguish document from data requests in your handlers base don the presence of a `.data` suffix (useful for [observability] purposes) + +If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location. + +👉 **Enable the Flag** + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + unstable_passThroughRequests: true, + }, +} satisfies Config; +``` + +**Update your Code** + +If your code relies on inspecting the request URL, you should review it for any assumptions about the URL format: + +```tsx +// ❌ Before: assuming no `.data` suffix in `request.url` pathname +export async function loader({ + request, +}: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check might now behave differently because the request pathname will + // contain the `.data` suffix on data requests + } +} + +// ✅ After: use `unstable_url` for normalized routing logic and `request.url` +// for raw routing logic +export async function loader({ + request, + unstable_url, +}: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL( + request.url, + ).pathname.endsWith(".data"); +} +``` + +[api-development-strategy]: ../community/api-development-strategy +[unstable]: ../community/api-development-strategy#unstable-flags +[observability]: ../how-to/instrumentation [Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response [vite-environment]: https://vite.dev/guide/api-environment diff --git a/examples/modal/package-lock.json b/examples/modal/package-lock.json index 08cb323fe0..da288722cb 100644 --- a/examples/modal/package-lock.json +++ b/examples/modal/package-lock.json @@ -6,16 +6,15 @@ "": { "name": "modal", "dependencies": { - "@reach/dialog": "0.18.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router-dom": "^6.15.0" }, "devDependencies": { "@rollup/plugin-replace": "^5.0.2", "@types/node": "18.x", - "@types/react": "^17.0.59", - "@types/react-dom": "^17.0.20", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^3.0.1", "typescript": "^4.9.5", "vite": "^4.0.4" @@ -314,17 +313,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", - "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==", - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz", @@ -780,51 +768,6 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, - "node_modules/@reach/dialog": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/dialog/-/dialog-0.18.0.tgz", - "integrity": "sha512-hWhzmBK8VJj+yf6OivFqHFZIV4q9TISZrkPaglKE5oFYtrr75lxWjrBTA+BshL0r/FfKodvNtdT8yq4vj/6Gcw==", - "dependencies": { - "@reach/polymorphic": "0.18.0", - "@reach/portal": "0.18.0", - "@reach/utils": "0.18.0", - "react-focus-lock": "2.5.2", - "react-remove-scroll": "2.4.3" - }, - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/polymorphic": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/polymorphic/-/polymorphic-0.18.0.tgz", - "integrity": "sha512-N9iAjdMbE//6rryZZxAPLRorzDcGBnluf7YQij6XDLiMtfCj1noa7KyLpEc/5XCIB/EwhX3zCluFAwloBKdblA==", - "peerDependencies": { - "react": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/portal": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/portal/-/portal-0.18.0.tgz", - "integrity": "sha512-TImozRapd576ofRk30Le2L3lRTFXF1p47B182wnp5eMTdZa74JX138BtNGEPJFOyrMaVmguVF8SSwZ6a0fon1Q==", - "dependencies": { - "@reach/utils": "0.18.0" - }, - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/utils": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/utils/-/utils-0.18.0.tgz", - "integrity": "sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A==", - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, "node_modules/@remix-run/router": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.8.0.tgz", @@ -892,34 +835,29 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { - "version": "17.0.60", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.60.tgz", - "integrity": "sha512-pCH7bqWIfzHs3D+PDs3O/COCQJka+Kcw3RnO9rFA2zalqoXg7cNjJDh6mZ7oRtY1wmY4LVwDdAbA1F7Z8tv3BQ==", - "devOptional": true, + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "17.0.20", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", - "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, - "dependencies": { - "@types/react": "^17" + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" } }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "devOptional": true - }, "node_modules/@vitejs/plugin-react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.1.0.tgz", @@ -1039,10 +977,11 @@ "dev": true }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "devOptional": true + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" }, "node_modules/debug": { "version": "4.3.4", @@ -1061,11 +1000,6 @@ } } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "node_modules/electron-to-chromium": { "version": "1.4.419", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.419.tgz", @@ -1133,17 +1067,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, - "node_modules/focus-lock": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.2.tgz", - "integrity": "sha512-YtHxjX7a0IC0ZACL5wsX8QdncXofWpGPNoVMuI/nZUrPGp6LmNI6+D5j0pPj+v8Kw5EpweA+T5yImK0rnWf7oQ==", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1167,14 +1090,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -1193,14 +1108,6 @@ "node": ">=4" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1292,14 +1199,6 @@ "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", "dev": true }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -1346,73 +1245,31 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/react-clientside-effect": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", - "integrity": "sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==", - "dependencies": { - "@babel/runtime": "^7.12.13" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/react-focus-lock": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.5.2.tgz", - "integrity": "sha512-WzpdOnEqjf+/A3EH9opMZWauag7gV0BxFl+EY4ElA4qFqYsUsBLnmo2sELbN5OC30S16GAWMy16B9DLPpdJKAQ==", - "dependencies": { - "@babel/runtime": "^7.0.0", - "focus-lock": "^0.9.1", - "prop-types": "^15.6.2", - "react-clientside-effect": "^1.2.5", - "use-callback-ref": "^1.2.5", - "use-sidecar": "^1.0.5" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0" + "react": "^18.3.1" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -1422,56 +1279,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.3.tgz", - "integrity": "sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q==", - "dependencies": { - "react-remove-scroll-bar": "^2.1.0", - "react-style-singleton": "^2.1.0", - "tslib": "^1.0.0", - "use-callback-ref": "^1.2.3", - "use-sidecar": "^1.0.1" - }, - "engines": { - "node": ">=8.5.0" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/react-router": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.15.0.tgz", @@ -1502,33 +1309,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" - }, "node_modules/rollup": { "version": "3.23.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.1.tgz", @@ -1546,12 +1326,12 @@ } }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -1593,11 +1373,6 @@ "node": ">=4" } }, - "node_modules/tslib": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", - "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" - }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -1641,47 +1416,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/vite": { "version": "4.3.9", "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz", diff --git a/examples/modal/package.json b/examples/modal/package.json index c665689c27..b6a0b9e621 100644 --- a/examples/modal/package.json +++ b/examples/modal/package.json @@ -7,16 +7,15 @@ "serve": "vite preview" }, "dependencies": { - "@reach/dialog": "0.18.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router-dom": "^6.15.0" }, "devDependencies": { "@rollup/plugin-replace": "^5.0.2", "@types/node": "18.x", - "@types/react": "^17.0.59", - "@types/react-dom": "^17.0.20", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^3.0.1", "typescript": "^4.9.5", "vite": "^4.0.4" diff --git a/examples/modal/src/App.tsx b/examples/modal/src/App.tsx index 50aece0aa0..2e092a493b 100644 --- a/examples/modal/src/App.tsx +++ b/examples/modal/src/App.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { createPortal } from "react-dom"; import { Routes, Route, @@ -8,8 +9,6 @@ import { useNavigate, useParams, } from "react-router-dom"; -import { Dialog } from "@reach/dialog"; -import "@reach/dialog/styles.css"; import { IMAGES, getImageById } from "./images"; @@ -228,3 +227,124 @@ function NoMatch() { ); } + +type DialogProps = { + children: React.ReactNode; + onDismiss: () => void; + "aria-label"?: string; + "aria-labelledby"?: string; + initialFocusRef?: React.RefObject; +}; + +function Dialog({ + children, + onDismiss, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + initialFocusRef, +}: DialogProps) { + let overlayRef = React.useRef(null); + let contentRef = React.useRef(null); + let previouslyFocusedRef = React.useRef(null); + + React.useEffect(() => { + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + + let focusTarget = initialFocusRef?.current ?? contentRef.current; + if (focusTarget) { + focusTarget.focus(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onDismiss(); + return; + } + + if (event.key === "Tab") { + let container = contentRef.current; + if (!container) return; + + let focusable = getFocusableElements(container); + if (focusable.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + + let activeElement = document.activeElement as HTMLElement | null; + let currentIndex = focusable.indexOf(activeElement ?? focusable[0]); + + let nextIndex = currentIndex; + if (event.shiftKey) { + nextIndex = + currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1; + } else { + nextIndex = + currentIndex === focusable.length - 1 ? 0 : currentIndex + 1; + } + + event.preventDefault(); + focusable[nextIndex].focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocusedRef.current?.focus(); + }; + }, [initialFocusRef, onDismiss]); + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onDismiss(); + } + }} + style={{ + position: "fixed", + inset: 0, + background: "rgba(0, 0, 0, 0.55)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "24px", + zIndex: 1000, + }} + > +
+ {children} +
+
, + document.body, + ); +} + +function getFocusableElements(container: HTMLElement) { + return Array.from( + container.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + ), + ).filter((element) => !element.hasAttribute("disabled")); +} diff --git a/integration/passthrough-requests-test.ts b/integration/passthrough-requests-test.ts new file mode 100644 index 0000000000..e9b1e19ba2 --- /dev/null +++ b/integration/passthrough-requests-test.ts @@ -0,0 +1,231 @@ +import { test, expect } from "@playwright/test"; +import { + type AppFixture, + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { reactRouterConfig } from "./helpers/vite.js"; + +const files = { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader() { + return "ROOT"; + } + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function shouldRevalidate({ defaultShouldRevalidate }) { + return defaultShouldRevalidate; + } + `, + "app/routes/_index.tsx": js` + import { Form, Link } from "react-router"; + + export function loader({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export function action({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export default function Component({ loaderData, actionData }) { + return ( + <> + Add param +
+ +
+ + Go to new page + +

{loaderData.url}

+

{loaderData.path}

+ {actionData ? + <> +

{actionData.url}

+

{actionData.path}

+ : + null} + + ) + } + `, + "app/routes/page.tsx": js` + export function loader({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export default function Component({ loaderData }) { + return ( + <> +

{loaderData.url}

+

{loaderData.path}

+ + ) + } + `, +}; + +test.describe("pass through requests", () => { + test("sends proper arguments to loaders when future.unstable_passThroughRequests is disabled", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + future: { + unstable_passThroughRequests: false, + }, + }), + ...files, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load + await app.goto("/"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/"); + expect(await page.locator("[data-loader-path]").textContent()).toBe("/"); + + // Client-side navigation with query params + await app.clickLink("/?a=1"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?a=1"]); + requests = []; + + // Client-side form submission with query params + await app.clickElement('button[type="submit"]'); + expect(await page.locator("[data-action-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-action-path]").textContent()).toBe( + "/?a=1", + ); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?index&a=1", "/_root.data?a=1"]); + requests = []; + + // Navigate to new page + await app.clickLink("/page?b=2"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/page?b=2", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/page?b=2", + ); + expect(requests).toEqual(["/page.data?b=2&_routes=routes%2Fpage"]); + requests = []; + }); + + test("sends proper arguments to loaders when future.unstable_passThroughRequests is enabled", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + future: { + unstable_passThroughRequests: true, + }, + }), + ...files, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load + await app.goto("/"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/"); + expect(await page.locator("[data-loader-path]").textContent()).toBe("/"); + + // Client-side navigation with query params + await app.clickLink("/?a=1"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/_root.data?a=1", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?a=1"]); + requests = []; + + // Client-side form submission with query params + await app.clickElement('button[type="submit"]'); + expect(await page.locator("[data-action-url]").textContent()).toBe( + "/_root.data?index&a=1", + ); + expect(await page.locator("[data-action-path]").textContent()).toBe( + "/?a=1", + ); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/_root.data?a=1", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?index&a=1", "/_root.data?a=1"]); + requests = []; + + // Navigate to new page + await app.clickLink("/page?b=2"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/page.data?b=2&_routes=routes%2Fpage", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/page?b=2", + ); + expect(requests).toEqual(["/page.data?b=2&_routes=routes%2Fpage"]); + requests = []; + }); +}); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 4c2eb5a979..abf835389a 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -245,6 +245,7 @@ test.describe("Vite / presets", async () => { // Ensure future flags from presets are properly merged expect(buildEndArgsMeta.futureFlags).toEqual({ unstable_optimizeDeps: true, + unstable_passThroughRequests: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: false, unstable_previewServerPrerendering: false, diff --git a/package.json b/package.json index 568a7299e6..581823b18f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@typescript-eslint/parser": "^7.5.0", "babel-jest": "^29.7.0", "babel-plugin-dev-expression": "^0.2.3", - "chalk": "^4.1.2", + "picocolors": "^1.1.1", "dox": "^1.0.0", "eslint": "^8.57.0", "eslint-config-react-app": "^7.0.1", diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 0784fd5bb9..4f7c02a71c 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,11 @@ # `create-react-router` +## 7.13.2 + +### Patch Changes + +- chore: replace chalk with picocolors ([#14837](https://github.com/remix-run/react-router/pull/14837)) + ## 7.13.1 ## 7.13.0 diff --git a/packages/create-react-router/__tests__/create-react-router-test.ts b/packages/create-react-router/__tests__/create-react-router-test.ts index c1ac99c31c..3bb7a31a8e 100644 --- a/packages/create-react-router/__tests__/create-react-router-test.ts +++ b/packages/create-react-router/__tests__/create-react-router-test.ts @@ -1205,6 +1205,9 @@ async function execCreateReactRouter({ env: { ...process.env, ...env, + NODE_OPTIONS: [process.env.NODE_OPTIONS, "--no-deprecation"] + .filter(Boolean) + .join(" "), ...(interactive ? { CREATE_REACT_ROUTER_FORCE_INTERACTIVE: "true" } : {}), diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index a2df126d02..8aa481a8a5 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.13.1", + "version": "7.13.2", "description": "Create a new React Router app", "homepage": "https://reactrouter.com", "bugs": { @@ -39,7 +39,7 @@ "dependencies": { "@remix-run/web-fetch": "^4.4.2", "arg": "^5.0.1", - "chalk": "^4.1.2", + "picocolors": "^1.1.1", "execa": "5.1.1", "gunzip-maybe": "^1.4.2", "log-update": "^5.0.1", diff --git a/packages/create-react-router/utils.ts b/packages/create-react-router/utils.ts index 0effcdb084..6c30c56c85 100644 --- a/packages/create-react-router/utils.ts +++ b/packages/create-react-router/utils.ts @@ -5,56 +5,69 @@ import process from "node:process"; import os from "node:os"; import { type Key as ActionKey } from "node:readline"; import { erase, cursor } from "sisteransi"; -import chalk from "chalk"; +import pc from "picocolors"; // https://no-color.org/ -const SUPPORTS_COLOR = chalk.supportsColor && !process.env.NO_COLOR; +// picocolors natively respects NO_COLOR and FORCE_COLOR env vars +const SUPPORTS_COLOR = pc.isColorSupported; export const color = { supportsColor: SUPPORTS_COLOR, - heading: safeColor(chalk.bold), - arg: safeColor(chalk.yellowBright), - error: safeColor(chalk.red), - warning: safeColor(chalk.yellow), - hint: safeColor(chalk.blue), - bold: safeColor(chalk.bold), - black: safeColor(chalk.black), - white: safeColor(chalk.white), - blue: safeColor(chalk.blue), - cyan: safeColor(chalk.cyan), - red: safeColor(chalk.red), - yellow: safeColor(chalk.yellow), - green: safeColor(chalk.green), - blackBright: safeColor(chalk.blackBright), - whiteBright: safeColor(chalk.whiteBright), - blueBright: safeColor(chalk.blueBright), - cyanBright: safeColor(chalk.cyanBright), - redBright: safeColor(chalk.redBright), - yellowBright: safeColor(chalk.yellowBright), - greenBright: safeColor(chalk.greenBright), - bgBlack: safeColor(chalk.bgBlack), - bgWhite: safeColor(chalk.bgWhite), - bgBlue: safeColor(chalk.bgBlue), - bgCyan: safeColor(chalk.bgCyan), - bgRed: safeColor(chalk.bgRed), - bgYellow: safeColor(chalk.bgYellow), - bgGreen: safeColor(chalk.bgGreen), - bgBlackBright: safeColor(chalk.bgBlackBright), - bgWhiteBright: safeColor(chalk.bgWhiteBright), - bgBlueBright: safeColor(chalk.bgBlueBright), - bgCyanBright: safeColor(chalk.bgCyanBright), - bgRedBright: safeColor(chalk.bgRedBright), - bgYellowBright: safeColor(chalk.bgYellowBright), - bgGreenBright: safeColor(chalk.bgGreenBright), - gray: safeColor(chalk.gray), - dim: safeColor(chalk.dim), - reset: safeColor(chalk.reset), - inverse: safeColor(chalk.inverse), - hex: (color: string) => safeColor(chalk.hex(color)), - underline: chalk.underline, + heading: safeColor(pc.bold), + arg: safeColor(pc.yellowBright), + error: safeColor(pc.red), + warning: safeColor(pc.yellow), + hint: safeColor(pc.blue), + bold: safeColor(pc.bold), + black: safeColor(pc.black), + white: safeColor(pc.white), + blue: safeColor(pc.blue), + cyan: safeColor(pc.cyan), + red: safeColor(pc.red), + yellow: safeColor(pc.yellow), + green: safeColor(pc.green), + blackBright: safeColor(pc.blackBright), + whiteBright: safeColor(pc.whiteBright), + blueBright: safeColor(pc.blueBright), + cyanBright: safeColor(pc.cyanBright), + redBright: safeColor(pc.redBright), + yellowBright: safeColor(pc.yellowBright), + greenBright: safeColor(pc.greenBright), + bgBlack: safeColor(pc.bgBlack), + bgWhite: safeColor(pc.bgWhite), + bgBlue: safeColor(pc.bgBlue), + bgCyan: safeColor(pc.bgCyan), + bgRed: safeColor(pc.bgRed), + bgYellow: safeColor(pc.bgYellow), + bgGreen: safeColor(pc.bgGreen), + bgBlackBright: safeColor(pc.bgBlackBright), + bgWhiteBright: safeColor(pc.bgWhiteBright), + bgBlueBright: safeColor(pc.bgBlueBright), + bgCyanBright: safeColor(pc.bgCyanBright), + bgRedBright: safeColor(pc.bgRedBright), + bgYellowBright: safeColor(pc.bgYellowBright), + bgGreenBright: safeColor(pc.bgGreenBright), + gray: safeColor(pc.gray), + dim: safeColor(pc.dim), + reset: safeColor(pc.reset), + inverse: safeColor(pc.inverse), + hex: (hex: string) => safeColor(hexColor(hex)), + underline: pc.underline, }; -function safeColor(style: chalk.Chalk) { +/** + * Converts a hex color string to an ANSI true-color (24-bit) formatter. + * Used by the loading indicator gradient animation. + */ +function hexColor(hex: string): (input: string) => string { + let h = hex.replace("#", ""); + let r = parseInt(h.substring(0, 2), 16); + let g = parseInt(h.substring(2, 4), 16); + let b = parseInt(h.substring(4, 6), 16); + return (input: string) => `\x1b[38;2;${r};${g};${b}m${input}\x1b[39m`; +} + +function safeColor(style: (input: string) => string) { return SUPPORTS_COLOR ? style : identity; } @@ -93,8 +106,8 @@ export function logError(message: string) { function logBullet( logger: typeof log | typeof logError, - colorizePrefix: (v: V) => V, - colorizeText: (v: V) => V, + colorizePrefix: (v: string) => string, + colorizeText: (v: string) => string, symbol: string, prefix: string, text?: string | string[], diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index c8c119e4ca..db623847bd 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + - `@react-router/node@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index dbe134378c..eee863494e 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.13.1", + "version": "7.13.2", "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 00809f3abf..f94eba0164 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 6bb01d8ad4..6630574b8a 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.13.1", + "version": "7.13.2", "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 aad0e8732d..94ba71fd1c 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,56 @@ # `@react-router/dev` +## 7.13.2 + +### Patch Changes + +- Fix `react-router dev` crash when Unix socket files exist in the project root ([#14854](https://github.com/remix-run/react-router/pull/14854)) + +- Escape redirect locations in prerendered redirect HTML ([#14880](https://github.com/remix-run/react-router/pull/14880)) + +- Add `future.unstable_passThroughRequests` flag ([#14775](https://github.com/remix-run/react-router/pull/14775)) + + By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details (`.data` suffixes, `index` + `_routes` query params). + + Enabling this flag removes that normalization and passes the raw HTTP `request` instance to your handlers. This provides a few benefits: + - Reduces server-side overhead by eliminating multiple `new Request()` calls on the critical path + - Allows you to distinguish document from data requests in your handlers base don the presence of a `.data` suffix (useful for observability purposes) + + If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location: + + ```tsx + // ❌ Before: you could assume there was no `.data` suffix in `request.url` + export async function loader({ request }: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check will fail with the flag enabled because the `.data` suffix will + // exist on data requests + } + } + + // ✅ After: use `unstable_url` for normalized routing logic and `request.url` + // for raw routing logic + export async function loader({ request, unstable_url }: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL(request.url).pathname.endsWith(".data"); + } + ``` + +- Add a new `unstable_url: URL` parameter to route handler methods (`loader`, `action`, `middleware`, etc.) representing the normalized URL the application is navigating to or fetching, with React Router implementation details removed (`.data`suffix, `index`/`_routes` query params) ([#14775](https://github.com/remix-run/react-router/pull/14775)) + + This is being added alongside the new `future.unstable_passthroughRequests` future flag so that users still have a way to access the normalized URL when that flag is enabled and non-normalized `request`'s are being passed to your handlers. When adopting this flag, you will only need to start leveraging this new parameter if you are relying on the normalization of `request.url` in your application code. + + If you don't have the flag enabled, then `unstable_url` will match `request.url`. + +- Updated dependencies: + - `react-router@7.13.2` + - `@react-router/node@7.13.2` + - `@react-router/serve@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-dev/__tests__/watcher-ignored-test.ts b/packages/react-router-dev/__tests__/watcher-ignored-test.ts new file mode 100644 index 0000000000..6d06533b7b --- /dev/null +++ b/packages/react-router-dev/__tests__/watcher-ignored-test.ts @@ -0,0 +1,70 @@ +import path from "node:path"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; + +import { isIgnoredByWatcher } from "../config/config"; + +describe("isIgnoredByWatcher", () => { + let tmpDir: string; + let root: string; + let appDirectory: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rr-watcher-test-")); + root = tmpDir; + appDirectory = path.join(root, "app"); + fs.mkdirSync(appDirectory, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("does not ignore regular files at root level", () => { + let filePath = path.join(root, "react-router.config.ts"); + fs.writeFileSync(filePath, ""); + expect(isIgnoredByWatcher(filePath, { root, appDirectory })).toBe(false); + }); + + it("does not ignore the root directory itself", () => { + expect(isIgnoredByWatcher(root, { root, appDirectory })).toBe(false); + }); + + it("does not ignore files inside app directory", () => { + let filePath = path.join(appDirectory, "root.tsx"); + fs.writeFileSync(filePath, ""); + expect(isIgnoredByWatcher(filePath, { root, appDirectory })).toBe(false); + }); + + it("ignores files in subdirectories outside the app directory", () => { + let subDir = path.join(root, "node_modules", "some-package"); + fs.mkdirSync(subDir, { recursive: true }); + let filePath = path.join(subDir, "index.js"); + fs.writeFileSync(filePath, ""); + expect(isIgnoredByWatcher(filePath, { root, appDirectory })).toBe(true); + }); + + it("ignores Unix socket files at the root level", async () => { + let socketPath = path.join(root, "overmind.sock"); + let server = net.createServer(); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + expect(isIgnoredByWatcher(socketPath, { root, appDirectory })).toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("ignores paths that cannot be stat'd", () => { + let nonexistent = path.join(root, "ghost.sock"); + // File doesn't exist — statSync with throwIfNoEntry: false returns + // undefined, so this should fall through to `return false`. But if + // statSync throws for another reason, the catch block returns true. + // Here we just verify it doesn't throw. + expect(() => + isIgnoredByWatcher(nonexistent, { root, appDirectory }), + ).not.toThrow(); + }); +}); diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 0052aeb646..f3269803b8 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -86,6 +86,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; /** @@ -684,6 +685,8 @@ async function resolveConfig({ let future: FutureConfig = { unstable_optimizeDeps: userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, + unstable_passThroughRequests: + userAndPresetConfigs.future?.unstable_passThroughRequests ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, unstable_trailingSlashAwareDataRequests: @@ -815,17 +818,12 @@ export async function createConfigLoader({ if (!fsWatcher) { fsWatcher = chokidar.watch([root, appDirectory], { ignoreInitial: true, - ignored: (path) => { - let dirname = Path.dirname(path); - - return ( - !dirname.startsWith(appDirectory) && - // Ensure we're only watching files outside of the app directory - // that are at the root level, not nested in subdirectories - path !== root && // Watch the root directory itself - dirname !== root // Watch files at the root level - ); - }, + ignored: (path) => isIgnoredByWatcher(path, { root, appDirectory }), + }); + + fsWatcher.on("error", (error: unknown) => { + let message = error instanceof Error ? error.message : String(error); + console.warn(colors.yellow(`File watcher error: ${message}`)); }); fsWatcher.on("all", async (...args) => { @@ -1163,3 +1161,35 @@ function isEntryFileDependency( return false; } + +export function isIgnoredByWatcher( + path: string, + { root, appDirectory }: { root: string; appDirectory: string }, +): boolean { + let dirname = Path.dirname(path); + + let ignoredByPath = + !dirname.startsWith(appDirectory) && + // Ensure we're only watching files outside of the app directory + // that are at the root level, not nested in subdirectories + path !== root && // Watch the root directory itself + dirname !== root; // Watch files at the root level + + if (ignoredByPath) { + return true; + } + + // Filter out non-regular files (sockets, pipes, etc.) that + // crash `fs.watch()` on macOS with errno -102 + // https://github.com/paulmillr/chokidar/issues/1391 + try { + let stat = fs.statSync(path, { throwIfNoEntry: false }); + if (stat && !stat.isFile() && !stat.isDirectory()) { + return true; + } + } catch { + return true; + } + + return false; +} diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index cbd36e9bd5..071d382389 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.13.1", + "version": "7.13.2", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com", "bugs": { diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6eaf1297a3..6dd897b04f 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -2752,15 +2752,17 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { // A short delay causes Google to interpret the redirect as temporary. // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh let delay = response.status === 302 ? 2 : 0; + let escapedLocation = escapeHtml(location ?? ""); + let escapedPathname = escapeHtml(pathname); html = ` -Redirecting to: ${location} - +Redirecting to: ${escapedLocation} + - - Redirecting from ${pathname} to ${location} + + Redirecting from ${escapedPathname} to ${escapedLocation} `; @@ -3345,15 +3347,17 @@ async function prerenderRoute( // A short delay causes Google to interpret the redirect as temporary. // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh let delay = response.status === 302 ? 2 : 0; + let escapedLocation = escapeHtml(location ?? ""); + let escapedNormalizedPath = escapeHtml(normalizedPath); html = ` -Redirecting to: ${location} - +Redirecting to: ${escapedLocation} + - - Redirecting from ${normalizedPath} to ${location} + + Redirecting from ${escapedNormalizedPath} to ${escapedLocation} `; @@ -4328,3 +4332,18 @@ function createSpaModeRequest( metadata: { type: "spa", path: "/" }, }; } + +// Note: Duplicated from react-router/lib/dom/ssr/markup +// Must be kept in sync with the original implementation. +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; +const ESCAPE_LOOKUP: { [match: string]: string } = { + "&": "\\u0026", + ">": "\\u003e", + "<": "\\u003c", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +function escapeHtml(html: string) { + return html.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); +} diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 08ae899ab4..cc18319f99 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index acf8f7e4fe..78ff090a7a 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.13.1", + "version": "7.13.2", "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 e3a04f2727..afcaecb26d 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + - `@react-router/node@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index 27cdb9fae2..74cf7546dd 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.13.1", + "version": "7.13.2", "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 57efb2e6c3..430d52e93d 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.13.2 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index d1782e99d3..a07087298d 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.13.1", + "version": "7.13.2", "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 46f78a092c..a95a36181d 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 5d44e20d50..503e94a189 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.13.1", + "version": "7.13.2", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index 8f58373864..b26491c899 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.13.2 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 874d21e0f9..2228e95656 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.13.1", + "version": "7.13.2", "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 ba58db9041..91f64047b4 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.13.2 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.13.2` + - `@react-router/node@7.13.2` + - `@react-router/express@7.13.2` + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 61fdba42e0..0b59a2e4a4 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.13.1", + "version": "7.13.2", "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 fe148cf413..8a36ebe2be 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,57 @@ # `react-router` +## 7.13.2 + +### Patch Changes + +- Fix clientLoader.hydrate when an ancestor route is also hydrating a clientLoader ([#14835](https://github.com/remix-run/react-router/pull/14835)) + +- Fix type error when passing Framework Mode route components using `Route.ComponentProps` to `createRoutesStub` ([#14892](https://github.com/remix-run/react-router/pull/14892)) + +- Fix percent encoding in relative path navigation ([#14786](https://github.com/remix-run/react-router/pull/14786)) + +- Add `future.unstable_passThroughRequests` flag ([#14775](https://github.com/remix-run/react-router/pull/14775)) + + By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details (`.data` suffixes, `index` + `_routes` query params). + + Enabling this flag removes that normalization and passes the raw HTTP `request` instance to your handlers. This provides a few benefits: + - Reduces server-side overhead by eliminating multiple `new Request()` calls on the critical path + - Allows you to distinguish document from data requests in your handlers base don the presence of a `.data` suffix (useful for observability purposes) + + If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location: + + ```tsx + // ❌ Before: you could assume there was no `.data` suffix in `request.url` + export async function loader({ request }: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check will fail with the flag enabled because the `.data` suffix will + // exist on data requests + } + } + + // ✅ After: use `unstable_url` for normalized routing logic and `request.url` + // for raw routing logic + export async function loader({ request, unstable_url }: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL(request.url).pathname.endsWith(".data"); + } + ``` + +- Internal refactor to consolidate framework-agnostic/React-specific route type layers - no public API changes ([#14765](https://github.com/remix-run/react-router/pull/14765)) + +- Sync protocol validation to rsc flows ([#14882](https://github.com/remix-run/react-router/pull/14882)) + +- Add a new `unstable_url: URL` parameter to route handler methods (`loader`, `action`, `middleware`, etc.) representing the normalized URL the application is navigating to or fetching, with React Router implementation details removed (`.data`suffix, `index`/`_routes` query params) ([#14775](https://github.com/remix-run/react-router/pull/14775)) + + This is being added alongside the new `future.unstable_passthroughRequests` future flag so that users still have a way to access the normalized URL when that flag is enabled and non-normalized `request`'s are being passed to your handlers. When adopting this flag, you will only need to start leveraging this new parameter if you are relying on the normalization of `request.url` in your application code. + + If you don't have the flag enabled, then `unstable_url` will match `request.url`. + ## 7.13.1 ### Patch Changes diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index ea8736a4f1..aa2d85d44d 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -336,6 +336,74 @@ function testDomRouter( `); }); + it("renders ancestor HydrateFallback during hydration middleware-only execution", async () => { + let middlewareDfd = createDeferred(); + let router = createTestRouter([ + { + path: "/", + Component: Outlet, + HydrateFallback: () => "Loading root...", + children: [ + { + index: true, + middleware: [() => middlewareDfd.promise], + Component: () => "Hello World!", + }, + ], + }, + ]); + + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ Loading root... +
" + `); + + middlewareDfd.resolve(); + await waitFor(() => screen.getByText("Hello World!")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ Hello World! +
" + `); + }); + + it("renders self HydrateFallback during hydration middleware-only execution", async () => { + let middlewareDfd = createDeferred(); + let router = createTestRouter([ + { + path: "/", + Component: Outlet, + children: [ + { + index: true, + HydrateFallback: () => "Loading index...", + middleware: [() => middlewareDfd.promise], + Component: () => "Hello World!", + }, + ], + }, + ]); + + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ Loading index... +
" + `); + + middlewareDfd.resolve(); + await waitFor(() => screen.getByText("Hello World!")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ Hello World! +
" + `); + }); + it("does not render hydrateFallback if no data fetch or lazy loading is required", async () => { let fooDefer = createDeferred(); let router = createTestRouter( diff --git a/packages/react-router/__tests__/dom/partial-hydration-test.tsx b/packages/react-router/__tests__/dom/partial-hydration-test.tsx index 9b61ac66ba..6ec3501380 100644 --- a/packages/react-router/__tests__/dom/partial-hydration-test.tsx +++ b/packages/react-router/__tests__/dom/partial-hydration-test.tsx @@ -66,9 +66,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ @@ -155,9 +152,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ @@ -248,9 +242,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, async patchRoutesOnNavigation({ path, patch }) { await patchDfd.promise; if (path === "/parent/child") { @@ -853,4 +844,76 @@ function testPartialHydration( expect(rootSpy).toHaveBeenCalledTimes(1); expect(indexSpy).not.toHaveBeenCalled(); }); + + it("renders child fallback when ancestor route has hydration data and a hydrating loader", async () => { + let rootDfd = createDeferred(); + let rootLoader: LoaderFunction = () => rootDfd.promise; + rootLoader.hydrate = true; + let indexDfd = createDeferred(); + let indexLoader: LoaderFunction = () => indexDfd.promise; + indexLoader.hydrate = true; + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: rootLoader, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + }, + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index Loading... +

+
" + `); + + rootDfd.resolve("ROOT UPDATED"); + indexDfd.resolve("INDEX UPDATED"); + await waitFor(() => screen.getByText(/INDEX UPDATED/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - ROOT UPDATED +

+

+ Index - INDEX UPDATED +

+
" + `); + }); } diff --git a/packages/react-router/__tests__/dom/special-characters-test.tsx b/packages/react-router/__tests__/dom/special-characters-test.tsx index e37a24f9d1..f9c64b128e 100644 --- a/packages/react-router/__tests__/dom/special-characters-test.tsx +++ b/packages/react-router/__tests__/dom/special-characters-test.tsx @@ -761,6 +761,85 @@ describe("special character tests", () => { ); } }); + + it("handles encoded percent signs in ancestor splat route segments", async () => { + let ctx = render( + + + , + ); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "" + `); + + await fireEvent.click(screen.getByText("Link to grandchild")); + await waitFor(() => screen.getByText("Grandchild")); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "
+ + Link to grandchild + +

+ Grandchild +

+
+            {"*":"grandchild","param":"percent-%-sign"}
+          
+
" + `); + + function App() { + return ( + + } /> + + ); + } + + function Parent() { + return ( + + } /> + + ); + } + + function Child() { + let location = useLocation(); + let to = location.pathname.endsWith("grandchild") + ? "." + : "./grandchild"; + return ( + <> + Link to grandchild + + } /> + + + ); + } + + function Grandchild() { + return ( + <> +

Grandchild

+
{JSON.stringify(useParams())}
+ + ); + } + }); }); describe("when matching as part of the defined route path", () => { diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fdf16c762..a8bcf82c96 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -172,6 +172,15 @@ describe("fetchers", () => { await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); expect(A.fetcher.data).toBe("A DATA"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo"), + context: {}, + }); }); it("loader re-fetch", async () => { @@ -212,11 +221,15 @@ describe("fetchers", () => { expect(A.fetcher.formAction).toBe("/foo"); expect(A.fetcher.formData).toEqual(createFormData({ key: "value" })); expect(A.fetcher.formEncType).toBe("application/x-www-form-urlencoded"); - expect( - new URL( - A.loaders.foo.stub.mock.calls[0][0].request.url, - ).searchParams.toString(), - ).toBe("key=value"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo?key=value", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo?key=value"), + context: {}, + }); await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); @@ -264,6 +277,13 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); expect(A.fetcher.state).toBe("submitting"); + expect(A.actions.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: expect.any(Request), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo"), + context: {}, + }); await A.actions.foo.resolve("A ACTION"); expect(A.fetcher.state).toBe("loading"); @@ -374,6 +394,7 @@ describe("fetchers", () => { signal: A.loaders.root.stub.mock.calls[0][0].request.signal, }), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); }); @@ -3375,6 +3396,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3405,6 +3427,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3433,6 +3456,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3461,6 +3485,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3490,6 +3515,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3521,6 +3547,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3551,6 +3578,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index c5f9bbee35..7bd315f1b1 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -1752,6 +1752,7 @@ describe("a router", () => { signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks"), context: {}, }); @@ -1762,6 +1763,7 @@ describe("a router", () => { signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks/:id", + unstable_url: new URL("http://localhost/tasks/1"), context: {}, }); @@ -1772,6 +1774,7 @@ describe("a router", () => { signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar#hash"), context: {}, }); @@ -1784,6 +1787,7 @@ describe("a router", () => { signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar#hash"), context: {}, }); @@ -2210,6 +2214,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks"), context: {}, }); @@ -2254,7 +2259,8 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - unstable_pattern: expect.any(String), + unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar"), context: {}, }); // Assert request internals, cannot do a deep comparison above since some @@ -2289,6 +2295,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index 317bbe50d8..2cf9bd61c2 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -837,12 +837,29 @@ describe("ssr", () => { ]); await query(createRequest("/child")); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); }); @@ -874,6 +891,14 @@ describe("ssr", () => { }), ); + expect(actionStub).toHaveBeenCalledTimes(1); + expect(actionStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let actionRequest = actionStub.mock.calls[0][0]?.request; expect(actionRequest.method).toBe("POST"); @@ -883,14 +908,31 @@ describe("ssr", () => { ); expect((await actionRequest.formData()).get("key")).toBe("value"); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); expect(rootLoaderRequest.headers.get("test")).toBe("value"); expect(await rootLoaderRequest.text()).toBe(""); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); expect(childLoaderRequest.headers.get("test")).toBe("value"); diff --git a/packages/react-router/__tests__/router/submission-test.ts b/packages/react-router/__tests__/router/submission-test.ts index 7cc38b1c31..fcfb3d369a 100644 --- a/packages/react-router/__tests__/router/submission-test.ts +++ b/packages/react-router/__tests__/router/submission-test.ts @@ -949,6 +949,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -984,6 +985,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1017,6 +1019,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1122,6 +1125,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1161,6 +1165,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1197,6 +1202,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 696362f091..a398f1d0ed 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -30,6 +30,9 @@ export type { export type { ActionFunction, ActionFunctionArgs, + BaseRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyMatch, @@ -39,16 +42,22 @@ export type { FormEncType, FormMethod, HTMLFormMethod, + IndexRouteObject, LazyRouteFunction, LoaderFunction, LoaderFunctionArgs, MiddlewareFunction, + NonIndexRouteObject, ParamParseKey, Params, + PatchRoutesOnNavigationFunction, + PatchRoutesOnNavigationFunctionArgs, PathMatch, PathParam, PathPattern, RedirectFunction, + RouteMatch, + RouteObject, RouterContext, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, @@ -87,18 +96,7 @@ export { } from "./lib/router/utils"; // Expose react-router public API -export type { - DataRouteMatch, - DataRouteObject, - IndexRouteObject, - NavigateOptions, - Navigator, - NonIndexRouteObject, - PatchRoutesOnNavigationFunction, - PatchRoutesOnNavigationFunctionArgs, - RouteMatch, - RouteObject, -} from "./lib/context"; +export type { NavigateOptions, Navigator } from "./lib/context"; export { AwaitContextProvider as UNSAFE_AwaitContextProvider } from "./lib/context"; export type { AwaitProps, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index e6a7a3d7af..e1c9643dec 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -24,9 +24,15 @@ import type { } from "./router/router"; import { createRouter } from "./router/router"; import type { + DataRouteObject, DataStrategyFunction, + IndexRouteObject, LazyRouteFunction, + NonIndexRouteObject, Params, + PatchRoutesOnNavigationFunction, + RouteMatch, + RouteObject, TrackedPromise, } from "./router/utils"; import { @@ -36,16 +42,7 @@ import { stripBasename, } from "./router/utils"; -import type { - DataRouteObject, - IndexRouteObject, - Navigator, - NonIndexRouteObject, - PatchRoutesOnNavigationFunction, - RouteMatch, - RouteObject, - ViewTransitionContextObject, -} from "./context"; +import type { Navigator, ViewTransitionContextObject } from "./context"; import { AwaitContext, DataRouterContext, @@ -1215,6 +1212,9 @@ export interface IndexRouteProps { ErrorBoundary?: React.ComponentType | null; } +/** + * @category Types + */ export type RouteProps = PathRouteProps | LayoutRouteProps | IndexRouteProps; /** @@ -1936,7 +1936,7 @@ function useRouteComponentProps() { } export type RouteComponentProps = ReturnType; -export type RouteComponentType = React.ComponentType; +type RouteComponentType = React.ComponentType; export function WithComponentProps({ children, @@ -1963,7 +1963,7 @@ function useHydrateFallbackProps() { } export type HydrateFallbackProps = ReturnType; -export type HydrateFallbackType = React.ComponentType; +type HydrateFallbackType = React.ComponentType; export function WithHydrateFallbackProps({ children, @@ -1991,7 +1991,7 @@ function useErrorBoundaryProps() { } export type ErrorBoundaryProps = ReturnType; -export type ErrorBoundaryType = React.ComponentType; +type ErrorBoundaryType = React.ComponentType; export function WithErrorBoundaryProps({ children, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index ebe1b0f73b..bdfb1ccef3 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -11,79 +11,7 @@ import type { Router, StaticHandlerContext, } from "./router/router"; -import type { - AgnosticIndexRouteObject, - AgnosticNonIndexRouteObject, - AgnosticPatchRoutesOnNavigationFunction, - AgnosticPatchRoutesOnNavigationFunctionArgs, - AgnosticRouteMatch, - LazyRouteDefinition, - TrackedPromise, -} from "./router/utils"; - -// Create react-specific types from the agnostic types in @remix-run/router to -// export from react-router -export interface IndexRouteObject { - caseSensitive?: AgnosticIndexRouteObject["caseSensitive"]; - path?: AgnosticIndexRouteObject["path"]; - id?: AgnosticIndexRouteObject["id"]; - middleware?: AgnosticIndexRouteObject["middleware"]; - loader?: AgnosticIndexRouteObject["loader"]; - action?: AgnosticIndexRouteObject["action"]; - hasErrorBoundary?: AgnosticIndexRouteObject["hasErrorBoundary"]; - shouldRevalidate?: AgnosticIndexRouteObject["shouldRevalidate"]; - handle?: AgnosticIndexRouteObject["handle"]; - index: true; - children?: undefined; - element?: React.ReactNode | null; - hydrateFallbackElement?: React.ReactNode | null; - errorElement?: React.ReactNode | null; - Component?: React.ComponentType | null; - HydrateFallback?: React.ComponentType | null; - ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteDefinition; -} - -export interface NonIndexRouteObject { - caseSensitive?: AgnosticNonIndexRouteObject["caseSensitive"]; - path?: AgnosticNonIndexRouteObject["path"]; - id?: AgnosticNonIndexRouteObject["id"]; - middleware?: AgnosticNonIndexRouteObject["middleware"]; - loader?: AgnosticNonIndexRouteObject["loader"]; - action?: AgnosticNonIndexRouteObject["action"]; - hasErrorBoundary?: AgnosticNonIndexRouteObject["hasErrorBoundary"]; - shouldRevalidate?: AgnosticNonIndexRouteObject["shouldRevalidate"]; - handle?: AgnosticNonIndexRouteObject["handle"]; - index?: false; - children?: RouteObject[]; - element?: React.ReactNode | null; - hydrateFallbackElement?: React.ReactNode | null; - errorElement?: React.ReactNode | null; - Component?: React.ComponentType | null; - HydrateFallback?: React.ComponentType | null; - ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteDefinition; -} - -export type RouteObject = IndexRouteObject | NonIndexRouteObject; - -export type DataRouteObject = RouteObject & { - children?: DataRouteObject[]; - id: string; -}; - -export interface RouteMatch< - ParamKey extends string = string, - RouteObjectType extends RouteObject = RouteObject, -> extends AgnosticRouteMatch {} - -export interface DataRouteMatch extends RouteMatch {} - -export type PatchRoutesOnNavigationFunctionArgs = - AgnosticPatchRoutesOnNavigationFunctionArgs; - -export type PatchRoutesOnNavigationFunction = - AgnosticPatchRoutesOnNavigationFunction; +import type { TrackedPromise, RouteMatch } from "./router/utils"; export interface DataRouterContextObject // Omit `future` since those can be pulled from the `router` diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index d229f31320..ddb69df172 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -188,7 +188,8 @@ function createHydratedRouter({ unstable_instrumentations, mapRouteProperties, future: { - middleware: ssrInfo.context.future.v8_middleware, + unstable_passThroughRequests: + ssrInfo.context.future.unstable_passThroughRequests, }, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index d3924ca980..7b804e76fa 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -30,6 +30,8 @@ import type { DataStrategyFunction, FormEncType, HTMLFormMethod, + PatchRoutesOnNavigationFunction, + RouteObject, UIMatch, } from "../router/utils"; import { @@ -73,11 +75,7 @@ import { mapRouteProperties, hydrationRouteProperties, } from "../components"; -import type { - RouteObject, - NavigateOptions, - PatchRoutesOnNavigationFunction, -} from "../context"; +import type { NavigateOptions } from "../context"; import { DataRouterContext, DataRouterStateContext, @@ -1222,7 +1220,7 @@ export interface LinkProps unstable_defaultShouldRevalidate?: boolean; /** - * Masked path for for this navigation, when you want to navigate the router to + * Masked path for this navigation, when you want to navigate the router to * one location but display a separate location in the URL bar. * * This is useful for contextual navigations such as opening an image in a modal diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index ca9c5a3e8d..8714f1859b 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -20,13 +20,12 @@ import { IDLE_NAVIGATION, createStaticHandler as routerCreateStaticHandler, } from "../router/router"; -import type { RouteManifest } from "../router/utils"; +import type { RouteManifest, RouteObject } from "../router/utils"; import { convertRoutesToDataRoutes, isRouteErrorResponse, } from "../router/utils"; import { DataRoutes, Router, mapRouteProperties } from "../components"; -import type { RouteObject } from "../context"; import { DataRouterContext, DataRouterStateContext, @@ -390,9 +389,9 @@ export function createStaticRouter( manifest, ); - // Because our context matches may be from a framework-agnostic set of - // routes passed to createStaticHandler(), we update them here with our - // newly created/enhanced data routes + // Because our context matches may be from a set of routes passed to + // createStaticHandler(), we update them here with our newly created/enhanced + // data routes let matches = context.matches.map((match) => { let route = manifest[match.route.id] || match.route; return { @@ -411,6 +410,7 @@ export function createStaticRouter( get future() { return { v8_middleware: false, + unstable_passThroughRequests: false, ...opts?.future, }; }, diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 67d047b869..e3ffa5f8eb 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -6,7 +6,7 @@ import type { import * as React from "react"; import type { RouterState } from "../../router/router"; -import type { AgnosticDataRouteMatch } from "../../router/utils"; +import type { DataRouteMatch } from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { FrameworkContextObject } from "./entry"; @@ -356,7 +356,7 @@ export function PrefetchPageLinks({ page, ...linkProps }: PageLinkDescriptor) { return ; } -function useKeyedPrefetchLinks(matches: AgnosticDataRouteMatch[]) { +function useKeyedPrefetchLinks(matches: DataRouteMatch[]) { let { manifest, routeModules } = useFrameworkContext(); let [keyedPrefetchLinks, setKeyedPrefetchLinks] = React.useState< @@ -387,7 +387,7 @@ function PrefetchPageLinksImpl({ matches: nextMatches, ...linkProps }: PageLinkDescriptor & { - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }) { let location = useLocation(); let { future, manifest, routeModules } = useFrameworkContext(); diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index f965b43444..c8eaf06c74 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -44,6 +44,7 @@ export interface EntryContext extends FrameworkContextObject { } export interface FutureConfig { + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; v8_middleware: boolean; diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index a9aaa8e861..72c24c7f8f 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -1,7 +1,9 @@ import * as React from "react"; -import type { PatchRoutesOnNavigationFunction } from "../../context"; import type { Router as DataRouter } from "../../router/router"; -import type { RouteManifest } from "../../router/utils"; +import type { + PatchRoutesOnNavigationFunction, + RouteManifest, +} from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules } from "./routeModules"; diff --git a/packages/react-router/lib/dom/ssr/hydration.tsx b/packages/react-router/lib/dom/ssr/hydration.tsx index 4185633bc7..bbcc904221 100644 --- a/packages/react-router/lib/dom/ssr/hydration.tsx +++ b/packages/react-router/lib/dom/ssr/hydration.tsx @@ -1,6 +1,6 @@ -import type { DataRouteObject } from "../../context"; import type { Path } from "../../router/history"; import type { Router as DataRouter, HydrationState } from "../../router/router"; +import type { DataRouteObject } from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { ClientLoaderFunction } from "./routeModules"; import { shouldHydrateRouteLoader } from "./routes"; diff --git a/packages/react-router/lib/dom/ssr/links.ts b/packages/react-router/lib/dom/ssr/links.ts index 55f6dbc87b..ec5fc8dee2 100644 --- a/packages/react-router/lib/dom/ssr/links.ts +++ b/packages/react-router/lib/dom/ssr/links.ts @@ -1,5 +1,5 @@ import type { Location } from "../../router/history"; -import type { AgnosticDataRouteMatch } from "../../router/utils"; +import type { DataRouteMatch } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules, RouteModule } from "./routeModules"; @@ -16,7 +16,7 @@ import type { * loaded already. */ export function getKeyedLinksForMatches( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], routeModules: RouteModules, manifest: AssetsManifest, ): KeyedLinkDescriptor[] { @@ -147,7 +147,7 @@ function isHtmlLinkDescriptor(object: any): object is HtmlLinkDescriptor { export type KeyedHtmlLinkDescriptor = { key: string; link: HtmlLinkDescriptor }; export async function getKeyedPrefetchLinks( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: AssetsManifest, routeModules: RouteModules, ): Promise { @@ -178,18 +178,18 @@ export async function getKeyedPrefetchLinks( // This is ridiculously identical to transition.ts `filterMatchesToLoad` export function getNewMatchesForLinks( page: string, - nextMatches: AgnosticDataRouteMatch[], - currentMatches: AgnosticDataRouteMatch[], + nextMatches: DataRouteMatch[], + currentMatches: DataRouteMatch[], manifest: AssetsManifest, location: Location, mode: "data" | "assets", -): AgnosticDataRouteMatch[] { - let isNew = (match: AgnosticDataRouteMatch, index: number) => { +): DataRouteMatch[] { + let isNew = (match: DataRouteMatch, index: number) => { if (!currentMatches[index]) return true; return match.route.id !== currentMatches[index].route.id; }; - let matchPathChanged = (match: AgnosticDataRouteMatch, index: number) => { + let matchPathChanged = (match: DataRouteMatch, index: number) => { return ( // param change, /users/123 -> /users/456 currentMatches[index].pathname !== match.pathname || @@ -244,7 +244,7 @@ export function getNewMatchesForLinks( } export function getModuleLinkHrefs( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: AssetsManifest, { includeHydrateFallback }: { includeHydrateFallback?: boolean } = {}, ): string[] { diff --git a/packages/react-router/lib/dom/ssr/routeModules.ts b/packages/react-router/lib/dom/ssr/routeModules.ts index e8926d46b4..43a79a8ece 100644 --- a/packages/react-router/lib/dom/ssr/routeModules.ts +++ b/packages/react-router/lib/dom/ssr/routeModules.ts @@ -8,11 +8,11 @@ import type { MiddlewareFunction, Params, ShouldRevalidateFunction, + DataRouteMatch, DataStrategyResult, } from "../../router/utils"; import type { EntryRoute } from "./routes"; -import type { DataRouteMatch } from "../../context"; import type { LinkDescriptor } from "../../router/links"; import type { SerializeFrom } from "../../types/route-data"; diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index b9038287cc..2d46872c62 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -2,15 +2,13 @@ import * as React from "react"; import type { ActionFunction, ActionFunctionArgs, + DataRouteObject, + IndexRouteObject, LoaderFunction, LoaderFunctionArgs, MiddlewareFunction, -} from "../../router/utils"; -import type { - DataRouteObject, - IndexRouteObject, NonIndexRouteObject, -} from "../../context"; +} from "../../router/utils"; import type { LinksFunction, MetaFunction, RouteModules } from "./routeModules"; import type { InitialEntry } from "../../router/history"; import type { HydrationState } from "../../router/router"; @@ -18,7 +16,6 @@ import { convertRoutesToDataRoutes, RouterContextProvider, } from "../../router/utils"; -import type { MiddlewareEnabled } from "../../types/future"; import type { AppLoadContext } from "../../server-runtime/data"; import type { AssetsManifest, @@ -26,9 +23,6 @@ import type { FrameworkContextObject, } from "./entry"; import { - type RouteComponentType, - type HydrateFallbackType, - type ErrorBoundaryType, Outlet, RouterProvider, createMemoryRouter, @@ -40,9 +34,9 @@ import type { EntryRoute } from "./routes"; import { FrameworkContext } from "./components"; interface StubRouteExtensions { - Component?: RouteComponentType; - HydrateFallback?: HydrateFallbackType; - ErrorBoundary?: ErrorBoundaryType; + Component?: React.ComponentType; + HydrateFallback?: React.ComponentType; + ErrorBoundary?: React.ComponentType; loader?: LoaderFunction; action?: ActionFunction; children?: StubRouteObject[]; @@ -132,6 +126,8 @@ export function createRoutesStub( if (routerRef.current == null) { frameworkContextRef.current = { future: { + unstable_passThroughRequests: + future?.unstable_passThroughRequests === true, unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true, v8_middleware: future?.v8_middleware === true, @@ -154,7 +150,7 @@ export function createRoutesStub( // the manifest and routeModules during the walk let patched = processRoutes( // @ts-expect-error `StubRouteObject` is stricter about `loader`/`action` - // types compared to `AgnosticRouteObject` + // types compared to `RouteObject` convertRoutesToDataRoutes(routes, (r) => r), _context !== undefined ? _context diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 0c1682c250..3f87f76666 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import type { HydrationState } from "../../router/router"; import type { ActionFunctionArgs, + DataRouteObject, LoaderFunctionArgs, RouteManifest, ShouldRevalidateFunction, @@ -21,7 +22,6 @@ import { RemixRootDefaultErrorBoundary } from "./errorBoundaries"; import { RemixRootDefaultHydrateFallback } from "./fallback"; import invariant from "./invariant"; import { useRouteError } from "../../hooks"; -import type { DataRouteObject } from "../../context"; export interface Route { index?: boolean; @@ -340,7 +340,13 @@ export function createClientRoutes( (routeModule.clientLoader?.hydrate === true || !route.hasLoader); dataRoute.loader = async ( - { request, params, context, unstable_pattern }: LoaderFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -359,6 +365,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -394,7 +401,13 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context, unstable_pattern }: ActionFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -414,6 +427,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, async serverAction() { preventInvalidServerHandlerCall("action", route); return fetchServerAction(singleFetch); diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index fdf78ff273..b952903e0c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -4,6 +4,7 @@ import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isDataWithResponseInit, isResponse } from "../../router/router"; import type { + DataRouteMatch, DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyResult, @@ -20,7 +21,6 @@ import type { AssetsManifest, EntryContext } from "./entry"; import { escapeHtml } from "./markup"; import invariant from "./invariant"; import type { RouteModules } from "./routeModules"; -import type { DataRouteMatch } from "../../context"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 29887101ef..e7d917b046 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1,11 +1,5 @@ import * as React from "react"; -import type { - DataRouteMatch, - NavigateOptions, - RouteContextObject, - RouteMatch, - RouteObject, -} from "./context"; +import type { NavigateOptions, RouteContextObject } from "./context"; import { AwaitContext, DataRouterContext, @@ -34,10 +28,13 @@ import type { } from "./router/router"; import { IDLE_BLOCKER } from "./router/router"; import type { + DataRouteMatch, ParamParseKey, Params, PathMatch, PathPattern, + RouteMatch, + RouteObject, UIMatch, } from "./router/utils"; import { @@ -300,7 +297,7 @@ function useIsomorphicLayoutEffect( * * Be cautious with `navigate(number)`. If your application can load up to a * route that has a button that tries to navigate forward/back, there may not be - * a `[`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) + * a [`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) * entry to go back or forward to, or it can go somewhere you don't expect * (like a different domain). * @@ -891,12 +888,15 @@ export function useRoutesImpl( pathname: joinPaths([ parentPathnameBase, // Re-encode pathnames that were decoded inside matchRoutes. - // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses + // Pre-encode `%`, `?` and `#` ahead of `encodeLocation` because it uses // `new URL()` internally and we need to prevent it from treating // them as separators navigator.encodeLocation ? navigator.encodeLocation( - match.pathname.replace(/\?/g, "%3F").replace(/#/g, "%23"), + match.pathname + .replace(/%/g, "%25") + .replace(/\?/g, "%3F") + .replace(/#/g, "%23"), ).pathname : match.pathname, ]), @@ -906,12 +906,13 @@ export function useRoutesImpl( : joinPaths([ parentPathnameBase, // Re-encode pathnames that were decoded inside matchRoutes - // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses + // Pre-encode `%`, `?` and `#` ahead of `encodeLocation` because it uses // `new URL()` internally and we need to prevent it from treating // them as separators navigator.encodeLocation ? navigator.encodeLocation( match.pathnameBase + .replace(/%/g, "%25") .replace(/\?/g, "%3F") .replace(/#/g, "%23"), ).pathname diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 4ba8a16063..43fef3185e 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -5,7 +5,7 @@ import { createPath, invariant } from "./history"; import type { Router } from "./router"; import type { ActionFunctionArgs, - AgnosticDataRouteObject, + DataRouteObject, FormEncType, HTMLFormMethod, LazyRouteObject, @@ -141,7 +141,7 @@ const UninstrumentedSymbol = Symbol("Uninstrumented"); export function getRouteInstrumentationUpdates( fns: unstable_InstrumentRouteFunction[], - route: Readonly, + route: Readonly, ) { let aggregated: { lazy: InstrumentFunction[]; @@ -178,23 +178,23 @@ export function getRouteInstrumentationUpdates( ); let updates: { - middleware?: AgnosticDataRouteObject["middleware"]; - loader?: AgnosticDataRouteObject["loader"]; - action?: AgnosticDataRouteObject["action"]; - lazy?: AgnosticDataRouteObject["lazy"]; + middleware?: DataRouteObject["middleware"]; + loader?: DataRouteObject["loader"]; + action?: DataRouteObject["action"]; + lazy?: DataRouteObject["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"]; + updates.lazy = instrumented as DataRouteObject["lazy"]; } } // Instrument the lazy object format if (typeof route.lazy === "object") { - let lazyObject: LazyRouteObject = route.lazy; + let lazyObject: LazyRouteObject = route.lazy; (["middleware", "loader", "action"] as const).forEach((key) => { let lazyFn = lazyObject[key]; let instrumentations = aggregated[`lazy.${key}`]; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index e0c8ab445a..e383f06662 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,4 +1,3 @@ -import type { DataRouteMatch, RouteObject } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -20,10 +19,10 @@ import { instrumentClientSideRouter, } from "./instrumentation"; import type { - AgnosticDataRouteMatch, - AgnosticDataRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyMatch, - AgnosticRouteObject, + RouteObject, DataResult, DataStrategyFunction, DataStrategyFunctionArgs, @@ -42,7 +41,6 @@ import type { Submission, SuccessResult, UIMatch, - AgnosticPatchRoutesOnNavigationFunction, DataWithResponseInit, LoaderFunctionArgs, ActionFunctionArgs, @@ -50,6 +48,7 @@ import type { ActionFunction, MiddlewareFunction, MiddlewareNextFunction, + PatchRoutesOnNavigationFunction, } from "./utils"; import { ErrorResponseImpl, @@ -109,7 +108,7 @@ export interface Router { * * Return the routes for this router instance */ - get routes(): AgnosticDataRouteObject[]; + get routes(): DataRouteObject[]; /** * @private @@ -285,7 +284,7 @@ export interface Router { */ patchRoutes( routeId: string | null, - children: AgnosticRouteObject[], + children: RouteObject[], unstable_allowElementMutations?: boolean, ): void; @@ -296,7 +295,7 @@ export interface Router { * HMR needs to pass in-flight route updates to React Router * TODO: Replace this with granular route update APIs (addRoute, updateRoute, deleteRoute) */ - _internalSetRoutes(routes: AgnosticRouteObject[]): void; + _internalSetRoutes(routes: RouteObject[]): void; /** * @private @@ -337,7 +336,7 @@ export interface RouterState { /** * The current set of route matches */ - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; /** * Tracks whether we've completed our initial data load @@ -409,13 +408,15 @@ export type HydrationState = Partial< /** * Future flags to toggle new feature behavior */ -export interface FutureConfig {} +export interface FutureConfig { + unstable_passThroughRequests: boolean; +} /** * Initialization options for createRouter */ export interface RouterInit { - routes: AgnosticRouteObject[]; + routes: RouteObject[]; history: History; basename?: string; getContext?: () => MaybePromise; @@ -426,7 +427,7 @@ export interface RouterInit { hydrationData?: HydrationState; window?: Window; dataStrategy?: DataStrategyFunction; - patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction; + patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; } /** @@ -449,12 +450,12 @@ export interface StaticHandlerContext { * A StaticHandler instance manages a singular SSR navigation/fetch event */ export interface StaticHandler { - dataRoutes: AgnosticDataRouteObject[]; + dataRoutes: DataRouteObject[]; query( request: Request, opts?: { requestContext?: unknown; - filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean; + filterMatchesToLoad?: (match: DataRouteMatch) => boolean; skipLoaderErrorBubbling?: boolean; skipRevalidation?: boolean; dataStrategy?: DataStrategyFunction; @@ -462,10 +463,11 @@ export interface StaticHandler { query: ( r: Request, args?: { - filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean; + filterMatchesToLoad?: (match: DataRouteMatch) => boolean; }, ) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; queryRoute( @@ -477,6 +479,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; } @@ -793,7 +796,7 @@ interface FetchLoadMatch { */ interface RevalidatingFetcher extends FetchLoadMatch { key: string; - match: AgnosticDataRouteMatch | null; + match: DataRouteMatch | null; matches: DataStrategyMatch[] | null; request: Request | null; controller: AbortController | null; @@ -892,7 +895,7 @@ export function createRouter(init: RouterInit): Router { if (init.unstable_instrumentations) { let instrumentations = init.unstable_instrumentations; - mapRouteProperties = (route: AgnosticDataRouteObject) => { + mapRouteProperties = (route: DataRouteObject) => { return { ..._mapRouteProperties(route), ...getRouteInstrumentationUpdates( @@ -914,7 +917,7 @@ export function createRouter(init: RouterInit): Router { undefined, manifest, ); - let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; + let inFlightDataRoutes: DataRouteObject[] | undefined; let basename = init.basename || "/"; if (!basename.startsWith("/")) { basename = `/${basename}`; @@ -923,6 +926,7 @@ export function createRouter(init: RouterInit): Router { // Config driven behavior flags let future: FutureConfig = { + unstable_passThroughRequests: false, ...init.future, }; // Cleanup function for history @@ -1025,11 +1029,14 @@ export function createRouter(init: RouterInit): Router { } // Toggle renderFallback based on per-route values + // Using a `.forEach` is important instead of something like an `.every` + // here because we need to evaluate renderFallback for all matches renderFallback = false; - initialized = relevantMatches.every((m) => { + initialized = true; + relevantMatches.forEach((m) => { let status = getRouteHydrationStatus(m.route, loaderData, errors); renderFallback = renderFallback || status.renderFallback; - return !status.shouldLoad; + initialized = initialized && !status.shouldLoad; }); } } @@ -1273,7 +1280,7 @@ export function createRouter(init: RouterInit): Router { ) { return { ...m, - route: route as AgnosticDataRouteObject, + route: route as DataRouteObject, }; } return m; @@ -1907,7 +1914,7 @@ export function createRouter(init: RouterInit): Router { request: Request, location: Location, submission: Submission, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, initialHydration: boolean, @@ -1991,6 +1998,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, request, + location, matches, actionMatch, initialHydration ? [] : hydrationRouteProperties, @@ -1998,6 +2006,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( request, + location, dsMatches, scopedContext, null, @@ -2078,7 +2087,7 @@ export function createRouter(init: RouterInit): Router { async function handleLoaders( request: Request, location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, overrideNavigation?: Navigation, @@ -2272,6 +2281,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, request, + location, scopedContext, ); @@ -2465,7 +2475,7 @@ export function createRouter(init: RouterInit): Router { key: string, routeId: string, path: string, - requestMatches: AgnosticDataRouteMatch[], + requestMatches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, flushSync: boolean, @@ -2536,6 +2546,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, requestMatches, match, hydrationRouteProperties, @@ -2543,6 +2554,7 @@ export function createRouter(init: RouterInit): Router { ); let actionResults = await callDataStrategy( fetchRequest, + path, fetchMatches, scopedContext, key, @@ -2684,6 +2696,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, revalidationRequest, + nextLocation, scopedContext, ); @@ -2782,7 +2795,7 @@ export function createRouter(init: RouterInit): Router { key: string, routeId: string, path: string, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, flushSync: boolean, @@ -2842,6 +2855,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, matches, match, hydrationRouteProperties, @@ -2849,6 +2863,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( fetchRequest, + path, dsMatches, scopedContext, key, @@ -3050,6 +3065,7 @@ export function createRouter(init: RouterInit): Router { // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + path: To, matches: DataStrategyMatch[], scopedContext: RouterContextProvider, fetcherKey: string | null, @@ -3060,6 +3076,7 @@ export function createRouter(init: RouterInit): Router { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, request, + path, matches, fetcherKey, scopedContext, @@ -3135,11 +3152,13 @@ export function createRouter(init: RouterInit): Router { matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, + location: Location, scopedContext: RouterContextProvider, ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( request, + location, matches, scopedContext, null, @@ -3150,6 +3169,7 @@ export function createRouter(init: RouterInit): Router { if (f.matches && f.match && f.request && f.controller) { let results = await callDataStrategy( f.request, + f.path, f.matches, scopedContext, f.key, @@ -3428,7 +3448,7 @@ export function createRouter(init: RouterInit): Router { }; } - function getScrollKey(location: Location, matches: AgnosticDataRouteMatch[]) { + function getScrollKey(location: Location, matches: DataRouteMatch[]) { if (getScrollRestorationKey) { let key = getScrollRestorationKey( location, @@ -3441,7 +3461,7 @@ export function createRouter(init: RouterInit): Router { function saveScrollPosition( location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], ): void { if (savedScrollPositions && getScrollPosition) { let key = getScrollKey(location, matches); @@ -3451,7 +3471,7 @@ export function createRouter(init: RouterInit): Router { function getSavedScrollPosition( location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], ): number | null { if (savedScrollPositions) { let key = getScrollKey(location, matches); @@ -3464,13 +3484,13 @@ export function createRouter(init: RouterInit): Router { } function checkFogOfWar( - matches: AgnosticDataRouteMatch[] | null, - routesToUse: AgnosticDataRouteObject[], + matches: DataRouteMatch[] | null, + routesToUse: DataRouteObject[], pathname: string, - ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } { + ): { active: boolean; matches: DataRouteMatch[] | null } { if (init.patchRoutesOnNavigation) { if (!matches) { - let fogMatches = matchRoutesImpl( + let fogMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3483,7 +3503,7 @@ export function createRouter(init: RouterInit): Router { // If we matched a dynamic param or a splat, it might only be because // we haven't yet discovered other routes that would match with a // higher score. Call patchRoutesOnNavigation just to be sure - let partialMatches = matchRoutesImpl( + let partialMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3499,12 +3519,12 @@ export function createRouter(init: RouterInit): Router { type DiscoverRoutesSuccessResult = { type: "success"; - matches: AgnosticDataRouteMatch[] | null; + matches: DataRouteMatch[] | null; }; type DiscoverRoutesErrorResult = { type: "error"; error: any; - partialMatches: AgnosticDataRouteMatch[]; + partialMatches: DataRouteMatch[]; }; type DiscoverRoutesAbortedResult = { type: "aborted" }; type DiscoverRoutesResult = @@ -3513,7 +3533,7 @@ export function createRouter(init: RouterInit): Router { | DiscoverRoutesAbortedResult; async function discoverRoutes( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], pathname: string, signal: AbortSignal, fetcherKey?: string, @@ -3522,7 +3542,7 @@ export function createRouter(init: RouterInit): Router { return { type: "success", matches }; } - let partialMatches: AgnosticDataRouteMatch[] | null = matches; + let partialMatches: DataRouteMatch[] | null = matches; while (true) { let isNonHMR = inFlightDataRoutes == null; let routesToUse = inFlightDataRoutes || dataRoutes; @@ -3564,7 +3584,7 @@ export function createRouter(init: RouterInit): Router { } let newMatches = matchRoutes(routesToUse, pathname, basename); - let newPartialMatches: AgnosticDataRouteMatch[] | null = null; + let newPartialMatches: DataRouteMatch[] | null = null; if (newMatches) { if (Object.keys(newMatches[0].params).length === 0) { @@ -3598,7 +3618,7 @@ export function createRouter(init: RouterInit): Router { // Perform partial matching if we didn't already do it above if (!newPartialMatches) { - newPartialMatches = matchRoutesImpl( + newPartialMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3618,16 +3638,13 @@ export function createRouter(init: RouterInit): Router { } } - function compareMatches( - a: AgnosticDataRouteMatch[], - b: AgnosticDataRouteMatch[], - ) { + function compareMatches(a: DataRouteMatch[], b: DataRouteMatch[]) { return ( a.length === b.length && a.every((m, i) => m.route.id === b[i].route.id) ); } - function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) { + function _internalSetRoutes(newRoutes: DataRouteObject[]) { manifest = {}; inFlightDataRoutes = convertRoutesToDataRoutes( newRoutes, @@ -3639,7 +3656,7 @@ export function createRouter(init: RouterInit): Router { function patchRoutes( routeId: string | null, - children: AgnosticRouteObject[], + children: RouteObject[], unstable_allowElementMutations = false, ): void { let isNonHMR = inFlightDataRoutes == null; @@ -3727,11 +3744,11 @@ export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; unstable_instrumentations?: Pick[]; - future?: {}; + future?: Partial; } export function createStaticHandler( - routes: AgnosticRouteObject[], + routes: RouteObject[], opts?: CreateStaticHandlerOptions, ): StaticHandler { invariant( @@ -3744,13 +3761,19 @@ export function createStaticHandler( let _mapRouteProperties = opts?.mapRouteProperties || defaultMapRouteProperties; let mapRouteProperties = _mapRouteProperties; + // Currently unused in the static handler, but available for additional flags in the future + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let future: FutureConfig = { + unstable_passThroughRequests: false, // unused in static handler + ...opts?.future, + }; // 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) => { + mapRouteProperties = (route: DataRouteObject) => { return { ..._mapRouteProperties(route), ...getRouteInstrumentationUpdates( @@ -3805,11 +3828,12 @@ export function createStaticHandler( skipRevalidation, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -3888,6 +3912,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: createDataFunctionUrl(request, location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -3901,7 +3926,7 @@ export function createStaticHandler( revalidationRequest: Request, opts: { filterMatchesToLoad?: - | ((match: AgnosticDataRouteMatch) => boolean) + | ((match: DataRouteMatch) => boolean) | undefined; } = {}, ) => { @@ -4080,11 +4105,12 @@ export function createStaticHandler( requestContext, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4120,6 +4146,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: createDataFunctionUrl(request, location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4211,12 +4238,12 @@ export function createStaticHandler( async function queryImpl( request: Request, location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, - routeMatch: AgnosticDataRouteMatch | null, - filterMatchesToLoad: ((m: AgnosticDataRouteMatch) => boolean) | null, + routeMatch: DataRouteMatch | null, + filterMatchesToLoad: ((m: DataRouteMatch) => boolean) | null, skipRevalidation: boolean, ): Promise | Response> { invariant( @@ -4228,6 +4255,7 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, + location, matches, routeMatch || getTargetMatch(matches, location), requestContext, @@ -4242,6 +4270,7 @@ export function createStaticHandler( let result = await loadRouteData( request, + location, matches, requestContext, dataStrategy, @@ -4277,13 +4306,14 @@ export function createStaticHandler( async function submit( request: Request, - matches: AgnosticDataRouteMatch[], - actionMatch: AgnosticDataRouteMatch, + location: Location, + matches: DataRouteMatch[], + actionMatch: DataRouteMatch, requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, isRouteRequest: boolean, - filterMatchesToLoad: ((m: AgnosticDataRouteMatch) => boolean) | null, + filterMatchesToLoad: ((m: DataRouteMatch) => boolean) | null, skipRevalidation: boolean, ): Promise | Response> { let result: DataResult; @@ -4306,6 +4336,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, actionMatch, [], @@ -4314,6 +4345,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4417,6 +4449,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4443,6 +4476,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4466,12 +4500,13 @@ export function createStaticHandler( async function loadRouteData( request: Request, - matches: AgnosticDataRouteMatch[], + location: Location, + matches: DataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, - routeMatch: AgnosticDataRouteMatch | null, - filterMatchesToLoad: ((match: AgnosticDataRouteMatch) => boolean) | null, + routeMatch: DataRouteMatch | null, + filterMatchesToLoad: ((match: DataRouteMatch) => boolean) | null, pendingActionResult?: PendingActionResult, ): Promise< | Omit< @@ -4501,6 +4536,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, routeMatch, [], @@ -4520,6 +4556,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4532,6 +4569,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4561,6 +4599,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4590,6 +4629,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + location: Location, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4598,6 +4638,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, + location, matches, null, requestContext, @@ -4664,7 +4705,7 @@ export function createStaticHandler( * @category Utils */ export function getStaticContextFromError( - routes: AgnosticDataRouteObject[], + routes: DataRouteObject[], handlerContext: StaticHandlerContext, error: any, boundaryId?: string, @@ -4704,16 +4745,25 @@ function isSubmissionNavigation( ); } +function defaultNormalizePath(request: Request): Path { + let url = new URL(request.url); + return { + pathname: url.pathname, + search: url.search, + hash: url.hash, + }; +} + function normalizeTo( location: Path, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], basename: string, to: To | null, fromRouteId?: string, relative?: RelativeRoutingType, ) { - let contextualMatches: AgnosticDataRouteMatch[]; - let activeRouteMatch: AgnosticDataRouteMatch | undefined; + let contextualMatches: DataRouteMatch[]; + let activeRouteMatch: DataRouteMatch | undefined; if (fromRouteId) { // Grab matches up to the calling route so our route-relative logic is // relative to the correct source route @@ -4926,7 +4976,7 @@ function getMatchesToLoad( manifest: RouteManifest, history: History, state: RouterState, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], submission: Submission | undefined, location: Location, lazyRoutePropertiesToSkip: string[], @@ -4936,7 +4986,7 @@ function getMatchesToLoad( fetchersQueuedForDeletion: Set, fetchLoadMatches: Map, fetchRedirectIds: Set, - routesToUse: AgnosticDataRouteObject[], + routesToUse: DataRouteObject[], basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, @@ -5023,6 +5073,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5067,6 +5118,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5148,6 +5200,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5162,6 +5215,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5190,6 +5244,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5227,7 +5282,7 @@ function routeHasLoaderOrMiddleware(route: RouteObject) { // except for when we are loading a route due to `loader.hydrate=true`, in which // case we don't want to render a fallback function getRouteHydrationStatus( - route: AgnosticDataRouteObject, + route: DataRouteObject, loaderData: RouteData | null | undefined, errors: RouteData | null | undefined, ): { shouldLoad: boolean; renderFallback: boolean } { @@ -5262,8 +5317,8 @@ function getRouteHydrationStatus( function isNewLoader( currentLoaderData: RouteData, - currentMatch: AgnosticDataRouteMatch, - match: AgnosticDataRouteMatch, + currentMatch: DataRouteMatch, + match: DataRouteMatch, ) { let isNew = // [a] -> [a, b] @@ -5280,8 +5335,8 @@ function isNewLoader( } function isNewRouteInstance( - currentMatch: AgnosticDataRouteMatch, - match: AgnosticDataRouteMatch, + currentMatch: DataRouteMatch, + match: DataRouteMatch, ) { let currentPath = currentMatch.route.path; return ( @@ -5296,7 +5351,7 @@ function isNewRouteInstance( } function shouldRevalidateLoader( - loaderMatch: AgnosticDataRouteMatch, + loaderMatch: DataRouteMatch, arg: ShouldRevalidateFunctionArgs, ) { if (loaderMatch.route.shouldRevalidate) { @@ -5311,13 +5366,13 @@ function shouldRevalidateLoader( function patchRoutesImpl( routeId: string | null, - children: AgnosticRouteObject[], - routesToUse: AgnosticDataRouteObject[], + children: RouteObject[], + routesToUse: DataRouteObject[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, allowElementMutations: boolean, ) { - let childrenToPatch: AgnosticDataRouteObject[]; + let childrenToPatch: DataRouteObject[]; if (routeId) { let route = manifest[routeId]; invariant( @@ -5335,10 +5390,10 @@ function patchRoutesImpl( // Don't patch in routes we already know about so that `patch` is idempotent // to simplify user-land code. This is useful because we re-call the // `patchRoutesOnNavigation` function for matched routes with params. - let uniqueChildren: AgnosticRouteObject[] = []; + let uniqueChildren: RouteObject[] = []; let existingChildren: { - existingRoute: AgnosticRouteObject; - newRoute: AgnosticRouteObject; + existingRoute: RouteObject; + newRoute: RouteObject; }[] = []; children.forEach((newRoute) => { let existingRoute = childrenToPatch.find((existingRoute) => @@ -5392,8 +5447,8 @@ function patchRoutesImpl( } function isSameRoute( - newRoute: AgnosticRouteObject, - existingRoute: AgnosticRouteObject, + newRoute: RouteObject, + existingRoute: RouteObject, ): boolean { // Most optimal check is by id if ( @@ -5434,8 +5489,8 @@ function isSameRoute( } const lazyRoutePropertyCache = new WeakMap< - AgnosticDataRouteObject, - Partial>> + DataRouteObject, + Partial>> >(); const loadLazyRouteProperty = ({ @@ -5444,8 +5499,8 @@ const loadLazyRouteProperty = ({ manifest, mapRouteProperties, }: { - key: keyof AgnosticDataRouteObject; - route: AgnosticDataRouteObject; + key: keyof DataRouteObject; + route: DataRouteObject; manifest: RouteManifest; mapRouteProperties: MapRoutePropertiesFunction; }): Promise | undefined => { @@ -5516,10 +5571,7 @@ const loadLazyRouteProperty = ({ return propertyPromise; }; -const lazyRouteFunctionCache = new WeakMap< - AgnosticDataRouteObject, - Promise ->(); +const lazyRouteFunctionCache = new WeakMap>(); /** * Execute route.lazy functions to lazily load route modules (loader, action, @@ -5527,7 +5579,7 @@ const lazyRouteFunctionCache = new WeakMap< * with dataRoutes so those get updated as well. */ function loadLazyRoute( - route: AgnosticDataRouteObject, + route: DataRouteObject, type: "loader" | "action", manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, @@ -5684,7 +5736,7 @@ function isNonNullable(value: T): value is NonNullable { } function loadLazyMiddlewareForMatches( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, ): Promise | void { @@ -5739,7 +5791,7 @@ function runServerMiddlewarePipeline( ) & { // Don't use `DataStrategyFunctionArgs` directly so we can we reduce these // back from `DataStrategyMatch` to regular matches for use in the staticHandler - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }, handler: () => Promise, errorHandler: ( @@ -5833,7 +5885,7 @@ async function runMiddlewarePipeline( ) & { // Don't use `DataStrategyFunctionArgs` directly so we can we reduce these // back from `DataStrategyMatch` to regular matches for use in the staticHandler - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }, // Handler to generate a Result in the leaf next() function handler: () => Promise, @@ -5852,18 +5904,13 @@ async function runMiddlewarePipeline( nextResult: { value: Result } | undefined, ) => Promise, ): Promise { - let { matches, request, params, context, unstable_pattern } = args; + let { matches, ...dataFnArgs } = 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, - unstable_pattern, - }, + dataFnArgs, tuples, handler, processResult, @@ -5985,6 +6032,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + path: To, unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6055,6 +6103,7 @@ function getDataStrategyMatch( ) { return callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, @@ -6072,8 +6121,9 @@ function getTargetedDataStrategyMatches( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, - matches: AgnosticDataRouteMatch[], - targetMatch: AgnosticDataRouteMatch, + path: To, + matches: DataRouteMatch[], + targetMatch: DataRouteMatch, lazyRoutePropertiesToSkip: string[], scopedContext: unknown, shouldRevalidateArgs: DataStrategyMatch["shouldRevalidateArgs"] = null, @@ -6102,6 +6152,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + path, getRoutePattern(matches), match, lazyRoutePropertiesToSkip, @@ -6115,6 +6166,7 @@ function getTargetedDataStrategyMatches( async function callDataStrategyImpl( dataStrategyImpl: DataStrategyFunction, request: Request, + path: To, matches: DataStrategyMatch[], fetcherKey: string | null, scopedContext: unknown, @@ -6128,8 +6180,12 @@ async function callDataStrategyImpl( // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. - let dataStrategyArgs = { + let dataStrategyArgs: Omit< + DataStrategyFunctionArgs, + "fetcherKey" | "runClientMiddleware" + > = { request, + unstable_url: createDataFunctionUrl(request, path), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6188,6 +6244,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise, @@ -6196,8 +6253,9 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + path: To; unstable_pattern: string; - match: AgnosticDataRouteMatch; + match: DataRouteMatch; lazyHandlerPromise: Promise | undefined; lazyRoutePromise: Promise | undefined; handlerOverride: Parameters[0]; @@ -6230,6 +6288,7 @@ async function callLoaderOrAction({ return handler( { request, + unstable_url: createDataFunctionUrl(request, path), unstable_pattern, params: match.params, context: scopedContext, @@ -6416,7 +6475,7 @@ function normalizeRelativeRoutingRedirectResponse( response: Response, request: Request, routeId: string, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], basename: string, ) { let location = response.headers.get("Location"); @@ -6442,28 +6501,28 @@ function normalizeRelativeRoutingRedirectResponse( return response; } +// Match Chrome's behavior: +// https://github.com/chromium/chromium/blob/216dbeb61db0c667e62082e5f5400a32d6983df3/content/public/common/url_utils.cc#L82 +export const invalidProtocols = [ + "about:", + "blob:", + "chrome:", + "chrome-untrusted:", + "content:", + "data:", + "devtools:", + "file:", + "filesystem:", + // eslint-disable-next-line no-script-url + "javascript:", +]; + function normalizeRedirectLocation( location: string, currentUrl: URL, basename: string, historyInstance: History, ): string { - // Match Chrome's behavior: - // https://github.com/chromium/chromium/blob/216dbeb61db0c667e62082e5f5400a32d6983df3/content/public/common/url_utils.cc#L82 - let invalidProtocols = [ - "about:", - "blob:", - "chrome:", - "chrome-untrusted:", - "content:", - "data:", - "devtools:", - "file:", - "filesystem:", - // eslint-disable-next-line no-script-url - "javascript:", - ]; - if (isAbsoluteUrl(location)) { // Strip off the protocol+origin for same-origin + same-basename absolute redirects let normalizedLocation = location; @@ -6529,6 +6588,33 @@ function createClientSideRequest( return new Request(url, init); } +// Create the unstable_url object to pass to loaders/actions/middleware. +// We strip the `?index` param because that is a React Router implementation detail. +function createDataFunctionUrl(request: Request, path: To): URL { + let url = new URL(request.url); + + let parsed = typeof path === "string" ? parsePath(path) : path; + url.pathname = parsed.pathname || "/"; + + if (parsed.search) { + let searchParams = new URLSearchParams(parsed.search); + + // Strip naked index param, preserve any other index params with values + let indexValues = searchParams.getAll("index"); + searchParams.delete("index"); + for (let value of indexValues.filter(Boolean)) { + searchParams.append("index", value); + } + url.search = searchParams.size ? `?${searchParams.toString()}` : ""; + } else { + url.search = ""; + } + + url.hash = parsed.hash || ""; + + return url; +} + function convertFormDataToSearchParams(formData: FormData): URLSearchParams { let searchParams = new URLSearchParams(); @@ -6551,7 +6637,7 @@ function convertSearchParamsToFormData( } function processRouteLoaderData( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], results: Record, pendingActionResult: PendingActionResult | undefined, isStaticHandler = false, @@ -6657,7 +6743,7 @@ function processRouteLoaderData( function processLoaderData( state: RouterState, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], results: Record, pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], @@ -6713,7 +6799,7 @@ function processLoaderData( function mergeLoaderData( loaderData: RouteData, newLoaderData: RouteData, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], errors: RouteData | null | undefined, ): RouteData { // Start with all new entries that are not being reset @@ -6766,9 +6852,9 @@ function getActionDataForCommit( // route specified by routeId) for the closest ancestor error boundary, // defaulting to the root match function findNearestBoundary( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], routeId?: string, -): AgnosticDataRouteMatch { +): DataRouteMatch { let eligibleMatches = routeId ? matches.slice(0, matches.findIndex((m) => m.route.id === routeId) + 1) : [...matches]; @@ -6778,9 +6864,9 @@ function findNearestBoundary( ); } -function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): { - matches: AgnosticDataRouteMatch[]; - route: AgnosticDataRouteObject; +function getShortCircuitMatches(routes: DataRouteObject[]): { + matches: DataRouteMatch[]; + route: DataRouteObject; } { // Prefer a root layout route if present, otherwise shim in a route object let route = @@ -6998,10 +7084,7 @@ function hasNakedIndexQuery(search: string): boolean { return new URLSearchParams(search).getAll("index").some((v) => v === ""); } -function getTargetMatch( - matches: AgnosticDataRouteMatch[], - location: Location | string, -) { +function getTargetMatch(matches: DataRouteMatch[], location: Path | string) { let search = typeof location === "string" ? parsePath(location).search : location.search; if ( diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 7a5c10b7c7..09345aaabe 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1,3 +1,4 @@ +import type * as React from "react"; import type { MiddlewareEnabled } from "../types/future"; import type { Equal, Expect } from "../types/utils"; import type { Location, Path, To } from "./history"; @@ -269,6 +270,15 @@ 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; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * suffixes, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * 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. @@ -362,11 +372,11 @@ export interface ShouldRevalidateFunctionArgs { /** This is the url the navigation started from. You can compare it with `nextUrl` to decide if you need to revalidate this route's data. */ currentUrl: URL; /** These are the {@link https://reactrouter.com/start/framework/routing#dynamic-segments dynamic route params} from the URL that can be compared to the `nextParams` to decide if you need to reload or not. Perhaps you're using only a partial piece of the param for data loading, you don't need to revalidate if a superfluous part of the param changed. */ - currentParams: AgnosticDataRouteMatch["params"]; + currentParams: DataRouteMatch["params"]; /** In the case of navigation, this the URL the user is requesting. Some revalidations are not navigation, so it will simply be the same as currentUrl. */ nextUrl: URL; /** In the case of navigation, these are the {@link https://reactrouter.com/start/framework/routing#dynamic-segments dynamic route params} from the next location the user is requesting. Some revalidations are not navigation, so it will simply be the same as currentParams. */ - nextParams: AgnosticDataRouteMatch["params"]; + nextParams: DataRouteMatch["params"]; /** The method (probably `"GET"` or `"POST"`) used in the form submission that triggered the revalidation. */ formMethod?: Submission["formMethod"]; /** The form action (`
`) that triggered the revalidation. */ @@ -423,8 +433,7 @@ export interface ShouldRevalidateFunction { (args: ShouldRevalidateFunctionArgs): boolean; } -export interface DataStrategyMatch - extends AgnosticRouteMatch { +export interface DataStrategyMatch extends RouteMatch { /** * @private */ @@ -527,30 +536,23 @@ export interface DataStrategyFunction { ): Promise>; } -export type AgnosticPatchRoutesOnNavigationFunctionArgs< - O extends AgnosticRouteObject = AgnosticRouteObject, - M extends AgnosticRouteMatch = AgnosticRouteMatch, -> = { +export type PatchRoutesOnNavigationFunctionArgs = { signal: AbortSignal; path: string; - matches: M[]; + matches: RouteMatch[]; fetcherKey: string | undefined; - patch: (routeId: string | null, children: O[]) => void; + patch: (routeId: string | null, children: RouteObject[]) => void; }; -export type AgnosticPatchRoutesOnNavigationFunction< - O extends AgnosticRouteObject = AgnosticRouteObject, - M extends AgnosticRouteMatch = AgnosticRouteMatch, -> = ( - opts: AgnosticPatchRoutesOnNavigationFunctionArgs, +export type PatchRoutesOnNavigationFunction = ( + opts: PatchRoutesOnNavigationFunctionArgs, ) => MaybePromise; /** - * Function provided by the framework-aware layers to set any framework-specific - * properties from framework-agnostic properties + * Function provided to set route-specific properties from route objects */ export interface MapRoutePropertiesFunction { - (route: AgnosticDataRouteObject): { + (route: DataRouteObject): { hasErrorBoundary: boolean; } & Record; } @@ -613,7 +615,7 @@ export function isUnsupportedLazyRouteFunctionKey( * lazy object to load route properties, which can add non-matching * related properties to a route */ -export type LazyRouteObject = { +export type LazyRouteObject = { [K in keyof R as K extends UnsupportedLazyRouteObjectKey ? never : K]?: () => Promise; @@ -623,46 +625,124 @@ export type LazyRouteObject = { * lazy() function to load a route definition, which can add non-matching * related properties to a route */ -export interface LazyRouteFunction { +export interface LazyRouteFunction { (): Promise< Omit & Partial> >; } -export type LazyRouteDefinition = +export type LazyRouteDefinition = | LazyRouteObject | LazyRouteFunction; /** * Base RouteObject with common props shared by all types of routes + * @internal */ -type AgnosticBaseRouteObject = { +export type BaseRouteObject = { + /** + * Whether the path should be case-sensitive. Defaults to `false`. + */ caseSensitive?: boolean; + /** + * The path pattern to match. If unspecified or empty, then this becomes a + * layout route. + */ path?: string; + /** + * The unique identifier for this route (for use with {@link DataRouter}s) + */ id?: string; + /** + * The route middleware. + * See [`middleware`](../../start/data/route-object#middleware). + */ middleware?: MiddlewareFunction[]; + /** + * The route loader. + * See [`loader`](../../start/data/route-object#loader). + */ loader?: LoaderFunction | boolean; + /** + * The route action. + * See [`action`](../../start/data/route-object#action). + */ action?: ActionFunction | boolean; + // TODO(v8): deprecate/remove hasErrorBoundary?: boolean; + /** + * The route shouldRevalidate function. + * See [`shouldRevalidate`](../../start/data/route-object#shouldRevalidate). + */ shouldRevalidate?: ShouldRevalidateFunction; + /** + * The route handle. + */ handle?: any; - lazy?: LazyRouteDefinition; + /** + * A function that returns a promise that resolves to the route object. + * Used for code-splitting routes. + * See [`lazy`](../../start/data/route-object#lazy). + */ + lazy?: LazyRouteDefinition; + /** + * The React Component to render when this route matches. + * Mutually exclusive with `element`. + */ + Component?: React.ComponentType | null; + /** + * The React element to render when this Route matches. + * Mutually exclusive with `Component`. + */ + element?: React.ReactNode | null; + /** + * The React Component to render at this route if an error occurs. + * Mutually exclusive with `errorElement`. + */ + ErrorBoundary?: React.ComponentType | null; + /** + * The React element to render at this route if an error occurs. + * Mutually exclusive with `ErrorBoundary`. + */ + errorElement?: React.ReactNode | null; + /** + * The React Component to render while this router is loading data. + * Mutually exclusive with `hydrateFallbackElement`. + */ + HydrateFallback?: React.ComponentType | null; + /** + * The React element to render while this router is loading data. + * Mutually exclusive with `HydrateFallback`. + */ + hydrateFallbackElement?: React.ReactNode | null; }; /** * Index routes must not have children */ -export type AgnosticIndexRouteObject = AgnosticBaseRouteObject & { +export type IndexRouteObject = BaseRouteObject & { + /** + * Child Route objects - not valid on index routes. + */ children?: undefined; + /** + * Whether this is an index route. + */ index: true; }; /** - * Non-index routes may have children, but cannot have index + * Non-index routes may have children, but cannot have `index` set to `true`. */ -export type AgnosticNonIndexRouteObject = AgnosticBaseRouteObject & { - children?: AgnosticRouteObject[]; +export type NonIndexRouteObject = BaseRouteObject & { + /** + * Child Route objects. + */ + children?: RouteObject[]; + /** + * Whether this is an index route - must be `false` or undefined on non-index routes. + */ index?: false; }; @@ -670,30 +750,23 @@ export type AgnosticNonIndexRouteObject = AgnosticBaseRouteObject & { * A route object represents a logical route, with (optionally) its child * routes organized in a tree-like structure. */ -export type AgnosticRouteObject = - | AgnosticIndexRouteObject - | AgnosticNonIndexRouteObject; +export type RouteObject = IndexRouteObject | NonIndexRouteObject; -export type AgnosticDataIndexRouteObject = AgnosticIndexRouteObject & { +export type DataIndexRouteObject = IndexRouteObject & { id: string; }; -export type AgnosticDataNonIndexRouteObject = AgnosticNonIndexRouteObject & { - children?: AgnosticDataRouteObject[]; +export type DataNonIndexRouteObject = NonIndexRouteObject & { + children?: DataRouteObject[]; id: string; }; /** * A data route object, which is just a RouteObject with a required unique ID */ -export type AgnosticDataRouteObject = - | AgnosticDataIndexRouteObject - | AgnosticDataNonIndexRouteObject; +export type DataRouteObject = DataIndexRouteObject | DataNonIndexRouteObject; -export type RouteManifest = Record< - string, - R | undefined ->; +export type RouteManifest = Record; // prettier-ignore type Regex_az = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" @@ -767,9 +840,9 @@ export type Params = { /** * A RouteMatch contains info about how a route matched a URL. */ -export interface AgnosticRouteMatch< +export interface RouteMatch< ParamKey extends string = string, - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, > { /** * The names and values of dynamic parameters in the URL. @@ -789,24 +862,21 @@ export interface AgnosticRouteMatch< route: RouteObjectType; } -export interface AgnosticDataRouteMatch - extends AgnosticRouteMatch {} +export interface DataRouteMatch extends RouteMatch {} -function isIndexRoute( - route: AgnosticRouteObject, -): route is AgnosticIndexRouteObject { +function isIndexRoute(route: RouteObject): route is IndexRouteObject { return route.index === true; } // Walk the route tree generating unique IDs where necessary, so we are working -// solely with AgnosticDataRouteObject's within the Router +// solely with DataRouteObject's within the Router export function convertRoutesToDataRoutes( - routes: AgnosticRouteObject[], + routes: RouteObject[], mapRouteProperties: MapRoutePropertiesFunction, parentPath: string[] = [], manifest: RouteManifest = {}, allowInPlaceMutations = false, -): AgnosticDataRouteObject[] { +): DataRouteObject[] { return routes.map((route, index) => { let treePath = [...parentPath, String(index)]; let id = typeof route.id === "string" ? route.id : treePath.join("-"); @@ -821,7 +891,7 @@ export function convertRoutesToDataRoutes( ); if (isIndexRoute(route)) { - let indexRoute: AgnosticDataIndexRouteObject = { + let indexRoute: DataIndexRouteObject = { ...route, id, }; @@ -831,7 +901,7 @@ export function convertRoutesToDataRoutes( ); return indexRoute; } else { - let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { + let pathOrLayoutRoute: DataNonIndexRouteObject = { ...route, id, children: undefined, @@ -856,7 +926,7 @@ export function convertRoutesToDataRoutes( }); } -function mergeRouteUpdates( +function mergeRouteUpdates( route: T, updates: ReturnType, ): T { @@ -899,24 +969,22 @@ function mergeRouteUpdates( * Defaults to `/`. * @returns An array of matched routes, or `null` if no matches were found. */ -export function matchRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +export function matchRoutes( routes: RouteObjectType[], locationArg: Partial | string, basename = "/", -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { return matchRoutesImpl(routes, locationArg, basename, false); } export function matchRoutesImpl< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, >( routes: RouteObjectType[], locationArg: Partial | string, basename: string, allowPartial: boolean, -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg; @@ -954,7 +1022,7 @@ export interface UIMatch { /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the matched route. */ - params: AgnosticRouteMatch["params"]; + params: RouteMatch["params"]; /** * The return value from the matched route's loader or clientLoader. This might * be `undefined` if this route's `loader` (or a deeper route's `loader`) threw @@ -977,7 +1045,7 @@ export interface UIMatch { } export function convertRouteMatchToUiMatch( - match: AgnosticDataRouteMatch, + match: DataRouteMatch, loaderData: RouteData, ): UIMatch { let { route, pathname, params } = match; @@ -991,26 +1059,20 @@ export function convertRouteMatchToUiMatch( }; } -interface RouteMeta< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, -> { +interface RouteMeta { relativePath: string; caseSensitive: boolean; childrenIndex: number; route: RouteObjectType; } -interface RouteBranch< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, -> { +interface RouteBranch { path: string; score: number; routesMeta: RouteMeta[]; } -function flattenRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +function flattenRoutes( routes: RouteObjectType[], branches: RouteBranch[] = [], parentsMeta: RouteMeta[] = [], @@ -1222,17 +1284,17 @@ function compareIndexes(a: number[], b: number[]): number { function matchRouteBranch< ParamKey extends string = string, - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, >( branch: RouteBranch, pathname: string, allowPartial = false, -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { let { routesMeta } = branch; let matchedParams = {}; let matchedPathname = "/"; - let matches: AgnosticRouteMatch[] = []; + let matches: RouteMatch[] = []; for (let i = 0; i < routesMeta.length; ++i) { let meta = routesMeta[i]; let end = i === routesMeta.length - 1; @@ -1687,9 +1749,9 @@ function getInvalidPathError( // // -export function getPathContributingMatches< - T extends AgnosticRouteMatch = AgnosticRouteMatch, ->(matches: T[]) { +export function getPathContributingMatches( + matches: T[], +) { return matches.filter( (match, index) => index === 0 || (match.route.path && match.route.path.length > 0), @@ -1698,9 +1760,9 @@ export function getPathContributingMatches< // Return the array of pathnames for the current route matches - used to // generate the routePathnames input for resolveTo() -export function getResolveToMatches< - T extends AgnosticRouteMatch = AgnosticRouteMatch, ->(matches: T[]) { +export function getResolveToMatches( + matches: T[], +) { let pathMatches = getPathContributingMatches(matches); // Use the full pathname for the leaf match so we include splat values for "." links @@ -2063,7 +2125,7 @@ by the star-slash in the `getRoutePattern` regex and messes up the parsed commen for `isRouteErrorResponse` above. This comment seems to reset the parser. */ -export function getRoutePattern(matches: AgnosticRouteMatch[]) { +export function getRoutePattern(matches: RouteMatch[]) { return ( matches .map((m) => m.route.path) diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 865ef8832f..4e557b0b09 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -2,23 +2,24 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { RouterProvider } from "../components"; -import { - RSCRouterContext, - type DataRouteMatch, - type DataRouteObject, -} from "../context"; +import { RSCRouterContext } from "../context"; import { FrameworkContext, setIsHydrated } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; import { createBrowserHistory, invariant } from "../router/history"; import type { Router as DataRouter, RouterInit } from "../router/router"; -import { createRouter, isMutationMethod } from "../router/router"; +import { + createRouter, + invalidProtocols, + isMutationMethod, +} from "../router/router"; import type { RSCPayload, RSCRouteManifest, RSCRenderPayload, } from "./server.rsc"; import type { - AgnosticDataRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyFunction, DataStrategyFunctionArgs, RouterContextProvider, @@ -143,6 +144,9 @@ export function createCallServer({ .then(async (payload) => { if (payload.type === "redirect") { if (payload.reload || isExternalLocation(payload.location)) { + if (hasInvalidProtocol(payload.location)) { + throw new Error("Invalid redirect location"); + } window.location.href = payload.location; return; } @@ -167,6 +171,9 @@ export function createCallServer({ ) { if (rerender.type === "redirect") { if (rerender.reload || isExternalLocation(rerender.location)) { + if (hasInvalidProtocol(rerender.location)) { + throw new Error("Invalid redirect location"); + } window.location.href = rerender.location; return; } @@ -824,6 +831,7 @@ export function RSCHydratedRouter({ v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, @@ -1084,9 +1092,15 @@ function isExternalLocation(location: string) { return newLocation.origin !== window.location.origin; } -function cloneRoutes( - routes: AgnosticDataRouteObject[] | undefined, -): AgnosticDataRouteObject[] { +function hasInvalidProtocol(location: string): boolean { + try { + return invalidProtocols.includes(new URL(location).protocol); + } catch { + return false; + } +} + +function cloneRoutes(routes: DataRouteObject[] | undefined): DataRouteObject[] { if (!routes) return undefined as any; return routes.map((route) => ({ ...route, @@ -1094,10 +1108,7 @@ function cloneRoutes( })) as any; } -function diffRoutes( - a: AgnosticDataRouteObject[], - b: AgnosticDataRouteObject[], -): boolean { +function diffRoutes(a: DataRouteObject[], b: DataRouteObject[]): boolean { if (a.length !== b.length) return true; return a.some((route, index) => { if ((route as any).element !== (b[index] as any).element) return true; diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index e19c12326d..7653c2a192 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -20,10 +20,11 @@ import { } from "../router/router"; import { type ActionFunction, - type AgnosticDataRouteMatch, type LoaderFunction, type Params, type ShouldRevalidateFunction, + type RouteMatch, + type RouteObject, type RouterContextProvider, type TrackedPromise, isAbsoluteUrl, @@ -39,7 +40,6 @@ import { import { getDocumentHeadersImpl } from "../server-runtime/headers"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; import { throwIfPotentialCSRFAttack } from "../actions"; -import type { RouteMatch, RouteObject } from "../context"; import invariant from "../server-runtime/invariant"; import { @@ -67,6 +67,7 @@ import { createRedirectErrorDigest, createRouteErrorResponseDigest, } from "../errors"; +import { getNormalizedPath } from "../server-runtime/urls"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -210,6 +211,8 @@ export type RSCRouteConfigEntry = RSCRouteConfigEntryBase & { export type RSCRouteConfig = Array; +type RSCRouteDataMatch = RouteMatch; + export type RSCRouteManifest = { clientAction?: ClientActionFunction; clientLoader?: ClientLoaderFunction; @@ -714,6 +717,7 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), }); return response; } catch (error) { @@ -805,6 +809,7 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), async generateMiddlewareResponse(query) { // If this is an RSC server action, process that and then call query as a // revalidation. If this is a RR Form/Fetcher submission, @@ -1130,7 +1135,7 @@ async function getRenderPayload( }); let matchesPromise = Promise.all( - staticContext.matches.map((match, i) => { + (staticContext.matches as RSCRouteDataMatch[]).map((match, i) => { let isBelowErrorBoundary = i > deepestRenderedRouteIdx; let parentId = parentIds[match.route.id]; return getRSCRouteMatch({ @@ -1167,24 +1172,23 @@ async function getRSCRouteMatch({ parentId, }: { staticContext: StaticHandlerContext; - match: AgnosticDataRouteMatch; + match: RSCRouteDataMatch; isBelowErrorBoundary: boolean; routeIdsToLoad: string[] | null; parentId: string | undefined; }) { - // @ts-expect-error - FIXME: Fix the types here - await explodeLazyRoute(match.route); - const Layout = (match.route as any).Layout || React.Fragment; - const Component = (match.route as any).Component; - const ErrorBoundary = (match.route as any).ErrorBoundary; - const HydrateFallback = (match.route as any).HydrateFallback; - const loaderData = staticContext.loaderData[match.route.id]; - const actionData = staticContext.actionData?.[match.route.id]; + const route = match.route; + await explodeLazyRoute(route); + const Layout = route.Layout || React.Fragment; + const Component = route.Component; + const ErrorBoundary = route.ErrorBoundary; + const HydrateFallback = route.HydrateFallback; + const loaderData = staticContext.loaderData[route.id]; + const actionData = staticContext.actionData?.[route.id]; const params = match.params; // TODO: DRY this up once it's fully fleshed out let element: React.ReactElement | undefined = undefined; - let shouldLoadRoute = - !routeIdsToLoad || routeIdsToLoad.includes(match.route.id); + let shouldLoadRoute = !routeIdsToLoad || routeIdsToLoad.includes(route.id); // Only bother rendering Server Components for routes that we're surfacing, // so nothing at/below an error boundary and prune routes if included in // `routeIdsToLoad`. This is specifically important when a middleware @@ -1213,7 +1217,7 @@ async function getRSCRouteMatch({ let error: unknown = undefined; if (ErrorBoundary && staticContext.errors) { - error = staticContext.errors[match.route.id]; + error = staticContext.errors[route.id]; } const errorElement = ErrorBoundary ? React.createElement( @@ -1247,33 +1251,37 @@ async function getRSCRouteMatch({ ) : undefined; + const hmrRoute = route as RSCRouteConfigEntry & { + __ensureClientRouteModuleForHMR?: unknown; + }; + return { - clientAction: (match.route as any).clientAction, - clientLoader: (match.route as any).clientLoader, + clientAction: route.clientAction, + clientLoader: route.clientLoader, element, errorElement, - handle: (match.route as any).handle, - hasAction: !!match.route.action, + handle: route.handle, + hasAction: !!route.action, hasComponent: !!Component, hasErrorBoundary: !!ErrorBoundary, - hasLoader: !!match.route.loader, + hasLoader: !!route.loader, hydrateFallbackElement, - id: match.route.id, - index: match.route.index, - links: (match.route as any).links, - meta: (match.route as any).meta, + id: route.id, + index: "index" in route ? route.index : undefined, + links: route.links, + meta: route.meta, params, parentId, - path: match.route.path, + path: route.path, pathname: match.pathname, pathnameBase: match.pathnameBase, - shouldRevalidate: (match.route as any).shouldRevalidate, + shouldRevalidate: route.shouldRevalidate, // Add an unused client-only export (if present) so HMR can support // switching between server-first and client-only routes during development - ...((match.route as any).__ensureClientRouteModuleForHMR + ...(hmrRoute.__ensureClientRouteModuleForHMR ? { - __ensureClientRouteModuleForHMR: (match.route as any) - .__ensureClientRouteModuleForHMR, + __ensureClientRouteModuleForHMR: + hmrRoute.__ensureClientRouteModuleForHMR, } : {}), }; diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 845d04544f..1e802e58cd 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { RSCRouterContext, type DataRouteObject } from "../context"; +import { RSCRouterContext } from "../context"; import { FrameworkContext } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; @@ -9,7 +9,7 @@ import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries"; import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; -import { isRouteErrorResponse } from "../router/utils"; +import { isRouteErrorResponse, type DataRouteObject } from "../router/utils"; import { decodeRedirectErrorDigest, decodeRouteErrorResponseDigest, @@ -581,6 +581,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index db680dfd78..3d75870aaa 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -4,6 +4,7 @@ import type { LoaderFunctionArgs, ActionFunctionArgs, } from "../router/utils"; +import type { FutureConfig } from "../router/router"; import { isDataWithResponseInit, isRedirectStatusCode } from "../router/router"; /** @@ -21,9 +22,13 @@ export interface AppLoadContext { export async function callRouteHandler( handler: LoaderFunction | ActionFunction, args: LoaderFunctionArgs | ActionFunctionArgs, + future: FutureConfig, ) { let result = await handler({ - request: stripRoutesParam(stripIndexParam(args.request)), + request: future.unstable_passThroughRequests + ? args.request + : stripRoutesParam(stripIndexParam(args.request)), + unstable_url: args.unstable_url, params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, @@ -42,11 +47,6 @@ export async function callRouteHandler( return result; } -// TODO: Document these search params better -// and stop stripping these in V2. These break -// support for running in a SW and also expose -// valuable info to data funcs that is being asked -// for such as "is this a data request?". function stripIndexParam(request: Request) { let url = new URL(request.url); let indexValues = url.searchParams.getAll("index"); diff --git a/packages/react-router/lib/server-runtime/headers.ts b/packages/react-router/lib/server-runtime/headers.ts index bef73e22c7..fa1988a172 100644 --- a/packages/react-router/lib/server-runtime/headers.ts +++ b/packages/react-router/lib/server-runtime/headers.ts @@ -1,6 +1,6 @@ import { splitCookiesString } from "set-cookie-parser"; -import type { DataRouteMatch } from "../context"; +import type { DataRouteMatch } from "../router/utils"; import type { StaticHandlerContext } from "../router/router"; import type { ServerRouteModule } from "../dom/ssr/routeModules"; import type { ServerBuild } from "./build"; diff --git a/packages/react-router/lib/server-runtime/routeMatching.ts b/packages/react-router/lib/server-runtime/routeMatching.ts index e6d38dab7d..c0f105c464 100644 --- a/packages/react-router/lib/server-runtime/routeMatching.ts +++ b/packages/react-router/lib/server-runtime/routeMatching.ts @@ -1,4 +1,4 @@ -import type { Params, AgnosticRouteObject } from "../router/utils"; +import type { Params, RouteObject } from "../router/utils"; import { matchRoutes } from "../router/utils"; import type { ServerRoute } from "./routes"; @@ -14,7 +14,7 @@ export function matchServerRoutes( basename?: string, ): RouteMatch[] | null { let matches = matchRoutes( - routes as unknown as AgnosticRouteObject[], + routes as unknown as RouteObject[], pathname, basename, ); diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a445101e44..9eebd42011 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -1,5 +1,5 @@ import type { - AgnosticDataRouteObject, + DataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, RouteManifest, @@ -70,7 +70,7 @@ export function createStaticHandlerDataRoutes( string, Omit[] > = groupRoutesByParentId(manifest), -): AgnosticDataRouteObject[] { +): DataRouteObject[] { return (routesByParentId[parentId] || []).map((route) => { let commonRoute = { // Always include root due to default boundaries @@ -131,13 +131,17 @@ export function createStaticHandlerDataRoutes( return result.data; } } - let val = await callRouteHandler(route.module.loader!, args); + let val = await callRouteHandler( + route.module.loader!, + args, + future, + ); return val; } : undefined, action: route.module.action ? (args: RRActionFunctionArgs) => - callRouteHandler(route.module.action!, args) + callRouteHandler(route.module.action!, args, future) : undefined, handle: route.module.handle, }; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index ea1df0ddad..12f249210e 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -39,6 +39,7 @@ import { getManifestPath } from "../dom/ssr/fog-of-war"; import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation"; import { instrumentHandler } from "../router/instrumentation"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { getNormalizedPath } from "./urls"; export type RequestHandler = ( request: Request, @@ -106,31 +107,12 @@ function derive(build: ServerBuild, mode?: string) { loadContext = initialContext || {}; } - let url = new URL(request.url); - - let normalizedBasename = build.basename || "/"; - let normalizedPath = url.pathname; - if (build.future.unstable_trailingSlashAwareDataRequests) { - if (normalizedPath.endsWith("/_.data")) { - // Handle trailing slash URLs: /about/_.data -> /about/ - normalizedPath = normalizedPath.replace(/_.data$/, ""); - } else { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - } else { - if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { - normalizedPath = normalizedBasename; - } else if (normalizedPath.endsWith(".data")) { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - - if ( - stripBasename(normalizedPath, normalizedBasename) !== "/" && - normalizedPath.endsWith("/") - ) { - normalizedPath = normalizedPath.slice(0, -1); - } - } + let requestUrl = new URL(request.url); + let normalizedPathname = getNormalizedPath( + request, + build.basename, + build.future, + ).pathname; let isSpaMode = getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes"; @@ -139,17 +121,17 @@ function derive(build: ServerBuild, mode?: string) { // pre-rendered site would if (!build.ssr) { // Decode the URL path before checking against the prerender config - let decodedPath = decodeURI(normalizedPath); + let decodedPath = decodeURI(normalizedPathname); - if (normalizedBasename !== "/") { - let strippedPath = stripBasename(decodedPath, normalizedBasename); + if (build.basename && build.basename !== "/") { + let strippedPath = stripBasename(decodedPath, build.basename); if (strippedPath == null) { errorHandler( new ErrorResponseImpl( 404, "Not Found", `Refusing to prerender the \`${decodedPath}\` path because it does ` + - `not start with the basename \`${normalizedBasename}\``, + `not start with the basename \`${build.basename}\``, ), { context: loadContext, @@ -174,7 +156,7 @@ function derive(build: ServerBuild, mode?: string) { !build.prerender.includes(decodedPath) && !build.prerender.includes(decodedPath + "/") ) { - if (url.pathname.endsWith(".data")) { + if (requestUrl.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests errorHandler( new ErrorResponseImpl( @@ -202,11 +184,11 @@ function derive(build: ServerBuild, mode?: string) { // Manifest request for fog of war let manifestUrl = getManifestPath( build.routeDiscovery.manifestPath, - normalizedBasename, + build.basename, ); - if (url.pathname === manifestUrl) { + if (requestUrl.pathname === manifestUrl) { try { - let res = await handleManifestRequest(build, routes, url); + let res = await handleManifestRequest(build, routes, requestUrl); return res; } catch (e) { handleError(e); @@ -214,19 +196,16 @@ function derive(build: ServerBuild, mode?: string) { } } - let matches = matchServerRoutes(routes, normalizedPath, build.basename); + let matches = matchServerRoutes(routes, normalizedPathname, build.basename); if (matches && matches.length > 0) { Object.assign(params, matches[0].params); } let response: Response; - if (url.pathname.endsWith(".data")) { - let handlerUrl = new URL(request.url); - handlerUrl.pathname = normalizedPath; - + if (requestUrl.pathname.endsWith(".data")) { let singleFetchMatches = matchServerRoutes( routes, - handlerUrl.pathname, + normalizedPathname, build.basename, ); @@ -235,7 +214,7 @@ function derive(build: ServerBuild, mode?: string) { build, staticHandler, request, - handlerUrl, + normalizedPathname, loadContext, handleError, ); @@ -281,7 +260,7 @@ function derive(build: ServerBuild, mode?: string) { handleError, ); } else { - let { pathname } = url; + let { pathname } = requestUrl; let criticalCss: CriticalCss | undefined = undefined; if (build.unstable_getCriticalCss) { @@ -443,10 +422,13 @@ async function handleSingleFetchRequest( build: ServerBuild, staticHandler: StaticHandler, request: Request, - handlerUrl: URL, + normalizedPath: string, loadContext: AppLoadContext | RouterContextProvider, handleError: (err: unknown) => void, ): Promise { + let handlerUrl = new URL(request.url); + handlerUrl.pathname = normalizedPath; + let response = request.method !== "GET" ? await singleFetchAction( @@ -511,6 +493,8 @@ async function handleDocumentRequest( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); if (!isResponse(result)) { @@ -688,6 +672,8 @@ async function handleResourceRequest( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryRouteResult(result); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 71e469b5fd..aecd30970f 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -24,6 +24,7 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { getNormalizedPath } from "./urls"; // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data @@ -54,13 +55,15 @@ export async function singleFetchAction( return handleQueryError(new Error("Bad Request"), 400); } - let handlerRequest = new Request(handlerUrl, { - method: request.method, - body: request.body, - headers: request.headers, - signal: request.signal, - ...(request.body ? { duplex: "half" } : undefined), - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, @@ -76,6 +79,8 @@ export async function singleFetchAction( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); @@ -147,10 +152,12 @@ export async function singleFetchLoaders( let loadRouteIds = routesParam ? new Set(routesParam.split(",")) : null; try { - let handlerRequest = new Request(handlerUrl, { - headers: request.headers, - signal: request.signal, - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + headers: request.headers, + signal: request.signal, + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, @@ -166,6 +173,8 @@ export async function singleFetchLoaders( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); diff --git a/packages/react-router/lib/server-runtime/urls.ts b/packages/react-router/lib/server-runtime/urls.ts new file mode 100644 index 0000000000..f71cc97719 --- /dev/null +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -0,0 +1,52 @@ +import type { FutureConfig } from "../dom/ssr/entry"; +import type { Path } from "../router/history"; +import { stripBasename } from "../router/utils"; + +export function getNormalizedPath( + request: Request, + basename: string | undefined, + future: FutureConfig | null, +): Path { + basename = basename || "/"; + + let url = new URL(request.url); + let pathname = url.pathname; + + // Strip .data suffix + if (future?.unstable_trailingSlashAwareDataRequests) { + if (pathname.endsWith("/_.data")) { + // Handle trailing slash URLs: /about/_.data -> /about/ + pathname = pathname.replace(/_\.data$/, ""); + } else { + pathname = pathname.replace(/\.data$/, ""); + } + } else { + if (stripBasename(pathname, basename) === "/_root.data") { + pathname = basename; + } else if (pathname.endsWith(".data")) { + pathname = pathname.replace(/\.data$/, ""); + } + + if (stripBasename(pathname, basename) !== "/" && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); + } + } + + // Strip _routes param + let searchParams = new URLSearchParams(url.search); + searchParams.delete("_routes"); + let search = searchParams.toString(); + if (search) { + search = `?${search}`; + } + + // Don't touch index params here - they're needed for router matching and are + // stripped when creating the loader/action args + + return { + pathname, + search, + // No hashes on the server + hash: "", + }; +} diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 52eefee088..b4bacc336d 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -2,6 +2,7 @@ import type { ClientLoaderFunctionArgs, ClientActionFunctionArgs, } from "../dom/ssr/routeModules"; +import type { Path } from "../router/history"; import type { DataWithResponseInit, RouterContextProvider, @@ -77,6 +78,15 @@ export type ClientDataFunctionArgs = { * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission. **/ request: Request; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * pathnames, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -111,6 +121,15 @@ export type ClientDataFunctionArgs = { export type ServerDataFunctionArgs = { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ request: Request; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * pathnames, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example diff --git a/packages/react-router/package.json b/packages/react-router/package.json index b82ce73ec9..7cb1a1e999 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.13.1", + "version": "7.13.2", "description": "Declarative routing for React", "keywords": [ "react", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 098fc66e24..bc26854688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,6 @@ importers: babel-plugin-dev-expression: specifier: ^0.2.3 version: 0.2.3(@babel/core@7.27.7) - chalk: - specifier: ^4.1.2 - version: 4.1.2 dox: specifier: ^1.0.0 version: 1.0.0 @@ -147,7 +144,7 @@ importers: version: 7.34.1(eslint@8.57.0) eslint-plugin-react-hooks: specifier: next - version: 7.1.0-canary-24d8716e-20260123(eslint@8.57.0) + version: 7.1.0-canary-e0cc7202-20260227(eslint@8.57.0) fast-glob: specifier: 3.2.11 version: 3.2.11 @@ -160,6 +157,9 @@ importers: jsonfile: specifier: ^6.1.0 version: 6.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -828,9 +828,6 @@ importers: arg: specifier: ^5.0.1 version: 5.0.2 - chalk: - specifier: ^4.1.2 - version: 4.1.2 execa: specifier: 5.1.1 version: 5.1.1 @@ -840,6 +837,9 @@ importers: log-update: specifier: ^5.0.1 version: 5.0.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 proxy-agent: specifier: ^6.3.0 version: 6.4.0 @@ -4799,6 +4799,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} @@ -5615,11 +5616,11 @@ 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@7.1.0-canary-24d8716e-20260123: - resolution: {integrity: sha512-Ku4UmX2ZuQtAUHu+9IqLcyKc0uqG8nAMXgpD0ycZUZaZ4nOneQP/qD0riPM6M/PUkDNF5xNLNc5Sde1mDkR9ig==} + eslint-plugin-react-hooks@7.1.0-canary-e0cc7202-20260227: + resolution: {integrity: sha512-Kg4EiP6olCKf9zrf3TGaMfyQfUOADsQDFa6q3Cfv+Fr47dQhOtbq6FkkyNZJEb+yz8kGrJJmIPKb+0Q2f+FrZw==} 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 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react@7.34.1: resolution: {integrity: sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==} @@ -5986,16 +5987,17 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.0.3: resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -7787,6 +7789,7 @@ packages: react-server-dom-webpack@19.2.3: resolution: {integrity: sha512-ifo7aqqdNJyV6U2zuvvWX4rRQ51pbleuUFNG7ZYhIuSuWZzQPbfmYv11GNsyJm/3uGNbt8buJ9wmoISn/uOAfw==} engines: {node: '>=0.10.0'} + deprecated: High Security Vulnerability in React Server Components peerDependencies: react: ^19.2.3 react-dom: ^19.2.3 @@ -13635,7 +13638,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-plugin-react-hooks@7.1.0-canary-24d8716e-20260123(eslint@8.57.0): + eslint-plugin-react-hooks@7.1.0-canary-e0cc7202-20260227(eslint@8.57.0): dependencies: '@babel/core': 7.27.7 '@babel/parser': 7.27.7 diff --git a/scripts/playground.js b/scripts/playground.js index b987ecaef0..20ea5648b0 100644 --- a/scripts/playground.js +++ b/scripts/playground.js @@ -4,7 +4,7 @@ let { existsSync, readdirSync } = require("node:fs"); let { cp } = require("node:fs/promises"); let path = require("node:path"); let prompts = require("prompts"); -let chalk = require("chalk"); +let pc = require("picocolors"); copyPlayground(); @@ -40,8 +40,8 @@ async function copyPlayground() { console.log( [ "", - chalk.green`Created local copy of "${templateName}"`, - chalk.green`To start playground, run:`, + pc.green(`Created local copy of "${templateName}"`), + pc.green(`To start playground, run:`), "", `cd ${relativeDestDir}`, "pnpm dev", diff --git a/scripts/version.js b/scripts/version.js index 71d96c6c16..6fc21a9a94 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -1,6 +1,6 @@ const fs = require("node:fs"); const { execSync } = require("child_process"); -const chalk = require("chalk"); +const pc = require("picocolors"); const semver = require("semver"); const { @@ -40,20 +40,18 @@ async function run() { packageName = pkg.name; pkg.version = version; }); - console.log( - chalk.green(` Updated ${packageName} to version ${version}`), - ); + console.log(pc.green(` Updated ${packageName} to version ${version}`)); } // 3. Commit and tag if (!skipGit) { execSync(`git commit --all --message="Version ${version}"`); execSync(`git tag -a -m "Version ${version}" v${version}`); - console.log(chalk.green(` Committed and tagged version ${version}`)); + console.log(pc.green(` Committed and tagged version ${version}`)); } } catch (error) { console.log(); - console.error(chalk.red(` ${error.message}`)); + console.error(pc.red(` ${error.message}`)); console.log(); return 1; }