From 6b01f85a4ed376eea14425b2f823fb13f6a99de0 Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Tue, 5 May 2026 14:36:10 +0000 Subject: [PATCH 01/23] chore: format --- CHANGELOG.md | 13 ------------- packages/react-router-dev/CHANGELOG.md | 3 --- packages/react-router/CHANGELOG.md | 10 ---------- 3 files changed, 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39610f1442..e1462e3ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,48 +177,37 @@ Date: 2026-05-05 ### Minor Changes - `react-router` - Stabilize `unstable_defaultShouldRevalidate` as `defaultShouldRevalidate` on ``, `
`, `useLinkClickHandler`, `useSubmit`, `fetcher.submit`, and `setSearchParams` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize the instrumentation APIs. `unstable_instrumentations` is now `instrumentations` and `unstable_pattern` is now `pattern` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - The `unstable_ServerInstrumentation`, `unstable_ClientInstrumentation`, `unstable_InstrumentRequestHandlerFunction`, `unstable_InstrumentRouterFunction`, `unstable_InstrumentRouteFunction`, and `unstable_InstrumentationHandlerResult` types have had their `unstable_` prefixes removed - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize `unstable_mask` as `mask` on ``, `useLinkClickHandler`, and `useNavigate`, and rename the corresponding `Location.unstable_mask` field to `Location.mask` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize the `unstable_normalizePath` option on `staticHandler.query` and `staticHandler.queryRoute` as `normalizePath` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize `future.unstable_passThroughRequests` as `future.v8_passThroughRequests` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Remove `unstable_subResourceIntegrity` from the runtime `FutureConfig` type; the flag is now controlled by the top-level `subResourceIntegrity` option in `react-router.config.ts` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize `unstable_url` as `url` on `loader`, `action`, and `middleware` function args ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `react-router` - Stabilize `unstable_useTransitions` as `useTransitions` on ``, ``, ``, ``, ``, ``, ``, and `useLinkClickHandler` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `@react-router/dev` - Stabilize `future.unstable_passThroughRequests` as `future.v8_passThroughRequests` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `@react-router/dev` - Stabilize `prerender.unstable_concurrency` as `prerender.concurrency` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - `@react-router/dev` - Stabilize `future.unstable_subResourceIntegrity` as a top-level `subResourceIntegrity` config option in `react-router.config.ts` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly ### Patch Changes @@ -228,7 +217,6 @@ Date: 2026-05-05 - `react-router` - Fix a bug with `unstable_defaultShouldRevalidate={false}` where parent routes that did not export a `shouldRevalidate` function could be incorrectly included in the single fetch call for new child route data ([#15012](https://github.com/remix-run/react-router/pull/15012)) - `react-router` - Improve server-side route matching performance by pre-computing flattened/cached route branches ([#14967](https://github.com/remix-run/react-router/pull/14967)) ([af5d49b](https://github.com/remix-run/react-router/commit/af5d49b)) - - Performance benchmarks showed roughly a 10-15% improvement in server-side request handling performance - `react-router` - Mark `mask` as an optional field in `Location` for easier mocking in unit tests ([#14999](https://github.com/remix-run/react-router/pull/14999)) @@ -236,7 +224,6 @@ Date: 2026-05-05 - `react-router` - Cache flattened/ranked route branches to optimize server-side route matching ([#14967](https://github.com/remix-run/react-router/pull/14967)) - `react-router` - Improve route matching performance in Framework/Data Mode ([#14971](https://github.com/remix-run/react-router/pull/14971)) ([af5d49b](https://github.com/remix-run/react-router/commit/af5d49b)) - - Avoiding unnecessary calls to `matchRoutes` in data router scenarios - This includes adding back the optimization that was removed in `7.6.0` ([#13562](https://github.com/remix-run/react-router/pull/13562)) - The issues that prompted the revert have been addressed by using the available router `matches` but always updating `match.route` to the latest route in the `manifest` diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index 894a6c44cd..3381692575 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -5,15 +5,12 @@ ### Minor Changes - Stabilize `future.unstable_passThroughRequests` as `future.v8_passThroughRequests` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `prerender.unstable_concurrency` as `prerender.concurrency` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `future.unstable_subResourceIntegrity` as a top-level `subResourceIntegrity` config option in `react-router.config.ts` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly ### Patch Changes diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index d0cee515ba..56620bed1c 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -5,36 +5,28 @@ ### Minor Changes - Stabilize `unstable_defaultShouldRevalidate` as `defaultShouldRevalidate` on ``, ``, `useLinkClickHandler`, `useSubmit`, `fetcher.submit`, and `setSearchParams` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize the instrumentation APIs. `unstable_instrumentations` is now `instrumentations` and `unstable_pattern` is now `pattern` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - The `unstable_ServerInstrumentation`, `unstable_ClientInstrumentation`, `unstable_InstrumentRequestHandlerFunction`, `unstable_InstrumentRouterFunction`, `unstable_InstrumentRouteFunction`, and `unstable_InstrumentationHandlerResult` types have had their `unstable_` prefixes removed - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `unstable_mask` as `mask` on ``, `useLinkClickHandler`, and `useNavigate`, and rename the corresponding `Location.unstable_mask` field to `Location.mask` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize the `unstable_normalizePath` option on `staticHandler.query` and `staticHandler.queryRoute` as `normalizePath` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `future.unstable_passThroughRequests` as `future.v8_passThroughRequests` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Remove `unstable_subResourceIntegrity` from the runtime `FutureConfig` type; the flag is now controlled by the top-level `subResourceIntegrity` option in `react-router.config.ts` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `unstable_url` as `url` on `loader`, `action`, and `middleware` function args ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly - Stabilize `unstable_useTransitions` as `useTransitions` on ``, ``, ``, ``, ``, ``, ``, and `useLinkClickHandler` ([a993f09](https://github.com/remix-run/react-router/commit/a993f09)) - - ⚠️ This is a breaking change if you have already opted into the unstable version - you will need to update your code accordingly ### Patch Changes @@ -44,7 +36,6 @@ - Fix a bug with `unstable_defaultShouldRevalidate={false}` where parent routes that did not export a `shouldRevalidate` function could be incorrectly included in the single fetch call for new child route data ([#15012](https://github.com/remix-run/react-router/pull/15012)) - Improve server-side route matching performance by pre-computing flattened/cached route branches ([#14967](https://github.com/remix-run/react-router/pull/14967)) ([af5d49b](https://github.com/remix-run/react-router/commit/af5d49b)) - - Performance benchmarks showed roughly a 10-15% improvement in server-side request handling performance - Mark `mask` as an optional field in `Location` for easier mocking in unit tests ([#14999](https://github.com/remix-run/react-router/pull/14999)) @@ -52,7 +43,6 @@ - Cache flattened/ranked route branches to optimize server-side route matching ([#14967](https://github.com/remix-run/react-router/pull/14967)) - Improve route matching performance in Framework/Data Mode ([#14971](https://github.com/remix-run/react-router/pull/14971)) ([af5d49b](https://github.com/remix-run/react-router/commit/af5d49b)) - - Avoiding unnecessary calls to `matchRoutes` in data router scenarios - This includes adding back the optimization that was removed in `7.6.0` ([#13562](https://github.com/remix-run/react-router/pull/13562)) - The issues that prompted the revert have been addressed by using the available router `matches` but always updating `match.route` to the latest route in the `manifest` From 522bc1b8cd0d7b3565bf9193789f2b7d5503856b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 7 May 2026 10:31:47 -0400 Subject: [PATCH 02/23] Add unstable_useRouterState hook (#15017) --- .agents/skills/implement-rfc/SKILL.md | 239 +++++++++++++ .../.changes/unstable.use-router-state.md | 35 ++ .../unstable-useRouterState-test.tsx | 333 ++++++++++++++++++ packages/react-router/index.ts | 8 +- packages/react-router/lib/hooks.tsx | 145 +++++++- packages/react-router/lib/router/router.ts | 60 +++- 6 files changed, 811 insertions(+), 9 deletions(-) create mode 100644 .agents/skills/implement-rfc/SKILL.md create mode 100644 packages/react-router/.changes/unstable.use-router-state.md create mode 100644 packages/react-router/__tests__/unstable-useRouterState-test.tsx diff --git a/.agents/skills/implement-rfc/SKILL.md b/.agents/skills/implement-rfc/SKILL.md new file mode 100644 index 0000000000..f73552ae33 --- /dev/null +++ b/.agents/skills/implement-rfc/SKILL.md @@ -0,0 +1,239 @@ +--- +name: implement-rfc +description: Implement a React Router RFC from a GitHub discussion URL. Fetches the proposal, evaluates community feedback, resolves outstanding questions interactively, then implements the feature with tests, future flags (if breaking), and a changeset. +disable-model-invocation: true +--- + +# Implement React Router RFC + +Implement the RFC from the following GitHub discussion: $ARGUMENTS + +## Branching + +RFC implementations should start from a clean working tree. If there are uncommitted changes, stop and ask me to resolve them before continuing. + +- If you are already on a named branch that is at the same HEAD as `dev`, use that branch. +- Otherwise, create a branch from `dev` using the format `{author}/rfc-{semantic-name}`: + ```sh + git branch {author}/rfc-{semantic-name} dev + git checkout {author}/rfc-{semantic-name} + ``` + +## Workflow + +### 1. Fetch and Understand the RFC + +Use `WebFetch` to read the discussion URL. If a GitHub discussion number is given instead of a URL, construct: +`https://github.com/remix-run/react-router/discussions/` + +Extract: + +- **Problem being solved**: what pain point does this RFC address? +- **Proposed API**: exact function signatures, hook names, types, option shapes +- **Affected modes**: Declarative / Data / Framework / RSC Data / RSC Framework +- **Breaking changes**: does this change or remove existing public API? +- **Open questions**: anything explicitly marked as unresolved, "TBD", or asked as a question in the proposal +- **Status**: are there linked tracking issues? Look for links to other github issues and read them to see if there is additional context. + + ```sh + gh issue view --repo remix-run/react-router + ``` + +### 2. Evaluate Community Feedback + +Fetch all comments from the discussion: + +Use `WebFetch` on `https://github.com/remix-run/react-router/discussions/` and scroll through the full thread. Look for: + +- **Concerns or objections** raised by community members or maintainers +- **Alternative proposals** or API shape suggestions +- **Edge cases** raised that the proposal does not address +- **Positive signals** — repeated praise for a specific approach signals it's the right direction +- **Maintainer responses** — Ryan Florence, Michael Jackson, or other core team members clarifying intent + +Summarize the community sentiment into: + +- Points of consensus (safe to proceed) +- Points of contention (need resolution before implementing) +- Unanswered questions from the original proposal + +### 3. Resolve Outstanding Questions + +Before writing any code, present me with a numbered list of every unresolved question — from both the RFC itself and from community feedback. For each question: + +- State the question clearly +- Summarize relevant community input +- Offer a recommended answer with reasoning + +Ask me to confirm, override, or skip each question. Do not proceed to implementation until all questions are either answered or explicitly deferred. + +Example format: + +``` +## Unresolved Questions + +1. **Should `useRouterState()` accept a path argument for scoped matching?** + Community feedback: 3 comments in favor, 1 against (concerns about complexity). + Recommendation: Yes — scoped matching improves type safety for nested routes. + → Your decision: [confirm / override / defer] + +2. **What should happen when the path doesn't match the current location?** + Recommendation: Return `null` for active state (consistent with `useMatch()`). + → Your decision: [confirm / override / defer] +``` + +Save the resolved decisions to a scratch file at `tasks/rfc-decisions.md` for reference during implementation. + +### 4. Plan the Implementation + +Before writing code, produce a concise implementation plan covering: + +- New types/interfaces to add +- New functions/hooks to implement and their file locations +- Existing APIs to deprecate (mark with `@deprecated` JSDoc + console warning in dev) +- Whether a future flag is needed (see §5 below) +- Test files to create or extend (unit and/or integration) +- Changeset bump level (`minor` for new features, `major` for breaking changes behind a future flag that is now defaulted on) + +Present the plan to me and wait for approval before implementing. + +### 5. Future Flags for Breaking Changes + +If the RFC changes or removes existing public API behavior, it **must** ship behind a future flag which will start with an `unstable_` prefix. + +**Future flag pattern:** + +1. Add the flag to `FutureConfig` in `packages/react-router/lib/router/utils.ts`: + + ```ts + export interface FutureConfig { + // existing flags... + unstable_myNewBehavior: boolean; + } + ``` + +2. Gate the new behavior on the flag: + + ```ts + if (router.future.unstable_myNewBehavior) { + // new behavior + } else { + // legacy behavior + } + ``` + +3. Document the flag in `docs/upgrading/future-flags.md` if it exists. + +New additive APIs (no behavior change to existing code) do **not** need a future flag. + +### 6. Key File Locations + +| Area | Files | +| --------------------- | -------------------------------------------- | +| Core router logic | `packages/react-router/lib/router/router.ts` | +| Router types/utils | `packages/react-router/lib/router/utils.ts` | +| React components | `packages/react-router/lib/components.tsx` | +| React hooks | `packages/react-router/lib/hooks.tsx` | +| Public exports | `packages/react-router/index.ts` | +| DOM utilities | `packages/react-router/lib/dom/` | +| Framework/Vite plugin | `packages/react-router-dev/vite/plugin.ts` | +| RSC runtime | `packages/react-router/lib/rsc/` | +| Unit tests | `packages/react-router/__tests__/` | +| Integration tests | `integration/` | +| Future flags doc | `docs/upgrading/future-flags.md` | + +Confirm existing patterns before writing new code - prefer using the LSP but `Grep`/`Glob` also work. Match naming conventions and code style exactly. + +### 7. Implement the Feature + +Follow the approved plan. For each logical unit of work: + +1. Write the implementation +2. Export from the appropriate public entry point (`packages/react-router/index.ts`) +3. Add `@deprecated` JSDoc to any APIs being superseded +4. Run typecheck to catch type errors early: + ```sh + pnpm typecheck + ``` + +Keep changes minimal and focused. Do not refactor unrelated code. Commit as often as needed. + +### 8. Write Tests + +**Unit tests** (for hooks, pure router logic, component behavior — no build): + +- Location: `packages/react-router/__tests__/` +- Runner: Jest → `pnpm test packages/react-router/__tests__/` +- Cover: happy path, edge cases identified in RFC/community feedback, future flag gating (if applicable), deprecation warnings + +**Integration tests** (for Vite/Framework Mode, SSR, hydration): + +- Location: `integration/` +- Runner: Playwright → `pnpm test:integration:run --project chromium integration/` +- Required if the RFC touches Framework Mode, file-system routing, or SSR behavior + +Run all tests and confirm they pass: + +```sh +pnpm test packages/react-router/ +pnpm test:integration:run --project chromium # only if integration tests were added/changed +``` + +### 9. Lint and Typecheck + +```sh +pnpm lint +pnpm typecheck +``` + +Fix all errors before proceeding. + +### 10. Create a Change File + +Create `packages//.changes/..md`. Use the RFC title or tracking issue as the description: + +```markdown +feat: + +Implements the `useRouterState()` RFC (#12358). Deprecates `useLocation`, +`useParams`, `useSearchParams`, `useNavigation`, `useMatches`, `useMatch`, +`useNavigationType`, and `useViewTransitionState` in favor of a unified API. + +Enable the `unstable_consolidatedRouterState` future flag to opt in. +``` + +Bump levels: + +- `patch` — bug-adjacent fix only +- `minor` — new additive API (no breaking changes) +- `major` — breaking change (should be rare; most breaking changes go behind a future flag as `minor` first) +- `unstable` — new API that is not yet stable (e.g. added in a future flag, or an experimental API that may be removed without a major bump) + +### 11. Report and Review + +Summarize: + +- What RFC was implemented and which decisions were made +- New public APIs added (with brief usage example) +- APIs deprecated and the migration path +- Future flag name (if applicable) and how to opt in +- Test coverage added +- Anything deferred or explicitly out of scope + +Ask me to review and iterate before opening a PR. + +### 12. Commit + +Once I approve, commit and open a PR to `dev`: + +```sh +gh pr create --base dev --title "feat: " --body "..." +``` + +PR body should include: + +- Link to the RFC discussion (Closes or Implements #NNNN) +- Summary of what was implemented +- Future flag instructions if applicable +- Testing notes +- Any decisions that deviated from the original proposal and why diff --git a/packages/react-router/.changes/unstable.use-router-state.md b/packages/react-router/.changes/unstable.use-router-state.md new file mode 100644 index 0000000000..6aff84cab8 --- /dev/null +++ b/packages/react-router/.changes/unstable.use-router-state.md @@ -0,0 +1,35 @@ +Add a new `unstable_useRouterState()` hook that consolidates access to active and pending router states (RFC: #12358) + +- Data/Framework/RSC only — throws when used without a data router +- This should allow you to consolidate usages of the following hooks which will likely be deprecated and removed in a future major version + - `useLocation` + - `useSearchParams` + - `useParams` + - `useMatches` + - `useNavigationType` + - `useNavigation` + + ```ts + let { active, pending } = unstable_useRouterState(); + + // Active is always populated with the current location + active.location; // replaces `useLocation()` + active.searchParams; // replaces `useSearchParams()[0]` + active.params; // replaces `useParams()` + active.matches; // replaces `useMatches()` + active.type; // replaces `useNavigationType()` + + // Pending is only populated during a navigation + pending.location; // replaces `useNavigation().location` + pending.searchParams; // equivalent to `new URLSearchParams(useNavigation().search` + pending.params; // Not directly accessible today + pending.matches; // Not directly accessible today + pending.type; // Not directly accessible today + pending.state; // replaces `useNavigation().state` + pending.formMethod; // replaces useNavigation().formMethod + pending.formAction; // replaces useNavigation().formAction + pending.formEncType; // replaces useNavigation().formEncType + pending.formData; // replaces useNavigation().formData + pending.json; // replaces useNavigation().json + pending.text; // replaces useNavigation().text + ``` diff --git a/packages/react-router/__tests__/unstable-useRouterState-test.tsx b/packages/react-router/__tests__/unstable-useRouterState-test.tsx new file mode 100644 index 0000000000..4b01ecac62 --- /dev/null +++ b/packages/react-router/__tests__/unstable-useRouterState-test.tsx @@ -0,0 +1,333 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { + MemoryRouter, + Outlet, + Route, + RouterProvider, + Routes, + createMemoryRouter, + unstable_useRouterState, +} from "react-router"; +import type { unstable_RouterState } from "react-router"; + +import { createDeferred } from "./router/utils/utils"; +import MemoryNavigate from "./utils/MemoryNavigate"; + +describe("unstable_useRouterState", () => { + it("returns active state for the current location", () => { + let captured: unstable_RouterState | undefined; + let router = createMemoryRouter( + [ + { + path: "/projects/:id", + Component() { + captured = unstable_useRouterState(); + return null; + }, + }, + ], + { initialEntries: ["/projects/123?tab=readme"] }, + ); + render(); + + expect(captured?.active.location.pathname).toBe("/projects/123"); + expect(captured?.active.searchParams.get("tab")).toBe("readme"); + expect(captured?.active.params).toEqual({ id: "123" }); + expect(captured?.active.type).toBe("POP"); + expect(captured?.pending).toBeNull(); + }); + + it("returns matches with id, pathname, params, and handle (no data fields)", () => { + let captured: unstable_RouterState | undefined; + let router = createMemoryRouter( + [ + { + id: "root", + path: "/", + handle: { breadcrumb: "Home" }, + element: , + children: [ + { + id: "projects", + path: "projects", + element: , + children: [ + { + id: "project", + path: ":id", + handle: { breadcrumb: "Project" }, + Component() { + captured = unstable_useRouterState(); + return null; + }, + }, + ], + }, + ], + }, + ], + { initialEntries: ["/projects/42"] }, + ); + render(); + + expect(captured?.active.matches).toEqual([ + { + id: "root", + pathname: "/", + params: { id: "42" }, + handle: { breadcrumb: "Home" }, + }, + { + id: "projects", + pathname: "/projects", + params: { id: "42" }, + handle: undefined, + }, + { + id: "project", + pathname: "/projects/42", + params: { id: "42" }, + handle: { breadcrumb: "Project" }, + }, + ]); + // None of the data-related fields from UIMatch should be present + captured?.active.matches.forEach((m) => { + expect(m).not.toHaveProperty("data"); + expect(m).not.toHaveProperty("loaderData"); + }); + }); + + it("populates `pending` during in-flight navigations", async () => { + let barDefer = createDeferred(); + let captured: unstable_RouterState | undefined; + + function Layout() { + captured = unstable_useRouterState(); + return ( +
+ Go + +
+ ); + } + + let router = createMemoryRouter( + [ + { + path: "/", + element: , + children: [ + { path: "foo", element:

Foo

}, + { + path: "bar/:id", + loader: () => barDefer.promise, + element:

Bar

, + }, + ], + }, + ], + { initialEntries: ["/foo"] }, + ); + render(); + + expect(captured?.active.location.pathname).toBe("/foo"); + expect(captured?.pending).toBeNull(); + + fireEvent.click(screen.getByText("Go")); + + expect(captured?.active.location.pathname).toBe("/foo"); + expect(captured?.pending).not.toBeNull(); + expect(captured?.pending?.location.pathname).toBe("/bar/9"); + expect(captured?.pending?.params).toEqual({ id: "9" }); + expect(captured?.pending?.type).toBe("PUSH"); + + barDefer.resolve({}); + await waitFor(() => + expect(captured?.active.location.pathname).toBe("/bar/9"), + ); + expect(captured?.pending).toBeNull(); + }); + + it("populates submission fields on `pending` during form submissions", async () => { + let actionDefer = createDeferred(); + let captured: unstable_RouterState | undefined; + + let formData = new FormData(); + formData.append("name", "Ryan"); + + function Layout() { + captured = unstable_useRouterState(); + return ( +
+ + + + +
+ ); + } + + let router = createMemoryRouter( + [ + { + path: "/", + element: , + children: [ + { index: true, element:

Home

}, + { + path: "submit", + action: () => actionDefer.promise, + element:

Done

, + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + render(); + + expect(captured?.pending).toBeNull(); + + fireEvent.click(screen.getByText("Submit")); + + expect(captured?.pending).not.toBeNull(); + expect(captured?.pending?.state).toBe("submitting"); + expect(captured?.pending?.location.pathname).toBe("/submit"); + expect(captured?.pending?.formMethod).toBe("POST"); + expect(captured?.pending?.formAction).toBe("/submit"); + expect(captured?.pending?.formEncType).toBe("application/x-www-form-urlencoded"); + expect(captured?.pending?.formData?.get("name")).toBe("Ryan"); + expect(captured?.pending?.json).toBeUndefined(); + expect(captured?.pending?.text).toBeUndefined(); + + actionDefer.resolve({}); + await waitFor(() => expect(captured?.pending).toBeNull()); + }); + + it("leaves submission fields undefined on `pending` during plain navigations", async () => { + let loaderDefer = createDeferred(); + let captured: unstable_RouterState | undefined; + + function Layout() { + captured = unstable_useRouterState(); + return ( +
+ Go + +
+ ); + } + + let router = createMemoryRouter( + [ + { + path: "/", + element: , + children: [ + { index: true, element:

Home

}, + { + path: "bar", + loader: () => loaderDefer.promise, + element:

Bar

, + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + render(); + + fireEvent.click(screen.getByText("Go")); + + expect(captured?.pending).not.toBeNull(); + expect(captured?.pending?.state).toBe("loading"); + expect(captured?.pending?.formMethod).toBeUndefined(); + expect(captured?.pending?.formAction).toBeUndefined(); + expect(captured?.pending?.formEncType).toBeUndefined(); + expect(captured?.pending?.formData).toBeUndefined(); + expect(captured?.pending?.json).toBeUndefined(); + expect(captured?.pending?.text).toBeUndefined(); + + loaderDefer.resolve({}); + await waitFor(() => expect(captured?.pending).toBeNull()); + }); + + it("preserves identity of `active` across pending-only changes (and vice versa)", async () => { + let barDefer = createDeferred(); + let snapshots: unstable_RouterState[] = []; + + function Layout() { + snapshots.push(unstable_useRouterState()); + return ( +
+ Go + +
+ ); + } + + let router = createMemoryRouter( + [ + { + path: "/", + element: , + children: [ + { index: true, element:

Home

}, + { + path: "bar", + loader: () => barDefer.promise, + element:

Bar

, + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + render(); + + let initial = snapshots[snapshots.length - 1]; + + fireEvent.click(screen.getByText("Go")); + + let mid = snapshots[snapshots.length - 1]; + // `pending` started, but `active` is still on the same location, so its + // identity should be preserved. + expect(mid.active).toBe(initial.active); + expect(mid.pending).not.toBe(initial.pending); + + barDefer.resolve({}); + await waitFor(() => + expect(snapshots[snapshots.length - 1].active.location.pathname).toBe( + "/bar", + ), + ); + + let final = snapshots[snapshots.length - 1]; + // `active` moved, so it should have a new identity, but `pending` is back + // to `null` and should match the initial `null` reference. + expect(final.active).not.toBe(mid.active); + expect(final.pending).toBe(initial.pending); + }); + + it("throws when used without a data router", () => { + function Probe() { + unstable_useRouterState(); + return null; + } + + // Silence React's error logging for this expected throw + let spy = jest.spyOn(console, "error").mockImplementation(() => {}); + expect(() => + render( + + + } /> + + , + ), + ).toThrow(/unstable_useRouterState must be used within a data router/); + spy.mockRestore(); + }); +}); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 5ebb3cfaef..681ecf7ec4 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -127,7 +127,12 @@ export { createRoutesFromElements, renderMatches, } from "./lib/components"; -export type { NavigateFunction } from "./lib/hooks"; +export type { + NavigateFunction, + unstable_RouterState, + unstable_RouterStateActiveVariant, + unstable_RouterStatePendingVariant, +} from "./lib/hooks"; export { useBlocker, useActionData, @@ -151,6 +156,7 @@ export { useRouteLoaderData, useRoutes, useRoute as unstable_useRoute, + useRouterState as unstable_useRouterState, } from "./lib/hooks"; // Expose old RR DOM API diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index f75de69745..071d95ae47 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -25,6 +25,7 @@ import type { Router as DataRouter, RevalidationState, Navigation, + NavigationStates, } from "./router/router"; import { IDLE_BLOCKER } from "./router/router"; import type { @@ -1402,6 +1403,7 @@ enum DataRouterStateHook { UseNavigateStable = "useNavigate", UseRouteId = "useRouteId", UseRoute = "useRoute", + UseRouterState = "unstable_useRouterState", } function getDataRouterConsoleError( @@ -1471,9 +1473,12 @@ export function useRouteId() { * @mode data * @returns The current {@link Navigation} object */ -export function useNavigation(): Navigation { +export function useNavigation(): Omit { let state = useDataRouterState(DataRouterStateHook.UseNavigation); - return state.navigation; + return React.useMemo>(() => { + let { matches, historyAction, ...rest } = state.navigation; + return rest; + }, [state.navigation]); } /** @@ -2002,3 +2007,139 @@ export function useRoute( actionData: state.actionData?.[id], } as UseRouteResult; } + +/** + * A single route match returned from {@link unstable_useRouterState}. Mirrors + * {@link UIMatch} minus the data-related fields (`data`, `loaderData`). + */ +type unstable_RouterStateMatch = Omit< + UIMatch, + "data" | "loaderData" +>; + +/** + * The shape of the `active` variant returned from + * {@link unstable_useRouterState}. + */ +export type unstable_RouterStateActiveVariant = { + location: Location; + searchParams: URLSearchParams; + params: Params; + matches: unstable_RouterStateMatch[]; + type: NavigationType; +}; + +/** + * The shape of the `pending` variant returned from + * {@link unstable_useRouterState}. Extends + * {@link unstable_RouterStateActiveVariant} with the navigation `state` and + * submission fields mirroring {@link useNavigation} — submission fields are + * populated when the in-flight navigation was triggered by a form submission, + * otherwise `undefined`. + */ +export type unstable_RouterStatePendingVariant = + unstable_RouterStatePendingVariants[keyof unstable_RouterStatePendingVariants]; + +type unstable_RouterStatePendingVariants = { + Loading: unstable_RouterStateActiveVariant & + Omit; + Submitting: unstable_RouterStateActiveVariant & + Omit; +}; + +/** + * The return shape of {@link unstable_useRouterState}. + * + * `active` reflects the currently-committed location. `pending` reflects the + * in-flight navigation (if any). + */ +export type unstable_RouterState = { + active: unstable_RouterStateActiveVariant; + pending: unstable_RouterStatePendingVariant | null; +}; + +function toRouterStateMatch(match: DataRouteMatch): unstable_RouterStateMatch { + return { + id: match.route.id, + pathname: match.pathname, + params: match.params, + handle: match.route.handle, + }; +} + +/** + * A unified hook for reading router state: current (`active`) and in-flight + * (`pending`) locations, search params, params, matches, and navigation type. + * + * @example + * import { unstable_useRouterState as useRouterState } from "react-router"; + * + * let { active, pending } = useRouterState(); + * active.params; // params from the leaf match + * pending?.location.pathname; // populated during in-flight navigations + * + * @public + * @category Hooks + * @mode framework + * @mode data + * @returns The current router state with `active` and `pending` variants + */ +export function useRouterState(): unstable_RouterState { + let { + location, + historyAction: type, + matches, + navigation, + } = useDataRouterState(DataRouterStateHook.UseRouterState); + + let active = React.useMemo( + () => ({ + type, + location, + searchParams: new URLSearchParams(location.search), + params: matches[matches.length - 1]?.params ?? {}, + matches: matches.map((m) => toRouterStateMatch(m)), + }), + [location, matches, type], + ); + + let pending = React.useMemo(() => { + if (navigation.state === "idle") return null; + let shared = { + type: navigation.historyAction, + location: navigation.location, + searchParams: new URLSearchParams(navigation.location.search), + params: navigation.matches[navigation.matches.length - 1]?.params ?? {}, + matches: navigation.matches.map((m) => toRouterStateMatch(m)), + }; + + // Do submissions fields independently to keep TS happy with the + // `NavigationStates` discriminated union + return navigation.state === "loading" + ? { + ...shared, + state: "loading", + formMethod: navigation.formMethod, + formAction: navigation.formAction, + formEncType: navigation.formEncType, + formData: navigation.formData, + json: navigation.json, + text: navigation.text, + } + : { + ...shared, + state: "submitting", + formMethod: navigation.formMethod, + formAction: navigation.formAction, + formEncType: navigation.formEncType, + formData: navigation.formData, + json: navigation.json, + text: navigation.text, + }; + }, [navigation]); + + return React.useMemo( + () => ({ active, pending }), + [active, pending], + ); +} diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index c7b3d6a4c4..c0ce6887e8 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -649,6 +649,8 @@ export type NavigationStates = { Idle: { state: "idle"; location: undefined; + matches: undefined; + historyAction: undefined; formMethod: undefined; formAction: undefined; formEncType: undefined; @@ -659,6 +661,8 @@ export type NavigationStates = { Loading: { state: "loading"; location: Location; + matches: DataRouteMatch[]; + historyAction: NavigationType; formMethod: Submission["formMethod"] | undefined; formAction: Submission["formAction"] | undefined; formEncType: Submission["formEncType"] | undefined; @@ -669,6 +673,8 @@ export type NavigationStates = { Submitting: { state: "submitting"; location: Location; + matches: DataRouteMatch[]; + historyAction: NavigationType; formMethod: Submission["formMethod"]; formAction: Submission["formAction"]; formEncType: Submission["formEncType"]; @@ -879,6 +885,8 @@ const redirectPreserveMethodStatusCodes = new Set([307, 308]); export const IDLE_NAVIGATION: NavigationStates["Idle"] = { state: "idle", location: undefined, + matches: undefined, + historyAction: undefined, formMethod: undefined, formAction: undefined, formEncType: undefined, @@ -1825,7 +1833,10 @@ export function createRouter(init: RouterInit): Router { initialHydration?: boolean; submission?: Submission; fetcherSubmission?: Submission; - overrideNavigation?: Navigation; + overrideNavigation?: Omit< + NavigationStates["Loading"], + "matches" | "historyAction" + >; pendingError?: ErrorResponseImpl; startUninterruptedRevalidation?: boolean; preventScrollReset?: boolean; @@ -1852,7 +1863,6 @@ export function createRouter(init: RouterInit): Router { pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true; let routesToUse = dataRoutes.activeRoutes; - let loadingNavigation = opts && opts.overrideNavigation; let matches = opts?.initialHydration && state.matches && @@ -1910,6 +1920,15 @@ export function createRouter(init: RouterInit): Router { return; } + let loadingNavigation: Navigation | undefined = + opts && opts.overrideNavigation + ? { + ...opts.overrideNavigation, + matches, + historyAction, + } + : undefined; + // Create a controller/Request for this navigation pendingNavigationController = new AbortController(); let request = createClientSideRequest( @@ -1944,6 +1963,7 @@ export function createRouter(init: RouterInit): Router { location, opts.submission, matches, + historyAction, scopedContext, fogOfWar.active, opts && opts.initialHydration === true, @@ -1978,7 +1998,12 @@ export function createRouter(init: RouterInit): Router { matches = actionResult.matches || matches; pendingActionResult = actionResult.pendingActionResult; - loadingNavigation = getLoadingNavigation(location, opts.submission); + loadingNavigation = getLoadingNavigation( + location, + matches, + historyAction, + opts.submission, + ); flushSync = false; // No need to do fog of war matching again on loader execution fogOfWar.active = false; @@ -2001,6 +2026,7 @@ export function createRouter(init: RouterInit): Router { request, location, matches, + historyAction, scopedContext, fogOfWar.active, loadingNavigation, @@ -2037,6 +2063,7 @@ export function createRouter(init: RouterInit): Router { location: Location, submission: Submission, matches: DataRouteMatch[], + historyAction: NavigationType, scopedContext: RouterContextProvider, isFogOfWar: boolean, initialHydration: boolean, @@ -2045,7 +2072,12 @@ export function createRouter(init: RouterInit): Router { interruptActiveLoads(); // Put us in a submitting state - let navigation = getSubmittingNavigation(location, submission); + let navigation = getSubmittingNavigation( + location, + matches, + historyAction, + submission, + ); updateState({ navigation }, { flushSync: opts.flushSync === true }); if (isFogOfWar) { @@ -2212,6 +2244,7 @@ export function createRouter(init: RouterInit): Router { request: Request, location: Location, matches: DataRouteMatch[], + historyAction: NavigationType, scopedContext: RouterContextProvider, isFogOfWar: boolean, overrideNavigation?: Navigation, @@ -2225,7 +2258,8 @@ export function createRouter(init: RouterInit): Router { ): Promise { // Figure out the right navigation we want to use for data loading let loadingNavigation = - overrideNavigation || getLoadingNavigation(location, submission); + overrideNavigation || + getLoadingNavigation(location, matches, historyAction, submission); // If this was a redirect from an action we don't have a "submission" but // we have it on the loading navigation so use that if available @@ -3193,9 +3227,13 @@ export function createRouter(init: RouterInit): Router { }); } else { // If we have a navigation submission, we will preserve it through the - // redirect navigation + // redirect navigation. `matches` for the redirect destination haven't + // been computed yet — handleLoaders will overwrite this with the real + // matches (and historyAction) before the state update lands. let overrideNavigation = getLoadingNavigation( redirectLocation, + [], + redirectNavigationType, submission, ); await startNavigation(redirectNavigationType, redirectLocation, { @@ -7336,12 +7374,16 @@ function getSubmissionFromNavigation( function getLoadingNavigation( location: Location, + matches: DataRouteMatch[], + historyAction: NavigationType, submission?: Submission, ): NavigationStates["Loading"] { if (submission) { let navigation: NavigationStates["Loading"] = { state: "loading", location, + matches, + historyAction, formMethod: submission.formMethod, formAction: submission.formAction, formEncType: submission.formEncType, @@ -7354,6 +7396,8 @@ function getLoadingNavigation( let navigation: NavigationStates["Loading"] = { state: "loading", location, + matches, + historyAction, formMethod: undefined, formAction: undefined, formEncType: undefined, @@ -7367,11 +7411,15 @@ function getLoadingNavigation( function getSubmittingNavigation( location: Location, + matches: DataRouteMatch[], + historyAction: NavigationType, submission: Submission, ): NavigationStates["Submitting"] { let navigation: NavigationStates["Submitting"] = { state: "submitting", location, + matches, + historyAction, formMethod: submission.formMethod, formAction: submission.formAction, formEncType: submission.formEncType, From 9f01e027c1fe51ec2dce4ac8dfe4330903d1a41e Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 7 May 2026 14:32:46 +0000 Subject: [PATCH 03/23] chore: format --- .../react-router/__tests__/unstable-useRouterState-test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-router/__tests__/unstable-useRouterState-test.tsx b/packages/react-router/__tests__/unstable-useRouterState-test.tsx index 4b01ecac62..2df97afe88 100644 --- a/packages/react-router/__tests__/unstable-useRouterState-test.tsx +++ b/packages/react-router/__tests__/unstable-useRouterState-test.tsx @@ -197,7 +197,9 @@ describe("unstable_useRouterState", () => { expect(captured?.pending?.location.pathname).toBe("/submit"); expect(captured?.pending?.formMethod).toBe("POST"); expect(captured?.pending?.formAction).toBe("/submit"); - expect(captured?.pending?.formEncType).toBe("application/x-www-form-urlencoded"); + expect(captured?.pending?.formEncType).toBe( + "application/x-www-form-urlencoded", + ); expect(captured?.pending?.formData?.get("name")).toBe("Ryan"); expect(captured?.pending?.json).toBeUndefined(); expect(captured?.pending?.text).toBeUndefined(); From 5700613f1be7f5ae4cae9f748489ddacc8c0f82f Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 7 May 2026 14:35:56 +0000 Subject: [PATCH 04/23] chore: generate markdown docs from jsdocs --- docs/api/hooks/useNavigation.md | 2 +- docs/api/hooks/useRouterState.md | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/api/hooks/useRouterState.md diff --git a/docs/api/hooks/useNavigation.md b/docs/api/hooks/useNavigation.md index 011b125afe..f8b8c976ee 100644 --- a/docs/api/hooks/useNavigation.md +++ b/docs/api/hooks/useNavigation.md @@ -41,7 +41,7 @@ function SomeComponent() { ## Signature ```tsx -function useNavigation(): Navigation +function useNavigation(): Omit ``` ## Returns diff --git a/docs/api/hooks/useRouterState.md b/docs/api/hooks/useRouterState.md new file mode 100644 index 0000000000..b6cdbb7292 --- /dev/null +++ b/docs/api/hooks/useRouterState.md @@ -0,0 +1,43 @@ +--- +title: useRouterState +--- + +# useRouterState + + + +[MODES: framework, data] + +## Summary + +A unified hook for reading router state: current (`active`) and in-flight +(`pending`) locations, search params, params, matches, and navigation type. + +```tsx +import { unstable_useRouterState as useRouterState } from "react-router"; + +let { active, pending } = useRouterState(); +active.params; // params from the leaf match +pending?.location.pathname; // populated during in-flight navigations +``` + +## Signature + +```tsx +function useRouterState(): unstable_RouterState +``` + +## Returns + +The current router state with `active` and `pending` variants + From a907779ffc6ce9437ef387bbecda2601b6b8d018 Mon Sep 17 00:00:00 2001 From: karthik <74087332+karthik-idikuda@users.noreply.github.com> Date: Thu, 7 May 2026 20:56:01 +0530 Subject: [PATCH 05/23] fix: fire onError for errors already present in initial router state (#14942) * fix: fire onError for errors already present in initial router state When a loader throws synchronously and the error is already in the initial router state at mount time, the onError callback was never called because it only fires inside the router subscriber (which only runs on subsequent state updates). Added a useEffect that checks for errors in router.state on mount and fires onError for each one. This ensures the onError callback is called for all errors, including those that occur before the subscriber is set up. Fixes remix-run/react-router#14941 * Add changeset and sign CLA * Updates * Apply suggestions from code review Co-authored-by: Matt Brophy * Migrate changeset --------- Co-authored-by: Matt Brophy --- contributors.yml | 1 + .../patch.fix-onerror-initial-errors.md | 1 + .../__tests__/dom/client-on-error-test.tsx | 58 +++++++++++++++++++ packages/react-router/lib/components.tsx | 2 +- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/react-router/.changes/patch.fix-onerror-initial-errors.md diff --git a/contributors.yml b/contributors.yml index 01178019f1..caa681eef1 100644 --- a/contributors.yml +++ b/contributors.yml @@ -224,6 +224,7 @@ - KaranRandhir - kark - KAROTT7 +- karthik-idikuda - kddnewton - ken0x0a - kentcdodds diff --git a/packages/react-router/.changes/patch.fix-onerror-initial-errors.md b/packages/react-router/.changes/patch.fix-onerror-initial-errors.md new file mode 100644 index 0000000000..89161cea50 --- /dev/null +++ b/packages/react-router/.changes/patch.fix-onerror-initial-errors.md @@ -0,0 +1 @@ +Fix `RouterProvider` `onError` callback not being called for synchronous initial loader errors in SPA mode diff --git a/packages/react-router/__tests__/dom/client-on-error-test.tsx b/packages/react-router/__tests__/dom/client-on-error-test.tsx index 9a945c9321..ddb5251a36 100644 --- a/packages/react-router/__tests__/dom/client-on-error-test.tsx +++ b/packages/react-router/__tests__/dom/client-on-error-test.tsx @@ -613,4 +613,62 @@ describe(`handleError`, () => { await waitFor(() => screen.getByText("FETCH")); expect(spy.mock.calls.length).toBe(1); }); + + it("handles initial load synchronous loader errors in SPA mode", async () => { + let spy = jest.fn(); + let router = createMemoryRouter([ + { + path: "/", + loader() { + throw new Error("immediate loader error!"); + }, + Component: () =>

Home

, + HydrateFallback: () =>

Loading...

, + ErrorBoundary: () => ( +

Error:{(useRouteError() as Error).message}

+ ), + }, + ]); + + render(); + + await waitFor(() => screen.getByText("Error:immediate loader error!")); + + expect(spy).toHaveBeenCalledWith(new Error("immediate loader error!"), { + location: expect.objectContaining({ pathname: "/" }), + params: {}, + pattern: "/", + }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("does not fire onError for errors from hydrationData", async () => { + let spy = jest.fn(); + let router = createMemoryRouter( + [ + { + path: "/", + loader() { + throw new Error("hydration error!"); + }, + Component: () =>

Home

, + ErrorBoundary: () => ( +

Error:{(useRouteError() as Error).message}

+ ), + }, + ], + { + hydrationData: { + errors: { "0": new Error("hydration error!") }, + loaderData: {}, + }, + }, + ); + + render(); + + await waitFor(() => screen.getByText("Error:hydration error!")); + + expect(spy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 5234145af9..b766e5d67a 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -628,7 +628,7 @@ export function RouterProvider({ setState(router.state, { deletedFetchers: [], flushSync: false, - newErrors: null, + newErrors: router.state.errors, // Initial data load errors }); } }, [initialized, setState, router.state]); From 041cd3236e39edd4d0a2d34999a46b61211c1605 Mon Sep 17 00:00:00 2001 From: Dami Oyeniyi Date: Thu, 7 May 2026 16:33:53 +0100 Subject: [PATCH 06/23] fix(react-router): Internal preloads refactor to preserve types (#14860) --- contributors.yml | 1 + .../react-router/lib/dom/ssr/components.tsx | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/contributors.yml b/contributors.yml index caa681eef1..299b6af77a 100644 --- a/contributors.yml +++ b/contributors.yml @@ -154,6 +154,7 @@ - goodrone - gowthamvbhat - GraxMonzo +- grzdev - guppy0356 - GuptaSiddhant - haivuw diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 78f362796a..0f1f6e1bb6 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -975,13 +975,16 @@ import(${JSON.stringify(manifest.entry.module)});`; let preloads = isHydrated || isRSCRouterContext ? [] - : dedupe( - manifest.entry.imports.concat( - getModuleLinkHrefs(matches, manifest, { - includeHydrateFallback: true, - }), + : [ + // Dedupe through a Set + ...new Set( + manifest.entry.imports.concat( + getModuleLinkHrefs(matches, manifest, { + includeHydrateFallback: true, + }), + ), ), - ); + ]; let sri = typeof manifest.sri === "object" ? manifest.sri : {}; @@ -1039,10 +1042,6 @@ import(${JSON.stringify(manifest.entry.module)});`; ); } -function dedupe(array: any[]) { - return [...new Set(array)]; -} - export function mergeRefs( ...refs: Array | React.LegacyRef> ): React.RefCallback { From 954a4a6afe4a1a3bd3086dcc2f838cd2635fae3b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 7 May 2026 16:25:45 -0400 Subject: [PATCH 07/23] Fix stale SSR data when hydration is aborted by a same-route navigation (#15022) * fix(react-router): clear isHydrationRequest on entry so aborted hydrations dont leak stale SSR data into subsequent loader calls When a clientLoader.hydrate=true loader is still pending (awaiting async work before serverLoader()) and the hydration POP is aborted by a new navigation, the finally block that clears isHydrationRequest never runs. The next loader invocation reuses the same closure, sees isHydrationRequest=true, and serverLoader() returns the SSR initialData captured for the original URL instead of fetching fresh data. Capture the flag into a local on entry and clear the closure immediately so any subsequent invocation observes false regardless of whether the prior call ever completed. Co-Authored-By: Claude Opus 4.7 (1M context) * Remove focused test --------- Co-authored-by: Claude Opus 4.7 (1M context) --- integration/client-data-test.ts | 93 +++++++++++++++++++ .../patch.fix-hydration-request-stale.md | 1 + packages/react-router/lib/dom/ssr/routes.tsx | 73 +++++++-------- packages/react-router/lib/rsc/browser.tsx | 49 +++++----- 4 files changed, 155 insertions(+), 61 deletions(-) create mode 100644 packages/react-router/.changes/patch.fix-hydration-request-stale.md diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 768460e66f..50aec0436c 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -960,6 +960,49 @@ test.describe("Client Data", () => { return

Hi!

; } `, + + "app/routes/client-loader-critical.aborted-hydration-fetches-fresh-data.tsx": js` + import { Link } from "react-router"; + + export function loader({ request }) { + return { query: new URL(request.url).searchParams.get("q") || "empty" }; + } + + export async function clientLoader({ serverLoader, request }) { + let q = new URL(request.url).searchParams.get("q") || "empty"; + + // Delay the initial invocation + if (q === "initial") { + if (!window.__hydrationBlock) { + let { promise, resolve } = Promise.withResolvers(); + window.__resolveHydrationBlock = resolve + window.__hydrationBlock = promise; + await window.__hydrationBlock; + } + } + + let serverData = await serverLoader(); + return { + ...serverData, + clientLoaderRan: true, + clientLoaderQuery: q, + }; + } + + clientLoader.hydrate = true; + + export default function Component({ loaderData }) { + return ( +
+

{loaderData.query}

+

{String(loaderData.clientLoaderQuery ?? "none")}

+ + Update query + +
+ ); + } + `, }, }, ServerMode.Development, // Avoid error sanitization @@ -1304,6 +1347,56 @@ test.describe("Client Data", () => { await expect(page.locator("#parent-2-data")).toHaveText("1"); await expect(page.locator("#b")).toHaveText("Hi!"); }); + + // When a same-route navigation aborts the pending hydration + // POP, serverLoader() must fetch fresh data — not return the + // stale SSR initialData captured for the original URL. + test("serverLoader() fetches fresh data when a same-route navigation aborts hydration", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto( + "/client-loader-critical/aborted-hydration-fetches-fresh-data?q=initial", + ); + + // SSR shows the server loader's data; clientLoader hasn't completed yet + await expect(page.locator("[data-server-query]")).toHaveText( + "initial", + ); + await expect( + page.locator("[data-client-loader-query]"), + ).toHaveText("none"); + + // Click before hydration completes to abort the hydration clientLoader call before it calls serverLoader + await app.clickLink( + "/client-loader-critical/aborted-hydration-fetches-fresh-data?q=updated", + { wait: false }, + ); + + await page.waitForURL(/q=updated/); + + // PUSH ran the clientLoader as call #2 and saw the new URL and the serverLoader + // invocation doesn't return hydrationData + await expect(page.locator("[data-server-query]")).toHaveText( + "updated", + ); + await expect( + page.locator("[data-client-loader-query]"), + ).toHaveText("updated"); + + // Release the still-pending hydration call so it can unwind. + await page.evaluate(() => + (window as any).__resolveHydrationBlock(), + ); + + await expect(page.locator("[data-server-query]")).toHaveText( + "updated", + ); + await expect( + page.locator("[data-client-loader-query]"), + ).toHaveText("updated"); + }); }); test.describe("clientLoader - lazy route module", () => { diff --git a/packages/react-router/.changes/patch.fix-hydration-request-stale.md b/packages/react-router/.changes/patch.fix-hydration-request-stale.md new file mode 100644 index 0000000000..3f17f4db26 --- /dev/null +++ b/packages/react-router/.changes/patch.fix-hydration-request-stale.md @@ -0,0 +1 @@ +Fix `serverLoader()` returning stale SSR data when a client navigation aborts pending hydration before the hydration `clientLoader` resolves diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index e32fe658df..ae8ebb72e8 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -343,47 +343,46 @@ export function createClientRoutes( { request, params, context, pattern, url }: LoaderFunctionArgs, singleFetch?: unknown, ) => { - try { - let result = await prefetchStylesAndCallHandler(async () => { - invariant( - routeModule, - "No `routeModule` available for critical-route loader", - ); - if (!routeModule.clientLoader) { - // Call the server when no client loader exists - return fetchServerLoader(singleFetch); - } + // Capture and clear immediately so that if this call is aborted + // mid-flight, subsequent calls won't see a stale `true` value + let _isHydrationRequest = isHydrationRequest; + isHydrationRequest = false; + + let result = await prefetchStylesAndCallHandler(async () => { + invariant( + routeModule, + "No `routeModule` available for critical-route loader", + ); + if (!routeModule.clientLoader) { + // Call the server when no client loader exists + return fetchServerLoader(singleFetch); + } - return routeModule.clientLoader({ - request, - params, - context, - pattern, - url, - async serverLoader() { - preventInvalidServerHandlerCall("loader", route); - - // On the first call, resolve with the server result - if (isHydrationRequest) { - if (hasInitialData) { - return initialData; - } - if (hasInitialError) { - throw initialError; - } + return routeModule.clientLoader({ + request, + params, + context, + pattern, + url, + async serverLoader() { + preventInvalidServerHandlerCall("loader", route); + + // On the first call, resolve with the server result + if (_isHydrationRequest) { + if (hasInitialData) { + return initialData; + } + if (hasInitialError) { + throw initialError; } + } - // Call the server loader for client-side navigations - return fetchServerLoader(singleFetch); - }, - }); + // Call the server loader for client-side navigations + return fetchServerLoader(singleFetch); + }, }); - return result; - } finally { - // Whether or not the user calls `serverLoader`, we only let this - // stick around as true for one loader call - isHydrationRequest = false; - } + }); + return result; }; // Let React Router know whether to run this on hydration diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 7d1b7656dd..4c2ee5a7d2 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -917,31 +917,32 @@ function createRouteFromServerManifest( index: match.index, loader: match.clientLoader ? async (args, singleFetch) => { - try { - let result = await match.clientLoader!({ - ...args, - serverLoader: () => { - preventInvalidServerHandlerCall( - "loader", - match.id, - match.hasLoader, - ); - // On the first call, resolve with the server result - if (isHydrationRequest) { - if (hasInitialData) { - return initialData; - } - if (hasInitialError) { - throw initialError; - } + // Capture and clear immediately so that if this call is aborted + // mid-flight, subsequent calls won't see a stale `true` value + let _isHydrationRequest = isHydrationRequest; + isHydrationRequest = false; + + let result = await match.clientLoader!({ + ...args, + serverLoader: () => { + preventInvalidServerHandlerCall( + "loader", + match.id, + match.hasLoader, + ); + // On the first call, resolve with the server result + if (_isHydrationRequest) { + if (hasInitialData) { + return initialData; } - return callSingleFetch(singleFetch); - }, - }); - return result; - } finally { - isHydrationRequest = false; - } + if (hasInitialError) { + throw initialError; + } + } + return callSingleFetch(singleFetch); + }, + }); + return result; } : // We always make the call in this RSC world since even if we don't // have a `loader` we may need to get the `element` implementation From aabd30c8d17fe698a64e096c9ee357cf1c3588fb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 11 May 2026 10:25:34 -0400 Subject: [PATCH 08/23] Use shared isMutationMethod check (#15033) --- .../.changes/patch.shared-mutation-check.md | 1 + packages/react-router/lib/rsc/server.rsc.ts | 2 +- .../react-router/lib/server-runtime/server.ts | 42 +++++++++---------- 3 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 packages/react-router/.changes/patch.shared-mutation-check.md diff --git a/packages/react-router/.changes/patch.shared-mutation-check.md b/packages/react-router/.changes/patch.shared-mutation-check.md new file mode 100644 index 0000000000..4cf74e9de7 --- /dev/null +++ b/packages/react-router/.changes/patch.shared-mutation-check.md @@ -0,0 +1 @@ +Internal refactor to consolidate mutation request detection through shared utility diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 69a6dab548..d85ab73f22 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -850,7 +850,7 @@ async function generateRenderResponse( let formState: unknown; let skipRevalidation = false; let potentialCSRFAttackError: unknown | undefined; - if (request.method === "POST") { + if (isMutationMethod(request.method)) { try { throwIfPotentialCSRFAttack(request.headers, allowedActionOrigins); diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index ae90e90c00..4cdb8b673d 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -15,6 +15,7 @@ import { createStaticHandler, isRedirectResponse, isResponse, + isMutationMethod, } from "../router/router"; import type { AppLoadContext } from "./data"; import type { HandleErrorFunction, ServerBuild } from "./build"; @@ -446,26 +447,25 @@ async function handleSingleFetchRequest( let handlerUrl = new URL(request.url); handlerUrl.pathname = normalizedPath; - let response = - request.method !== "GET" - ? await singleFetchAction( - build, - serverMode, - staticHandler, - request, - handlerUrl, - loadContext, - handleError, - ) - : await singleFetchLoaders( - build, - serverMode, - staticHandler, - request, - handlerUrl, - loadContext, - handleError, - ); + let response = isMutationMethod(request.method) + ? await singleFetchAction( + build, + serverMode, + staticHandler, + request, + handlerUrl, + loadContext, + handleError, + ) + : await singleFetchLoaders( + build, + serverMode, + staticHandler, + request, + handlerUrl, + loadContext, + handleError, + ); return response; } @@ -481,7 +481,7 @@ async function handleDocumentRequest( criticalCss?: CriticalCss, ) { try { - if (request.method === "POST") { + if (isMutationMethod(request.method)) { try { throwIfPotentialCSRFAttack( request.headers, From 7e6725a4c513dea08689e72cf632bcd4f75e0171 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 11 May 2026 11:44:51 -0400 Subject: [PATCH 09/23] Cleanup lint issues (#15030) * Cleanup lint issues in tests, examples, scripts, and integration Co-Authored-By: Claude Opus 4.7 (1M context) * Cleanup lint issues in source code Co-Authored-By: Claude Opus 4.7 (1M context) * Revert "Cleanup lint issues in source code" This reverts commit 949599b5ceee50990fab44f33f0d82e647ee74d8. * Revert "Cleanup lint issues in tests, examples, scripts, and integration" This reverts commit db7be2447d632b53c81d62737e105f2bdf82c19f. * Fix lint errors * Fix test --------- Co-authored-by: Claude Opus 4.7 (1M context) --- examples/auth-router-provider/src/App.tsx | 5 ++- examples/data-router/src/todos.ts | 5 ++- integration/defer-test.ts | 1 - .../helpers/rsc-vite/src/entry.rsc.tsx | 1 - integration/helpers/vite.ts | 1 + integration/http-test.ts | 1 - integration/passthrough-requests-test.ts | 1 - packages/create-react-router/copy-template.ts | 15 ++++++-- packages/create-react-router/prompt.ts | 5 ++- .../prompts-prompt-base.ts | 5 ++- packages/create-react-router/utils.ts | 5 ++- packages/react-router-dev/config/routes.ts | 1 + packages/react-router-dev/vite/babel.ts | 1 - .../vite/cloudflare-dev-proxy.ts | 5 ++- .../react-router-dev/vite/has-dependency.ts | 5 ++- packages/react-router-dev/vite/plugin.ts | 5 +-- packages/react-router-dev/vite/rsc/plugin.ts | 5 --- packages/react-router-dev/vite/vite.ts | 1 - packages/react-router-node/stream.ts | 5 ++- .../__tests__/dom/nav-link-active-test.tsx | 10 ++++-- .../__tests__/dom/special-characters-test.tsx | 8 ++--- .../__tests__/dom/ssr/components-test.tsx | 6 ++-- .../react-router/__tests__/dom/stub-test.tsx | 2 ++ .../__tests__/react-transitions-test.tsx | 1 - .../router/utils/data-router-setup.ts | 10 ++++-- .../__tests__/router/utils/utils.ts | 10 ++++-- .../__tests__/vendor/turbo-stream-test.ts | 1 + packages/react-router/lib/dom/dom.ts | 5 ++- packages/react-router/lib/dom/lib.tsx | 15 ++++++-- .../react-router/lib/dom/ssr/components.tsx | 5 ++- packages/react-router/lib/dom/ssr/errors.ts | 5 ++- .../react-router/lib/dom/ssr/single-fetch.tsx | 20 ++++++++--- packages/react-router/lib/router/history.ts | 5 ++- packages/react-router/lib/router/router.ts | 35 +++++++++++++++---- packages/react-router/lib/router/utils.ts | 5 ++- .../lib/rsc/html-stream/server.ts | 5 ++- packages/react-router/lib/rsc/server.ssr.tsx | 21 ++++++----- .../lib/server-runtime/cookies.ts | 5 ++- .../react-router/lib/server-runtime/crypto.ts | 5 ++- .../react-router/lib/server-runtime/dev.ts | 5 ++- .../lib/server-runtime/routeMatching.ts | 2 +- .../react-router/lib/server-runtime/server.ts | 5 ++- .../lib/server-runtime/single-fetch.ts | 5 ++- packages/react-router/lib/types/route-data.ts | 2 +- packages/react-router/lib/types/utils.ts | 1 + .../vendor/turbo-stream-v2/turbo-stream.ts | 15 ++++++-- scripts/release-comments.ts | 2 +- scripts/utils.js | 5 ++- scripts/utils/git.ts | 1 + 49 files changed, 213 insertions(+), 82 deletions(-) diff --git a/examples/auth-router-provider/src/App.tsx b/examples/auth-router-provider/src/App.tsx index 98bf144c1f..c68d7dbc6e 100644 --- a/examples/auth-router-provider/src/App.tsx +++ b/examples/auth-router-provider/src/App.tsx @@ -134,7 +134,10 @@ async function loginAction({ request }: LoaderFunctionArgs) { // Sign in and redirect to the proper destination if successful. try { await fakeAuthProvider.signin(username); - } catch (error) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // Unused as of now but this is how you would handle invalid // username/password combinations - just like validating the inputs // above diff --git a/examples/data-router/src/todos.ts b/examples/data-router/src/todos.ts index 37bcf626db..016b0961e3 100644 --- a/examples/data-router/src/todos.ts +++ b/examples/data-router/src/todos.ts @@ -27,7 +27,10 @@ export function getTodos(): Todos { try { // @ts-expect-error OK to throw here since we're catching todos = JSON.parse(localStorage.getItem(TODOS_KEY)); - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} if (!todos) { todos = initializeTodos(); } diff --git a/integration/defer-test.ts b/integration/defer-test.ts index 13cb46b82d..945683bce3 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -1,5 +1,4 @@ import { test, expect } from "@playwright/test"; -import type { Page } from "@playwright/test"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; diff --git a/integration/helpers/rsc-vite/src/entry.rsc.tsx b/integration/helpers/rsc-vite/src/entry.rsc.tsx index d71ba2e0b9..a4da4cba31 100644 --- a/integration/helpers/rsc-vite/src/entry.rsc.tsx +++ b/integration/helpers/rsc-vite/src/entry.rsc.tsx @@ -34,7 +34,6 @@ export async function fetchServer(request: Request) { export default async function handler(request: Request) { const ssr = await import.meta.viteRsc.loadModule< - // eslint-disable-next-line @typescript-eslint/consistent-type-imports typeof import("./entry.ssr") >("ssr", "index"); return ssr.default(request, await fetchServer(request)); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 273fb18496..b342ada5dc 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -502,6 +502,7 @@ export const test = base.extend({ }); stop?.(); }, + // eslint-disable-next-line no-empty-pattern vitePreview: async ({}, use) => { let stop: (() => unknown) | undefined; await use(async (files, template) => { diff --git a/integration/http-test.ts b/integration/http-test.ts index f4a13ab70c..98ae69ee52 100644 --- a/integration/http-test.ts +++ b/integration/http-test.ts @@ -1,6 +1,5 @@ import { test, expect } from "@playwright/test"; -import { UNSAFE_ServerMode as ServerMode } from "react-router"; import { createFixture, js } from "./helpers/create-fixture.js"; import type { Fixture } from "./helpers/create-fixture.js"; diff --git a/integration/passthrough-requests-test.ts b/integration/passthrough-requests-test.ts index 27334ec279..287c139a8f 100644 --- a/integration/passthrough-requests-test.ts +++ b/integration/passthrough-requests-test.ts @@ -1,6 +1,5 @@ import { test, expect } from "@playwright/test"; import { - type AppFixture, createAppFixture, createFixture, js, diff --git a/packages/create-react-router/copy-template.ts b/packages/create-react-router/copy-template.ts index f4295aa306..5685a8a2b0 100644 --- a/packages/create-react-router/copy-template.ts +++ b/packages/create-react-router/copy-template.ts @@ -87,7 +87,10 @@ function isLocalFilePath(input: string): boolean { path.isAbsolute(input) ? input : path.resolve(process.cwd(), input), ) ); - } catch (_) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return false; } } @@ -343,7 +346,10 @@ async function downloadAndExtractTarball( }, }), ); - } catch (_) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { throw new CopyTemplateError( "There was a problem extracting the file from the provided template." + ` Template URL: \`${tarballUrl}\`` + @@ -410,7 +416,10 @@ function isValidGithubRepoUrl( ? pathSegments[2] === "tree" && pathSegments.length >= 4 : true) ); - } catch (_) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return false; } } diff --git a/packages/create-react-router/prompt.ts b/packages/create-react-router/prompt.ts index ab70374e34..e63f0c187e 100644 --- a/packages/create-react-router/prompt.ts +++ b/packages/create-react-router/prompt.ts @@ -61,7 +61,10 @@ export async function prompt< answer = await prompts[type](Object.assign({ stdin, stdout }, question)); answers[name] = answer as any; quit = await onSubmit(question, answer, answers); - } catch (err) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { quit = !(await onCancel(question, answers)); } if (quit) { diff --git a/packages/create-react-router/prompts-prompt-base.ts b/packages/create-react-router/prompts-prompt-base.ts index c915950fca..3648988b01 100644 --- a/packages/create-react-router/prompts-prompt-base.ts +++ b/packages/create-react-router/prompts-prompt-base.ts @@ -42,7 +42,10 @@ export class Prompt extends EventEmitter { if (a === false) { try { this._(str, key); - } catch (_) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} // @ts-expect-error } else if (typeof this[a] === "function") { // @ts-expect-error diff --git a/packages/create-react-router/utils.ts b/packages/create-react-router/utils.ts index 6c30c56c85..fbbbd4bb25 100644 --- a/packages/create-react-router/utils.ts +++ b/packages/create-react-router/utils.ts @@ -226,7 +226,10 @@ export function isUrl(value: string | URL) { try { new URL(value); return true; - } catch (_) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return false; } } diff --git a/packages/react-router-dev/config/routes.ts b/packages/react-router-dev/config/routes.ts index fa94d482f7..98d03a55ec 100644 --- a/packages/react-router-dev/config/routes.ts +++ b/packages/react-router-dev/config/routes.ts @@ -310,6 +310,7 @@ function prefix( }); } +// eslint-disable-next-line @typescript-eslint/no-unused-vars const helpers = { route, index, layout, prefix }; export { route, index, layout, prefix }; /** diff --git a/packages/react-router-dev/vite/babel.ts b/packages/react-router-dev/vite/babel.ts index b4449c65b3..c4a721db7d 100644 --- a/packages/react-router-dev/vite/babel.ts +++ b/packages/react-router-dev/vite/babel.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ import type { NodePath } from "@babel/traverse"; import type { types as Babel } from "@babel/core"; import { parse, type ParseResult } from "@babel/parser"; diff --git a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts index 2db12f6e8a..4f992cc340 100644 --- a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts +++ b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts @@ -32,7 +32,10 @@ type GetLoadContext = (args: { function importWrangler() { try { return import("wrangler"); - } catch (_) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { throw Error("Could not import `wrangler`. Do you have it installed?"); } } diff --git a/packages/react-router-dev/vite/has-dependency.ts b/packages/react-router-dev/vite/has-dependency.ts index 59b65a1931..e9dff81cab 100644 --- a/packages/react-router-dev/vite/has-dependency.ts +++ b/packages/react-router-dev/vite/has-dependency.ts @@ -7,7 +7,10 @@ export function hasDependency({ }) { try { return Boolean(require.resolve(name, { paths: [rootDirectory] })); - } catch (err) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return false; } } diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 60180d447d..05b07e544c 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -657,9 +657,6 @@ let getServerBundleRouteIds = ( return Object.keys(serverBundleRoutes); }; -const injectQuery = (url: string, query: string) => - url.includes("?") ? url.replace("?", `?${query}&`) : `${url}?${query}`; - let defaultEntriesDir = path.resolve( path.dirname(require.resolve("@react-router/dev/package.json")), "dist", @@ -2833,7 +2830,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { async finalize(buildDirectory) { invariant(viteConfig); - let { ssr, future } = ctx.reactRouterConfig; + let { ssr } = ctx.reactRouterConfig; // if ssr:false is set if (!ssr) { diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index fa9878f1eb..cc32a3a281 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -78,11 +78,6 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { )?.[1]; } - function isMdxRouteModule(filename: string) { - let extension = path.extname(filename).toLowerCase(); - return extension === ".md" || extension === ".mdx"; - } - function getTransformLanguage( filename: string, ): "ts" | "tsx" | "jsx" | undefined { diff --git a/packages/react-router-dev/vite/vite.ts b/packages/react-router-dev/vite/vite.ts index a5db6bb350..71874a3d1a 100644 --- a/packages/react-router-dev/vite/vite.ts +++ b/packages/react-router-dev/vite/vite.ts @@ -4,7 +4,6 @@ import type { DepOptimizationConfig, ESBuildOptions } from "vite"; import invariant from "../invariant"; import { isReactRouterRepo } from "../config/is-react-router-repo"; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports type Vite = typeof import("vite"); let vite: Vite | undefined; diff --git a/packages/react-router-node/stream.ts b/packages/react-router-node/stream.ts index 6aeccf585c..806dd25e68 100644 --- a/packages/react-router-node/stream.ts +++ b/packages/react-router-node/stream.ts @@ -139,7 +139,10 @@ class StreamPump { if (available <= 0) { this.pause(); } - } catch (error: any) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { this.controller.error( new Error( "Could not create Buffer, chunk must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object", diff --git a/packages/react-router/__tests__/dom/nav-link-active-test.tsx b/packages/react-router/__tests__/dom/nav-link-active-test.tsx index 11449c9c77..6213df570b 100644 --- a/packages/react-router/__tests__/dom/nav-link-active-test.tsx +++ b/packages/react-router/__tests__/dom/nav-link-active-test.tsx @@ -1045,13 +1045,19 @@ function createDeferred() { res(val); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; reject = async (error?: Error) => { rej(error); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; }); return { diff --git a/packages/react-router/__tests__/dom/special-characters-test.tsx b/packages/react-router/__tests__/dom/special-characters-test.tsx index f9c64b128e..33ed863602 100644 --- a/packages/react-router/__tests__/dom/special-characters-test.tsx +++ b/packages/react-router/__tests__/dom/special-characters-test.tsx @@ -772,8 +772,8 @@ describe("special character tests", () => { expect(getHtml(ctx.container)).toMatchInlineSnapshot(` "
Link to grandchild @@ -786,8 +786,8 @@ describe("special character tests", () => { expect(getHtml(ctx.container)).toMatchInlineSnapshot(` "
Link to grandchild diff --git a/packages/react-router/__tests__/dom/ssr/components-test.tsx b/packages/react-router/__tests__/dom/ssr/components-test.tsx index 3b4a0a1e36..a26920631e 100644 --- a/packages/react-router/__tests__/dom/ssr/components-test.tsx +++ b/packages/react-router/__tests__/dom/ssr/components-test.tsx @@ -16,10 +16,7 @@ import { FrameworkContext, usePrefetchBehavior, } from "../../../lib/dom/ssr/components"; -import { - DataRouterContext, - DataRouterStateContext, -} from "../../../lib/context"; +import { DataRouterStateContext } from "../../../lib/context"; import invariant from "../../../lib/dom/ssr/invariant"; import { ServerRouter } from "../../../lib/dom/ssr/server"; import "@testing-library/jest-dom"; @@ -476,6 +473,7 @@ describe("usePrefetchBehavior", () => { }) { let [shouldPrefetch, ref] = usePrefetchBehavior(prefetch, {}); return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid Link diff --git a/packages/react-router/__tests__/dom/stub-test.tsx b/packages/react-router/__tests__/dom/stub-test.tsx index 825349e6de..f48e7d168e 100644 --- a/packages/react-router/__tests__/dom/stub-test.tsx +++ b/packages/react-router/__tests__/dom/stub-test.tsx @@ -84,6 +84,7 @@ test("middleware works without loader", async () => { }); await waitFor(() => screen.findByText("Target")); + expect(true).toBe(true); }); test("middleware works with loader", async () => { @@ -106,6 +107,7 @@ test("middleware works with loader", async () => { render(); await waitFor(() => screen.findByText("Message: hello")); + expect(true).toBe(true); }); // eslint-disable-next-line jest/expect-expect diff --git a/packages/react-router/__tests__/react-transitions-test.tsx b/packages/react-router/__tests__/react-transitions-test.tsx index 3235d6becc..36b0c069a7 100644 --- a/packages/react-router/__tests__/react-transitions-test.tsx +++ b/packages/react-router/__tests__/react-transitions-test.tsx @@ -515,7 +515,6 @@ describe("react transitions", () => { { index: true, Component() { - let navigate = useNavigate(); return ( Go to page diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index df2c2c2145..4135bc9f30 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -380,7 +380,10 @@ export function setup({ await internalHelpers.dfd.resolve(redirectResponse); } await tick(); - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} return helpers; } @@ -400,7 +403,10 @@ export function setup({ async reject(value) { try { await internalHelpers.dfd.reject(value); - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }, async redirect(href, status = 301, headers = {}, shims = []) { return _redirect(true, href, status, headers, shims); diff --git a/packages/react-router/__tests__/router/utils/utils.ts b/packages/react-router/__tests__/router/utils/utils.ts index 55ed3ee228..cdeb40b37d 100644 --- a/packages/react-router/__tests__/router/utils/utils.ts +++ b/packages/react-router/__tests__/router/utils/utils.ts @@ -40,13 +40,19 @@ export function createDeferred() { res(val); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; reject = async (error?: Error) => { rej(error); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; }); return { diff --git a/packages/react-router/__tests__/vendor/turbo-stream-test.ts b/packages/react-router/__tests__/vendor/turbo-stream-test.ts index 1bb967dd2a..5731758da3 100644 --- a/packages/react-router/__tests__/vendor/turbo-stream-test.ts +++ b/packages/react-router/__tests__/vendor/turbo-stream-test.ts @@ -585,6 +585,7 @@ test("should allow many nested promises without a memory leak", async () => { test("should encode large payload", async () => { const input = createDeeplyNestedObject(); await readStreamToString(encode(input)); + expect(true).toBe(true); }); test("should encode and decode large payload and yield the event loop", async () => { diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 562050adbe..c08f165a72 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -146,7 +146,10 @@ function isFormDataSubmitterSupported() { 0, ); _formDataSupportsSubmitter = false; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { _formDataSupportsSubmitter = true; } } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 9f6dde80bf..c012f4919e 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -122,7 +122,10 @@ try { // @ts-expect-error REACT_ROUTER_VERSION; } -} catch (e) { +} catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e +) { // no-op } //#endregion @@ -738,7 +741,10 @@ function deserializeErrors( // because we don't serialize SSR stack traces for security reasons error.stack = ""; serialized[key] = error; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // no-op - fall through and create a normal Error } } @@ -3115,7 +3121,10 @@ export function useScrollRestoration({ if (sessionPositions) { savedScrollPositions = JSON.parse(sessionPositions); } - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // no-op, use default empty object } }, [storageKey]); diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 0f1f6e1bb6..a6474b8cc2 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -726,7 +726,10 @@ export function Meta(): React.JSX.Element { dangerouslySetInnerHTML={{ __html: escapeHtml(json) }} /> ); - } catch (err) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return null; } } diff --git a/packages/react-router/lib/dom/ssr/errors.ts b/packages/react-router/lib/dom/ssr/errors.ts index 43bb0c9f2d..3bdd82c414 100644 --- a/packages/react-router/lib/dom/ssr/errors.ts +++ b/packages/react-router/lib/dom/ssr/errors.ts @@ -27,7 +27,10 @@ export function deserializeErrors( let error = new ErrorConstructor(val.message); error.stack = val.stack; serialized[key] = error; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // no-op - fall through and create a normal Error } } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index eff82ebc07..2860c51267 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -565,7 +565,10 @@ async function bubbleMiddlewareErrors( } }); } - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // No-op - this logic is only intended to process successful responses // If the `.data` failed, the routes will handle those errors themselves } @@ -726,7 +729,10 @@ async function fetchAndDecodeViaTurboStream( } } return { status: res.status, data }; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // Can't clone after consuming the body via turbo-stream so we can't // include the body here. In an ideal world we'd look for a turbo-stream // content type here, or even X-Remix-Response but then folks can't @@ -842,13 +848,19 @@ function createDeferred() { res(val); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; reject = async (error?: unknown) => { rej(error); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; }); return { diff --git a/packages/react-router/lib/router/history.ts b/packages/react-router/lib/router/history.ts index 44dd8eff81..29821d3bcf 100644 --- a/packages/react-router/lib/router/history.ts +++ b/packages/react-router/lib/router/history.ts @@ -536,7 +536,10 @@ export function warning(cond: any, message: string) { // find the source for a warning that appears in the console by // enabling "pause on exceptions" in your JavaScript debugger. throw new Error(message); - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} } } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index c0ce6887e8..68d474f724 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -5125,7 +5125,10 @@ function normalizeNavigateOptions( text: undefined, }, }; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return getInvalidBodyError(); } } @@ -5155,7 +5158,10 @@ function normalizeNavigateOptions( try { searchParams = new URLSearchParams(opts.body); formData = convertSearchParamsToFormData(searchParams); - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return getInvalidBodyError(); } } @@ -6467,7 +6473,10 @@ async function callDataStrategyImpl( m._lazyPromises?.route, ]), ); - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // No-op } @@ -6776,7 +6785,10 @@ function normalizeRedirectLocation( if (invalidProtocols.includes(url.protocol)) { throw new Error("Invalid redirect location"); } - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} return location; } @@ -7508,7 +7520,10 @@ function restoreAppliedTransitions( } } } - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // no-op, use default empty object } } @@ -7544,13 +7559,19 @@ function createDeferred() { res(val); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; reject = async (error?: Error) => { rej(error); try { await promise; - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} }; }); return { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 9e4b4f4029..c1ec6c55db 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -2234,7 +2234,10 @@ export function parseToInfo( } else { isExternal = true; } - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // We can't do external URL detection without a valid URL warning( false, diff --git a/packages/react-router/lib/rsc/html-stream/server.ts b/packages/react-router/lib/rsc/html-stream/server.ts index d990aff634..38cd5bdb3a 100644 --- a/packages/react-router/lib/rsc/html-stream/server.ts +++ b/packages/react-router/lib/rsc/html-stream/server.ts @@ -76,7 +76,10 @@ async function writeRSCStream( JSON.stringify(decoder.decode(chunk, { stream: true })), controller, ); - } catch (err) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { let base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk))); writeChunk( `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`, diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 2952f38501..eca9b8ce02 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -294,9 +294,9 @@ export async function routeRSCServerRequest({ statusText, headers, }); - } catch (reason) { - if (reason instanceof Response) { - return reason; + } catch (error) { + if (error instanceof Response) { + return error; } if (renderRedirect) { @@ -309,9 +309,9 @@ export async function routeRSCServerRequest({ } try { - reason = renderError ?? reason; - let [status, statusText] = isRouteErrorResponse(reason) - ? [reason.status, reason.statusText] + let normalizedError = renderError ?? error; + let [status, statusText] = isRouteErrorResponse(normalizedError) + ? [normalizedError.status, normalizedError.statusText] : [500, ""]; let retryRedirect: { status: number; location: string } | undefined; @@ -327,7 +327,7 @@ export async function routeRSCServerRequest({ status, errors: deepestRenderedBoundaryId ? { - [deepestRenderedBoundaryId]: reason, + [deepestRenderedBoundaryId]: normalizedError, } : {}, }), @@ -427,11 +427,14 @@ export async function routeRSCServerRequest({ statusText, headers, }); - } catch { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + error2 + ) { // Throw the original error below } - throw reason; + throw error; } } diff --git a/packages/react-router/lib/server-runtime/cookies.ts b/packages/react-router/lib/server-runtime/cookies.ts index 42ffeab6d6..284e6c1aad 100644 --- a/packages/react-router/lib/server-runtime/cookies.ts +++ b/packages/react-router/lib/server-runtime/cookies.ts @@ -178,7 +178,10 @@ function encodeData(value: any): string { function decodeData(value: string): any { try { return JSON.parse(decodeURIComponent(myEscape(atob(value)))); - } catch (error: unknown) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return {}; } } diff --git a/packages/react-router/lib/server-runtime/crypto.ts b/packages/react-router/lib/server-runtime/crypto.ts index 897bd1167f..4f40c08c61 100644 --- a/packages/react-router/lib/server-runtime/crypto.ts +++ b/packages/react-router/lib/server-runtime/crypto.ts @@ -28,7 +28,10 @@ export const unsign = async ( let valid = await crypto.subtle.verify("HMAC", key, signature, data); return valid ? value : false; - } catch (error: unknown) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // atob will throw a DOMException with name === 'InvalidCharacterError' // if the signature contains a non-base64 character, which should just // be treated as an invalid signature. diff --git a/packages/react-router/lib/server-runtime/dev.ts b/packages/react-router/lib/server-runtime/dev.ts index 1d2b2be036..4cd69b6420 100644 --- a/packages/react-router/lib/server-runtime/dev.ts +++ b/packages/react-router/lib/server-runtime/dev.ts @@ -25,7 +25,10 @@ export function getBuildTimeHeader(request: Request, headerName: string) { ) { return request.headers.get(headerName); } - } catch (e) {} + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) {} } return null; } diff --git a/packages/react-router/lib/server-runtime/routeMatching.ts b/packages/react-router/lib/server-runtime/routeMatching.ts index 76b5f89aff..6957e6f9fd 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 { DataRouteObject, Params, RouteObject } from "../router/utils"; +import type { DataRouteObject, Params } from "../router/utils"; import type { RouteBranch } from "../router/utils"; import { matchRoutesImpl } from "../router/utils"; import invariant from "./invariant"; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 4cdb8b673d..40ab71d101 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -606,7 +606,10 @@ async function handleDocumentRequest( error.statusText, data, ); - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // If we can't unwrap the response - just leave it as-is } } diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 63679304c3..4ab9886353 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -51,7 +51,10 @@ export async function singleFetchAction( ? build.allowedActionOrigins : [], ); - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return handleQueryError(new Error("Bad Request"), 400); } diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 30ae969bee..43cb4102ac 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -2,7 +2,6 @@ import type { ClientLoaderFunctionArgs, ClientActionFunctionArgs, } from "../dom/ssr/routeModules"; -import type { Path } from "../router/history"; import type { DataWithResponseInit, RouterContextProvider, @@ -221,6 +220,7 @@ type _DataActionData = Awaited< undefined > +// eslint-disable-next-line @typescript-eslint/no-unused-vars type __tests = [ // ServerDataFrom Expect, undefined>>, diff --git a/packages/react-router/lib/types/utils.ts b/packages/react-router/lib/types/utils.ts index 37d23d0cd0..ca09bbd251 100644 --- a/packages/react-router/lib/types/utils.ts +++ b/packages/react-router/lib/types/utils.ts @@ -26,6 +26,7 @@ type _Normalize = type UnionKeys = T extends any ? keyof T : never; // prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars type __tests = [ Expect, {}>>, Expect, {a: string}>>, diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts index 48ff40ef95..3128162d86 100644 --- a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts +++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts @@ -70,7 +70,10 @@ async function decodeInitial( let line: unknown; try { line = JSON.parse(read.value); - } catch (reason) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reason + ) { throw new SyntaxError(); } @@ -100,7 +103,10 @@ async function decodeDeferred( let jsonLine: unknown; try { jsonLine = JSON.parse(lineData); - } catch (reason) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reason + ) { throw new SyntaxError(); } @@ -120,7 +126,10 @@ async function decodeDeferred( let jsonLine: unknown; try { jsonLine = JSON.parse(lineData); - } catch (reason) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reason + ) { throw new SyntaxError(); } const value = unflatten.call(this, jsonLine); diff --git a/scripts/release-comments.ts b/scripts/release-comments.ts index c28aef00ac..82a415be6f 100644 --- a/scripts/release-comments.ts +++ b/scripts/release-comments.ts @@ -263,7 +263,7 @@ async function findMergedPRs( }), ); - return result.filter((pr: any): pr is MergedPR => pr != undefined); + return result.filter((pr: any): pr is MergedPR => pr != null); } type ReferencedIssueResult = { diff --git a/scripts/utils.js b/scripts/utils.js index 5d3a94e410..3c5e91cd3d 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -68,7 +68,10 @@ async function fileExists(filePath) { try { await fsp.stat(filePath); return true; - } catch (_) { + } catch ( + // eslint-disable-next-line no-unused-vars + e + ) { return false; } } diff --git a/scripts/utils/git.ts b/scripts/utils/git.ts index 2d6bedd767..cf06fffb35 100644 --- a/scripts/utils/git.ts +++ b/scripts/utils/git.ts @@ -57,6 +57,7 @@ export function findVersionIntroductionCommit( } let parentLine = execGit(["rev-list", "--parents", "-n", "1", commit]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars let [_commit, ...parents] = parentLine .split(" ") .filter((line) => line.length > 0); From a90d1849069fa036ec84a905ccec4f39eaff85e6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 11 May 2026 13:03:24 -0400 Subject: [PATCH 10/23] fix: resolve basename/base conflict when basename matches app directory name (#15027) When Vite's `base` config and React Router's `basename` both share the same path segment as the app directory (e.g. `base: "/app/"`, `basename: "/app/"`), Vite strips the base prefix from server-build virtual module import paths during SSR module loading. This caused "Failed to load url /root.tsx" errors because "/app/root.tsx" was being resolved to "/root.tsx". Fix by passing `publicPath` to `resolveFileUrl` when generating server-build virtual module imports, so that the function falls back to the `/@fs/` absolute path form when the root-relative URL would conflict with the Vite base path. Fixes #13716 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration/vite-basename-test.ts | 14 ++++++++++++++ .../patch.fix-basename-app-directory-conflict.md | 7 +++++++ packages/react-router-dev/vite/plugin.ts | 5 ++++- packages/react-router-dev/vite/resolve-file-url.ts | 14 +++++++++++++- 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 packages/react-router-dev/.changes/patch.fix-basename-app-directory-conflict.md diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index f77842a7c9..b9d7dc7d33 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -221,6 +221,13 @@ test.describe("Vite base + React Router basename", () => { await workflowDev({ page, cwd, port, basename: "/mybase/app/" }); }); + test("works when base and basename match the app directory name", async ({ + page, + }) => { + await setup({ base: "/app/", basename: "/app/" }); + await workflowDev({ page, cwd, port, base: "/app/", basename: "/app/" }); + }); + test("errors if basename does not start with base", async ({ page, }) => { @@ -421,6 +428,13 @@ test.describe("Vite base + React Router basename", () => { }); }); + test("works when base and basename match the app directory name", async ({ + page, + }) => { + await setup({ base: "/app/", basename: "/app/" }); + await workflowBuild({ page, port, base: "/app/", basename: "/app/" }); + }); + test("works when basename does not start with base", async ({ page, }) => { diff --git a/packages/react-router-dev/.changes/patch.fix-basename-app-directory-conflict.md b/packages/react-router-dev/.changes/patch.fix-basename-app-directory-conflict.md new file mode 100644 index 0000000000..2d2f2e8106 --- /dev/null +++ b/packages/react-router-dev/.changes/patch.fix-basename-app-directory-conflict.md @@ -0,0 +1,7 @@ +Fix `basename` conflicting with `app` directory name when Vite `base` is set + +When the Vite `base` config and React Router `basename` both match the +app directory name (e.g. `base: "/app/"`, `basename: "/app/"`), Vite would +strip the base prefix from server-build virtual module import paths, causing +"Failed to load url /root.tsx" errors. The fix uses `/@fs/` absolute paths +for those imports to bypass Vite's base-stripping logic. diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 05b07e544c..14fbacb8e4 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -815,7 +815,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { return ` import * as entryServer from ${JSON.stringify( - resolveFileUrl(ctx, ctx.entryServerFilePath), + resolveFileUrl(ctx, ctx.entryServerFilePath, { + publicPath: ctx.publicPath, + }), )}; ${Object.keys(routes) .map((key, index) => { @@ -831,6 +833,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { resolveFileUrl( ctx, resolveRelativeRouteFilePath(route, ctx.reactRouterConfig), + { publicPath: ctx.publicPath }, ), )};`; } diff --git a/packages/react-router-dev/vite/resolve-file-url.ts b/packages/react-router-dev/vite/resolve-file-url.ts index ae688febed..8a7ff2a608 100644 --- a/packages/react-router-dev/vite/resolve-file-url.ts +++ b/packages/react-router-dev/vite/resolve-file-url.ts @@ -5,6 +5,7 @@ import { getVite } from "./vite"; export const resolveFileUrl = ( { rootDirectory }: { rootDirectory: string }, filePath: string, + { publicPath }: { publicPath?: string } = {}, ) => { let vite = getVite(); let relativePath = path.relative(rootDirectory, filePath); @@ -18,5 +19,16 @@ export const resolveFileUrl = ( return path.posix.join("/@fs", vite.normalizePath(filePath)); } - return "/" + vite.normalizePath(relativePath); + let url = "/" + vite.normalizePath(relativePath); + + // When the Vite base config (publicPath) matches the start of the + // root-relative file URL, Vite strips the base prefix during SSR module + // loading, causing the file to not be found (e.g. basename "/app/" with + // appDirectory "app/" makes "/app/root.tsx" resolve to "/root.tsx"). Use + // the /@fs/ absolute path form to bypass Vite's base stripping. + if (publicPath && publicPath !== "/" && url.startsWith(publicPath)) { + return path.posix.join("/@fs", vite.normalizePath(filePath)); + } + + return url; }; From 90040d9769c55ee7121f07eace47173fea542efe Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Mon, 11 May 2026 17:04:13 +0000 Subject: [PATCH 11/23] chore: format --- integration/vite-basename-test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index b9d7dc7d33..da77e4e36d 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -225,7 +225,13 @@ test.describe("Vite base + React Router basename", () => { page, }) => { await setup({ base: "/app/", basename: "/app/" }); - await workflowDev({ page, cwd, port, base: "/app/", basename: "/app/" }); + await workflowDev({ + page, + cwd, + port, + base: "/app/", + basename: "/app/", + }); }); test("errors if basename does not start with base", async ({ From 9fd7b7c4eef89dcacba6f203e67472781db0c598 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 11 May 2026 15:38:43 -0400 Subject: [PATCH 12/23] Update release comments workflow --- .github/workflows/release-comments.yml | 3 +-- .github/workflows/release.yml | 27 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-comments.yml b/.github/workflows/release-comments.yml index d95c51f3e3..273e80ab52 100644 --- a/.github/workflows/release-comments.yml +++ b/.github/workflows/release-comments.yml @@ -1,7 +1,6 @@ -name: 💬 Release Comments +name: 💬 Release Comments (manual) on: - workflow_call: workflow_dispatch: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92373a9034..064abb82d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,7 +116,32 @@ jobs: comment: name: 📝 Comment on released issues/pull requests needs: publish - uses: ./.github/workflows/release-comments.yml + runs-on: ubuntu-latest + permissions: + issues: write # enable commenting on released issues + pull-requests: write # enable commenting on released pull requests + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 # Needed for node TS support + cache: "pnpm" + + - name: 📥 Install deps + run: pnpm install --frozen-lockfile + + - name: 📝 Comment on released issues and pull requests + env: + GH_TOKEN: ${{ github.token }} + run: pnpm run release-comments experimental-release: name: 🧪 Experimental Release From 0041c694f44eb4abf5fd247e38e5444cce52f542 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 12 May 2026 11:44:49 -0400 Subject: [PATCH 13/23] Disable caching on publish workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 064abb82d4..6a06a85143 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,7 +133,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24 # Needed for node TS support - cache: "pnpm" + package-manager-cache: false - name: 📥 Install deps run: pnpm install --frozen-lockfile From 44c34783abbdd2be1a9fe1a4b843d49e704f9a0e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 13 May 2026 09:28:01 -0400 Subject: [PATCH 14/23] fix: prevent fetcher formData flicker and eliminate state.fetchers mutations (#15028) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../patch.fix-fetcher-formdata-flicker.md | 1 + .../.changes/patch.memoize-usefetchers.md | 1 + .../dom/data-browser-router-test.tsx | 111 +++++++- .../__tests__/router/fetchers-test.ts | 257 +++++++++++++++++- packages/react-router/lib/dom/lib.tsx | 12 +- packages/react-router/lib/router/router.ts | 148 ++++++---- 6 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md create mode 100644 packages/react-router/.changes/patch.memoize-usefetchers.md diff --git a/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md b/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md new file mode 100644 index 0000000000..47115b18d8 --- /dev/null +++ b/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md @@ -0,0 +1 @@ +Update router to operate on fetcher Maps in an immutable manner to avoid delayed React renders from potentially reading an updated but not yet committed Map. This could result in brief flickers in some fetcher-driven optimistic UI scenarios. diff --git a/packages/react-router/.changes/patch.memoize-usefetchers.md b/packages/react-router/.changes/patch.memoize-usefetchers.md new file mode 100644 index 0000000000..bab5e57f17 --- /dev/null +++ b/packages/react-router/.changes/patch.memoize-usefetchers.md @@ -0,0 +1 @@ +Memoize `useFetchers` to return a stable identity and only change if fetchers changed 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 b1dbfcee4c..06bd9adb5d 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -5738,6 +5738,111 @@ function testDomRouter( `); }); + it("useFetchers returns stable array reference when fetchers are unchanged", async () => { + let fetchDfd = createDeferred(); + let fetchersArrays: (Fetcher & { key: string })[][] = []; + let setCountRef = { current: null as React.Dispatch> | null }; + + function Parent() { + let fetchers = useFetchers(); + let fetcher = useFetcher(); + let [, setCount] = React.useState(0); + setCountRef.current = setCount; + fetchersArrays.push(fetchers); + return ( + + ); + } + + let router = createTestRouter( + [ + { + path: "/", + Component: Parent, + children: [{ path: "/fetch", loader: () => fetchDfd.promise }], + }, + ], + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + render(); + + // Wait for initial mount + await waitFor(() => screen.getByText("load")); + expect(fetchersArrays.at(-1)).toEqual([]); + + // Trigger a fetch — fetchers array should have a loading fetcher + fireEvent.click(screen.getByText("load")); + await waitFor(() => + expect(fetchersArrays.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ state: "loading" }), + ]), + ), + ); + let arrayWhileLoading = fetchersArrays.at(-1)!; + + // Unrelated state update re-renders the component — array ref should be stable + act(() => setCountRef.current!((c) => c + 1)); + expect(fetchersArrays.at(-1)).toBe(arrayWhileLoading); + + // Resolve the fetch — fetchers go idle and are removed from useFetchers + fetchDfd.resolve("DATA"); + await waitFor(() => expect(fetchersArrays.at(-1)).toEqual([])); + + // The final empty array is a new reference (fetchers changed) + expect(fetchersArrays.at(-1)).not.toBe(arrayWhileLoading); + }); + + it("useFetchers updates when a fetcher transitions state", async () => { + let fetchDfd = createDeferred(); + let states: string[] = []; + + function Parent() { + let fetchers = useFetchers(); + let fetcher = useFetcher(); + states.push(fetchers.map((f) => f.state).join(",") || "empty"); + return ( + + ); + } + + let router = createTestRouter( + [ + { + path: "/", + Component: Parent, + children: [{ path: "/fetch", loader: () => fetchDfd.promise }], + }, + ], + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + render(); + + // Wait for initial mount + await waitFor(() => screen.getByText("load")); + expect(states.at(-1)).toBe("empty"); + + // Fetch starts — useFetchers should see "loading" + fireEvent.click(screen.getByText("load")); + await waitFor(() => expect(states).toContain("loading")); + + // Fetch completes — useFetchers should go back to empty (idle fetchers excluded) + fetchDfd.resolve("DATA"); + await waitFor(() => expect(states.at(-1)).toBe("empty")); + + // States should have progressed: …empty → loading → empty + expect(states).toContain("loading"); + let loadingIdx = states.lastIndexOf("loading"); + let lastEmptyIdx = states.lastIndexOf("empty"); + expect(lastEmptyIdx).toBeGreaterThan(loadingIdx); + }); + it("handles revalidating fetchers", async () => { let count = 0; let fetchCount = 0; @@ -6501,10 +6606,10 @@ function testDomRouter( expect(container.querySelector("pre")?.innerHTML).toBe(""); fireEvent.click(screen.getByText("Load fetchers")); await waitFor(() => - // React `useId()` results in something such as `_r_2k_` or `_r_u_` - // depending on `DataBrowserRouter`/`DataHashRouter` + // React `useId()` results in something such as `_r_2k_`, `_r_u_`, + // or `_r_11_` depending on React version and component tree depth expect(container.querySelector("pre")?.innerHTML).toMatch( - /^_r_[0-9]?[a-z]_,my-key$/, + /^_r_[0-9a-z]+_,my-key$/, ), ); }); diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 4f42d4a36c..fea1fc24b4 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -1,5 +1,5 @@ /* eslint-disable jest/valid-title */ -import type { HydrationState } from "../../lib/router/router"; +import type { Fetcher, HydrationState } from "../../lib/router/router"; import { createMemoryHistory } from "../../lib/router/history"; import { createRouter, @@ -3703,4 +3703,259 @@ describe("fetchers", () => { expect(A.loaders.fetch.signal.reason).toBe("BECAUSE I SAID SO"); }); }); + + describe("fetcher Map mutation", () => { + // The root cause of the bug: after updateState({ fetchers: new Map(...) }) + // hands a Map (MapA) to React, a subsequent direct mutation of + // state.fetchers (e.g. state.fetchers.set(key, getDoneFetcher())) mutates + // that same MapA because state.fetchers === MapA after the updateState. + // React's concurrent renderer may still hold MapA and render it post- + // mutation, seeing an idle fetcher (formData gone) alongside stale + // loaderData — the "flicker". + // + // The subscriber-based approach does NOT catch this: the subscriber is + // called synchronously after each updateState, so it only ever sees + // fully-settled state. Instead, the test captures the Map reference handed + // to the subscriber during the "loading" phase and, after the fetch + // completes, asserts that reference was never mutated in place. + it("does not mutate the Map reference handed to subscribers (fetcher.submit)", async () => { + let fetcherMaps: Map[] = []; + let itemStatus = false; + + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/item"] }), + routes: [ + { + id: "item", + path: "/item", + loader: () => ({ status: itemStatus }), + action: async ({ request }) => { + let formData = await request.formData(); + itemStatus = formData.get("status") === "true"; + return { ok: true }; + }, + }, + ], + hydrationData: { + loaderData: { item: { status: false } }, + }, + }); + + router.initialize(); + + // Mount the fetcher so it stays in state + router.getFetcher("toggle"); + + router.subscribe((state) => { + fetcherMaps.push(state.fetchers); + }); + + let formData = new FormData(); + formData.append("status", "true"); + + await router.fetch("toggle", "item", "/item", { + formMethod: "POST", + formData, + }); + + router.dispose(); + + // After the fetch fully completes, the Maps we captured during the + // submitting/loading phases must still reflect submitting/loading — it must not have been + // mutated to "idle" in place + expect(fetcherMaps.length).toBe(3); + expect(fetcherMaps[0].get("toggle")?.state).toBe("submitting"); + expect(fetcherMaps[0].get("toggle")?.formData).toBeDefined(); + expect(fetcherMaps[1].get("toggle")?.state).toBe("loading"); + expect(fetcherMaps[1].get("toggle")?.formData).toBeDefined(); + expect(fetcherMaps[2].get("toggle")).toBeUndefined(); + }); + + it("does not mutate the Map reference handed to subscribers (fetcher.load)", async () => { + // updateFetcherState() does: state.fetchers.set(key, fetcher); updateState({fetchers: new Map(state.fetchers)}). + // After the first call (loading state), state.fetchers === MapA which React holds. + // The second call (done state) mutates MapA via state.fetchers.set before creating MapB. + let fetcherMaps: Map[] = []; + + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { id: "root", path: "/", loader: () => ({ data: "root" }) }, + { id: "item", path: "/item", loader: () => ({ data: "item" }) }, + ], + hydrationData: { loaderData: { root: { data: "root" } } }, + }); + + router.initialize(); + router.getFetcher("load"); + + router.subscribe((state) => { + fetcherMaps.push(state.fetchers); + }); + + await router.fetch("load", "root", "/item"); + + router.dispose(); + + // After the fetch fully completes, the Maps we captured during the + // loading phase must still reflect loading — they must not have been + // mutated to "idle" in place. + expect(fetcherMaps.length).toBe(2); + expect(fetcherMaps[0].get("load")?.state).toBe("loading"); + expect(fetcherMaps[0].get("load")?.data).toBeUndefined(); + expect(fetcherMaps[1].get("load")).toBeUndefined(); + }); + + it("does not mutate the Map reference handed to subscribers (fetcher revalidation during navigation)", async () => { + // getUpdatedRevalidatingFetchers() (dev branch) calls state.fetchers.set() + // on the current Map before returning a copy. This mutates MapPrev. + // Later, processLoaderData mutates the Map that subscribers received for + // the "loading" revalidation state. Test that the subscriber's loading + // Map is not mutated to idle by processLoaderData after loaders complete. + let fetcherMaps: Map[] = []; + + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { + id: "root", + path: "/", + loader: () => ({ data: "root" }), + action: () => ({ ok: true }), + }, + ], + hydrationData: { loaderData: { root: { data: "root" } } }, + }); + + router.initialize(); + router.getFetcher("f"); + + router.subscribe((state) => { + fetcherMaps.push(state.fetchers); + }); + + // GET load to register the fetcher in fetchLoadMatches (eligible for + // revalidation when a subsequent navigation action fires). + await router.fetch("f", "root", "/"); + + // POST to the same route — sets isRevalidationRequired=true and causes + // fetcher "f" to revalidate alongside the route loaders. + let formData = new FormData(); + await router.navigate("/", { formMethod: "POST", formData }); + + router.dispose(); + + // After the navigation fully completes, the Maps we captured during the + // loading phases (initial fetch + revalidation) must still reflect + // loading — they must not have been mutated to "idle"/done in place by + // getUpdatedRevalidatingFetchers/processLoaderData. + expect(fetcherMaps.length).toBe(5); + expect(fetcherMaps[0].get("f")?.state).toBe("loading"); + expect(fetcherMaps[1].get("f")).toBeUndefined(); + expect(fetcherMaps[2].get("f")).toBeUndefined(); + expect(fetcherMaps[3].get("f")?.state).toBe("loading"); + expect(fetcherMaps[4].get("f")).toBeUndefined(); + }); + + it("does not mutate the Map reference handed to subscribers (fetcher loader redirect)", async () => { + // handleFetcherLoader hands MapA (loading) to React via updateFetcherState(). + // When the loader returns a redirect, markFetchRedirectsDone() calls + // state.fetchers.set(key, doneFetcher) which mutates MapA before the + // final completeNavigation updateState creates MapB. + let fetcherMaps: Map[] = []; + + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { id: "root", path: "/", loader: () => ({ data: "root" }) }, + { + id: "redirect-source", + path: "/redirect", + loader: () => + new Response(null, { + status: 302, + headers: { Location: "/" }, + }), + }, + ], + hydrationData: { loaderData: { root: { data: "root" } } }, + }); + + router.initialize(); + router.getFetcher("redir"); + + router.subscribe((state) => { + fetcherMaps.push(state.fetchers); + }); + + await router.fetch("redir", "root", "/redirect"); + + router.dispose(); + + // After the redirect navigation fully completes, the Maps captured + // during the loading phases must still reflect loading — they must not + // have been mutated to "idle"/done in place by markFetchRedirectsDone(). + expect(fetcherMaps.length).toBe(3); + expect(fetcherMaps[0].get("redir")?.state).toBe("loading"); + expect(fetcherMaps[1].get("redir")?.state).toBe("loading"); + expect(fetcherMaps[2].get("redir")).toBeUndefined(); + }); + + it("does not mutate the Map reference handed to subscribers (fetcher action redirect)", async () => { + // When a fetcher action returns a redirect, handleFetcherAction calls + // updateFetcherState(key, getLoadingFetcher(submission)) which first + // mutates the "submitting" MapA via state.fetchers.set, creates MapB + // (loading), then kicks off startRedirectNavigation. Inside + // completeNavigation, markFetchRedirectsDone() mutates MapB + // (state.fetchers.set(key, doneFetcher)) before the final updateState + // creates MapC. Test that MapB is not mutated. + let fetcherMaps: Map[] = []; + + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { id: "root", path: "/", loader: () => ({ data: "root" }) }, + { + id: "action-route", + path: "/action", + action: () => + new Response(null, { + status: 302, + headers: { Location: "/" }, + }), + }, + ], + hydrationData: { loaderData: { root: { data: "root" } } }, + }); + + router.initialize(); + router.getFetcher("act"); + + router.subscribe((state) => { + fetcherMaps.push(state.fetchers); + }); + + let formData = new FormData(); + await router.fetch("act", "root", "/action", { + formMethod: "POST", + formData, + }); + + router.dispose(); + + // After the redirect navigation fully completes, the Maps captured + // during the submitting/loading phases must still reflect those + // states (with formData intact) — they must not have been mutated to + // "idle"/done in place by markFetchRedirectsDone() before + // completeNavigation finalizes. + expect(fetcherMaps.length).toBe(4); + expect(fetcherMaps[0].get("act")?.state).toBe("submitting"); + expect(fetcherMaps[0].get("act")?.formData).toBeDefined(); + expect(fetcherMaps[1].get("act")?.state).toBe("loading"); + expect(fetcherMaps[1].get("act")?.formData).toBeDefined(); + expect(fetcherMaps[2].get("act")?.state).toBe("loading"); + expect(fetcherMaps[2].get("act")?.formData).toBeDefined(); + expect(fetcherMaps[3].get("act")).toBeUndefined(); + }); + }); }); diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index c012f4919e..a9d3fe0051 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -3006,10 +3006,14 @@ export function useFetcher({ */ export function useFetchers(): (Fetcher & { key: string })[] { let state = useDataRouterState(DataRouterStateHook.UseFetchers); - return Array.from(state.fetchers.entries()).map(([key, fetcher]) => ({ - ...fetcher, - key, - })); + return React.useMemo( + () => + Array.from(state.fetchers.entries()).map(([key, fetcher]) => ({ + ...fetcher, + key, + })), + [state.fetchers], + ); } const SCROLL_RESTORATION_STORAGE_KEY = "react-router-scroll-positions"; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 68d474f724..4b58497434 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -841,6 +841,10 @@ interface HandleLoadersResult extends ShortCircuitable { * errors thrown from the current set of loaders */ errors?: RouterState["errors"]; + /** + * Updated fetchers map (when stale fetch loads were aborted or redirects completed) + */ + workingFetchers?: RouterState["fetchers"]; } /** @@ -1377,7 +1381,7 @@ export function createRouter(init: RouterInit): Router { } subscribers.clear(); pendingNavigationController && pendingNavigationController.abort(); - state.fetchers.forEach((_, key) => deleteFetcher(key)); + state.fetchers.forEach((_, key) => deleteFetcher(state.fetchers, key)); state.blockers.forEach((_, key) => deleteBlocker(key)); } @@ -1460,8 +1464,10 @@ export function createRouter(init: RouterInit): Router { }), ); - // Cleanup internally now that we've called our subscribers/updated state - unmountedFetchers.forEach((key) => deleteFetcher(key)); + // Cleanup after subscribers have been called. Unmounted fetchers are fully + // removed; mounted idle fetchers are removed from state.fetchers only (they + // stay in fetchLoadMatches etc. in case they're re-used). + unmountedFetchers.forEach((key) => deleteFetcher(state.fetchers, key)); mountedFetchers.forEach((key) => state.fetchers.delete(key)); } @@ -2022,6 +2028,7 @@ export function createRouter(init: RouterInit): Router { matches: updatedMatches, loaderData, errors, + workingFetchers, } = await handleLoaders( request, location, @@ -2053,6 +2060,7 @@ export function createRouter(init: RouterInit): Router { ...getActionDataForCommit(pendingActionResult), loaderData, errors, + ...(workingFetchers ? { fetchers: workingFetchers } : {}), }); } @@ -2382,7 +2390,8 @@ export function createRouter(init: RouterInit): Router { ) && revalidatingFetchers.length === 0 ) { - let updatedFetchers = markFetchRedirectsDone(); + let workingFetchers = new Map(state.fetchers); + let didUpdateFetcherRedirects = markFetchRedirectsDone(workingFetchers); completeNavigation( location, { @@ -2394,7 +2403,7 @@ export function createRouter(init: RouterInit): Router { ? { [pendingActionResult[0]]: pendingActionResult[1].error } : null, ...getActionDataForCommit(pendingActionResult), - ...(updatedFetchers ? { fetchers: new Map(state.fetchers) } : {}), + ...(didUpdateFetcherRedirects ? { fetchers: workingFetchers } : {}), }, { flushSync }, ); @@ -2484,6 +2493,7 @@ export function createRouter(init: RouterInit): Router { } // Process and commit output from loaders + let workingFetchers = new Map(state.fetchers); let { loaderData, errors } = processLoaderData( state, matches, @@ -2491,6 +2501,7 @@ export function createRouter(init: RouterInit): Router { pendingActionResult, revalidatingFetchers, fetcherResults, + workingFetchers, ); // Preserve SSR errors during partial hydration @@ -2498,16 +2509,21 @@ export function createRouter(init: RouterInit): Router { errors = { ...state.errors, ...errors }; } - let updatedFetchers = markFetchRedirectsDone(); - let didAbortFetchLoads = abortStaleFetchLoads(pendingNavigationLoadId); + let didUpdateFetcherRedirects = markFetchRedirectsDone(workingFetchers); + let didAbortFetchLoads = abortStaleFetchLoads( + pendingNavigationLoadId, + workingFetchers, + ); let shouldUpdateFetchers = - updatedFetchers || didAbortFetchLoads || revalidatingFetchers.length > 0; + didUpdateFetcherRedirects || + didAbortFetchLoads || + revalidatingFetchers.length > 0; return { matches, loaderData, errors, - ...(shouldUpdateFetchers ? { fetchers: new Map(state.fetchers) } : {}), + ...(shouldUpdateFetchers ? { workingFetchers } : {}), }; } @@ -2533,15 +2549,16 @@ export function createRouter(init: RouterInit): Router { function getUpdatedRevalidatingFetchers( revalidatingFetchers: RevalidatingFetcher[], ) { + let workingFetchers = new Map(state.fetchers); revalidatingFetchers.forEach((rf) => { - let fetcher = state.fetchers.get(rf.key); + let fetcher = workingFetchers.get(rf.key); let revalidatingFetcher = getLoadingFetcher( undefined, fetcher ? fetcher.data : undefined, ); - state.fetchers.set(rf.key, revalidatingFetcher); + workingFetchers.set(rf.key, revalidatingFetcher); }); - return new Map(state.fetchers); + return workingFetchers; } // Trigger a fetcher load/submit for the given fetcher key @@ -2808,9 +2825,6 @@ export function createRouter(init: RouterInit): Router { let loadId = ++incrementingLoadId; fetchReloadIds.set(key, loadId); - let loadFetcher = getLoadingFetcher(submission, actionResult.data); - state.fetchers.set(key, loadFetcher); - let { dsMatches, revalidatingFetchers } = getMatchesToLoad( revalidationRequest, scopedContext, @@ -2836,6 +2850,14 @@ export function createRouter(init: RouterInit): Router { callSiteDefaultShouldRevalidate, ); + // Build an updated fetchers map for the updateState call below without + // mutating state.fetchers. Set the submitting fetcher into loading state + // and put all revalidating fetchers (except the current one) into loading + // state as well. + let loadFetcher = getLoadingFetcher(submission, actionResult.data); + let workingFetchers = new Map(state.fetchers); + workingFetchers.set(key, loadFetcher); + // Put all revalidating fetchers into the loading state, except for the // current fetcher which we want to keep in it's current loading state which // contains it's action submission info + action data @@ -2843,19 +2865,19 @@ export function createRouter(init: RouterInit): Router { .filter((rf) => rf.key !== key) .forEach((rf) => { let staleKey = rf.key; - let existingFetcher = state.fetchers.get(staleKey); + let existingFetcher = workingFetchers.get(staleKey); let revalidatingFetcher = getLoadingFetcher( undefined, existingFetcher ? existingFetcher.data : undefined, ); - state.fetchers.set(staleKey, revalidatingFetcher); + workingFetchers.set(staleKey, revalidatingFetcher); abortFetcher(staleKey); if (rf.controller) { fetchControllers.set(staleKey, rf.controller); } }); - updateState({ fetchers: new Map(state.fetchers) }); + updateState({ fetchers: workingFetchers }); let abortPendingFetchRevalidations = () => revalidatingFetchers.forEach((rf) => abortFetcher(rf.key)); @@ -2887,15 +2909,25 @@ export function createRouter(init: RouterInit): Router { fetchControllers.delete(key); revalidatingFetchers.forEach((r) => fetchControllers.delete(r.key)); - // Since we let revalidations complete even if the submitting fetcher was - // deleted, only put it back to idle if it hasn't been deleted - if (state.fetchers.has(key)) { - let doneFetcher = getDoneFetcher(actionResult.data); - state.fetchers.set(key, doneFetcher); - } + let fetcherIsMounted = state.fetchers.has(key); + + // Generate a new local copy of `state` for the redirect navigation to read + // from and eventually commit. This avoids mutating the existing state.fetchers + // map Map which React already holds a reference to from updateState() above + let getRedirectStateWithDoneFetcher = (s: RouterState) => { + // Since we let revalidations complete even if the submitting fetcher was + // deleted, only put it back to idle if it hasn't been deleted. + if (!fetcherIsMounted) return s; + let workingFetchers = new Map(s.fetchers); + workingFetchers.set(key, getDoneFetcher(actionResult.data)); + return { ...s, fetchers: workingFetchers }; + }; let redirect = findRedirect(loaderResults); if (redirect) { + // Advance state.fetchers to include the done fetcher before handing off + // to the redirect navigation so that completeNavigation() sees it as idle. + state = getRedirectStateWithDoneFetcher(state); return startRedirectNavigation( revalidationRequest, redirect.result, @@ -2910,6 +2942,7 @@ export function createRouter(init: RouterInit): Router { // fetchRedirectIds so it doesn't get revalidated on the next set of // loader executions fetchRedirectIds.add(redirect.key); + state = getRedirectStateWithDoneFetcher(state); return startRedirectNavigation( revalidationRequest, redirect.result, @@ -2918,6 +2951,16 @@ export function createRouter(init: RouterInit): Router { ); } + // Build finalFetchers before processing so that processLoaderData and + // abortStaleFetchLoads can write revalidating-fetcher results into it + // alongside the done-fetcher for this action. Using a fresh Map ensures + // we never mutate the Map that was handed to React via the earlier + // updateState() call (see #14506). + let finalFetchers = new Map(state.fetchers); + if (fetcherIsMounted) { + finalFetchers.set(key, getDoneFetcher(actionResult.data)); + } + // Process and commit output from loaders let { loaderData, errors } = processLoaderData( state, @@ -2926,9 +2969,10 @@ export function createRouter(init: RouterInit): Router { undefined, revalidatingFetchers, fetcherResults, + finalFetchers, ); - abortStaleFetchLoads(loadId); + abortStaleFetchLoads(loadId, finalFetchers); // If we are currently in a navigation loading state and this fetcher is // more recent than the navigation, we want the newer data so abort the @@ -2944,7 +2988,7 @@ export function createRouter(init: RouterInit): Router { matches, loaderData, errors, - fetchers: new Map(state.fetchers), + fetchers: finalFetchers, }); } else { // otherwise just update with the fetcher data, preserving any existing @@ -2958,7 +3002,7 @@ export function createRouter(init: RouterInit): Router { matches, errors, ), - fetchers: new Map(state.fetchers), + fetchers: finalFetchers, }); isRevalidationRequired = false; } @@ -3408,9 +3452,10 @@ export function createRouter(init: RouterInit): Router { fetcher: Fetcher, opts: { flushSync?: boolean } = {}, ) { - state.fetchers.set(key, fetcher); + let workingFetchers = new Map(state.fetchers); + workingFetchers.set(key, fetcher); updateState( - { fetchers: new Map(state.fetchers) }, + { fetchers: workingFetchers }, { flushSync: (opts && opts.flushSync) === true }, ); } @@ -3422,13 +3467,17 @@ export function createRouter(init: RouterInit): Router { opts: { flushSync?: boolean } = {}, ) { let boundaryMatch = findNearestBoundary(state.matches, routeId); - deleteFetcher(key); + // Build the new Map first and delete from there, we don't want to mutate the map React + // already has a reference to. It will be removed from `state.fetchers` on a + // subsequent updateState() call + let workingFetchers = new Map(state.fetchers); + deleteFetcher(workingFetchers, key); updateState( { errors: { [boundaryMatch.route.id]: error, }, - fetchers: new Map(state.fetchers), + fetchers: workingFetchers, }, { flushSync: (opts && opts.flushSync) === true }, ); @@ -3449,7 +3498,7 @@ export function createRouter(init: RouterInit): Router { updateFetcherState(key, getDoneFetcher(null)); } - function deleteFetcher(key: string): void { + function deleteFetcher(fetchers: Map, key: string): void { let fetcher = state.fetchers.get(key); // Don't abort the controller if this is a deletion of a fetcher.submit() // in it's loading phase since - we don't want to abort the corresponding @@ -3465,7 +3514,7 @@ export function createRouter(init: RouterInit): Router { fetchRedirectIds.delete(key); fetchersQueuedForDeletion.delete(key); cancelledFetcherLoads.delete(key); - state.fetchers.delete(key); + fetchers.delete(key); } function queueFetcherForDeletion(key: string): void { @@ -3487,35 +3536,39 @@ export function createRouter(init: RouterInit): Router { } } - function markFetchersDone(keys: string[]) { + function markFetchersDone(keys: string[], fetchers: Map) { for (let key of keys) { - let fetcher = getFetcher(key); + let fetcher = fetchers.get(key); + invariant(fetcher, `Expected fetcher: ${key}`); let doneFetcher = getDoneFetcher(fetcher.data); - state.fetchers.set(key, doneFetcher); + fetchers.set(key, doneFetcher); } } - function markFetchRedirectsDone(): boolean { + function markFetchRedirectsDone(fetchers: Map): boolean { let doneKeys = []; - let updatedFetchers = false; + let didUpdateFetchers = false; for (let key of fetchRedirectIds) { - let fetcher = state.fetchers.get(key); + let fetcher = fetchers.get(key); invariant(fetcher, `Expected fetcher: ${key}`); if (fetcher.state === "loading") { fetchRedirectIds.delete(key); doneKeys.push(key); - updatedFetchers = true; + didUpdateFetchers = true; } } - markFetchersDone(doneKeys); - return updatedFetchers; + markFetchersDone(doneKeys, fetchers); + return didUpdateFetchers; } - function abortStaleFetchLoads(landedId: number): boolean { + function abortStaleFetchLoads( + landedId: number, + fetchers: Map, + ): boolean { let yeetedKeys = []; for (let [key, id] of fetchReloadIds) { if (id < landedId) { - let fetcher = state.fetchers.get(key); + let fetcher = fetchers.get(key); invariant(fetcher, `Expected fetcher: ${key}`); if (fetcher.state === "loading") { abortFetcher(key); @@ -3524,7 +3577,7 @@ export function createRouter(init: RouterInit): Router { } } } - markFetchersDone(yeetedKeys); + markFetchersDone(yeetedKeys, fetchers); return yeetedKeys.length > 0; } @@ -6993,6 +7046,7 @@ function processLoaderData( pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], fetcherResults: Record, + workingFetchers: Map, ): { loaderData: RouterState["loaderData"]; errors?: RouterState["errors"]; @@ -7027,14 +7081,14 @@ function processLoaderData( [boundaryMatch.route.id]: result.error, }; } - state.fetchers.delete(key); + workingFetchers.delete(key); } else if (isRedirectResult(result)) { // Should never get here, redirects should get processed above, but we // keep this to type narrow to a success result in the else invariant(false, "Unhandled fetcher revalidation redirect"); } else { let doneFetcher = getDoneFetcher(result.data); - state.fetchers.set(key, doneFetcher); + workingFetchers.set(key, doneFetcher); } }); From 6bf91cef0e5d3d224d5580d485b6b716d96742d1 Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Wed, 13 May 2026 13:29:04 +0000 Subject: [PATCH 15/23] chore: format --- .../.changes/patch.fix-fetcher-formdata-flicker.md | 2 +- .../__tests__/dom/data-browser-router-test.tsx | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md b/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md index 47115b18d8..211c3a0c57 100644 --- a/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md +++ b/packages/react-router/.changes/patch.fix-fetcher-formdata-flicker.md @@ -1 +1 @@ -Update router to operate on fetcher Maps in an immutable manner to avoid delayed React renders from potentially reading an updated but not yet committed Map. This could result in brief flickers in some fetcher-driven optimistic UI scenarios. +Update router to operate on fetcher Maps in an immutable manner to avoid delayed React renders from potentially reading an updated but not yet committed Map. This could result in brief flickers in some fetcher-driven optimistic UI scenarios. 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 06bd9adb5d..0e4d4e4656 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -5741,7 +5741,9 @@ function testDomRouter( it("useFetchers returns stable array reference when fetchers are unchanged", async () => { let fetchDfd = createDeferred(); let fetchersArrays: (Fetcher & { key: string })[][] = []; - let setCountRef = { current: null as React.Dispatch> | null }; + let setCountRef = { + current: null as React.Dispatch> | null, + }; function Parent() { let fetchers = useFetchers(); @@ -5749,9 +5751,7 @@ function testDomRouter( let [, setCount] = React.useState(0); setCountRef.current = setCount; fetchersArrays.push(fetchers); - return ( - - ); + return ; } let router = createTestRouter( @@ -5804,9 +5804,7 @@ function testDomRouter( let fetchers = useFetchers(); let fetcher = useFetcher(); states.push(fetchers.map((f) => f.state).join(",") || "empty"); - return ( - - ); + return ; } let router = createTestRouter( From 4322e58ded9b7f5c29de0f110a97f6f2a7c34fbc Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 13 May 2026 14:10:34 -0400 Subject: [PATCH 16/23] Update docs for useRouterState --- docs/api/hooks/useRouterState.md | 41 +++++++++++++++++-- .../.changes/unstable.use-router-state.md | 2 +- packages/react-router/lib/hooks.tsx | 33 +++++++++++++-- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/api/hooks/useRouterState.md b/docs/api/hooks/useRouterState.md index b6cdbb7292..7e0f979f84 100644 --- a/docs/api/hooks/useRouterState.md +++ b/docs/api/hooks/useRouterState.md @@ -1,8 +1,9 @@ --- title: useRouterState +unstable: true --- -# useRouterState +# unstable_useRouterState