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/.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 52b2c1b906..61419cf109 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 + package-manager-cache: false + + - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f61b26ae3..4136bb01ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,10 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) + - [v7.15.1](#v7151) - [v7.15.0](#v7150) + - [Stabilizations](#stabilizations) + - [Route matching optimizations](#route-matching-optimizations) - [v7.14.2](#v7142) - [v7.14.1](#v7141) - [v7.14.0](#v7140) @@ -170,6 +173,63 @@ We manage release notes in this file instead of the paginated Github Releases Pa +## v7.15.1 + +Date: 2026-05-14 + +### What's New + +#### `useRouterState` (unstable) + +Following our [Less is More](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#design-goals) design goal, this release includes a new `unstable_useRouterState()` hook (Framework + Data Mode) that consolidates access to active and pending router states ([RFC](https://github.com/remix-run/react-router/discussions/12358), [Roadmap Issue](https://github.com/remix-run/react-router/issues/13073)). + +This should allow you to consolidate usages of a bunch of different hooks which will likely be marked deprecated later on in v8 and potentially removed in an eventual v9: + +```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 +``` + +### Patch Changes + +- `react-router` - Memoize `useFetchers` to return a stable identity and only change if fetchers changed ([#15028](https://github.com/remix-run/react-router/pull/15028)) +- `react-router` - 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 ([#15028](https://github.com/remix-run/react-router/pull/15028)) +- `react-router` - Fix `serverLoader()` returning stale SSR data when a client navigation aborts pending hydration before the hydration `clientLoader` resolves ([#15022](https://github.com/remix-run/react-router/pull/15022)) +- `react-router` - Fix `RouterProvider` `onError` callback not being called for synchronous initial loader errors in SPA mode ([#15039](https://github.com/remix-run/react-router/pull/15039)) ([#14942](https://github.com/remix-run/react-router/pull/14942)) +- `react-router` - Internal refactor to consolidate mutation request detection through shared utility ([#15033](https://github.com/remix-run/react-router/pull/15033)) +- `@react-router/dev` - Fix `basename` conflicting with `app` directory name when Vite `base` is set ([#15027](https://github.com/remix-run/react-router/pull/15027)) + - 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 + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - Add a new `unstable_useRouterState()` hook that consolidates access to active and pending router states (RFC: #12358) ([#15017](https://github.com/remix-run/react-router/pull/15017)) + - Data/Framework/RSC only — throws when used without a data router + +**Full Changelog**: [`v7.15.0...v7.15.1`](https://github.com/remix-run/react-router/compare/react-router@7.15.0...react-router@7.15.1) + ## v7.15.0 Date: 2026-05-05 @@ -228,10 +288,10 @@ We've added a handful of route matching optimizations in this release for Framew - `react-router` - Add `nonce` to `` `` elements (if provided) ([af5d49b](https://github.com/remix-run/react-router/commit/af5d49b)) - `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` - Mark `mask` as an optional field in `Location` for easier mocking in unit tests ([#14999](https://github.com/remix-run/react-router/pull/14999)) -- `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)) +- `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)) - Performance benchmarks showed roughly a 10-15% improvement in server-side request handling performance - `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)) +- `react-router` - Improve route matching performance in Framework/Data Mode ([#14971](https://github.com/remix-run/react-router/pull/14971)) - 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/contributors.yml b/contributors.yml index 84a0c345e2..d1fb4af96a 100644 --- a/contributors.yml +++ b/contributors.yml @@ -155,6 +155,7 @@ - goodrone - gowthamvbhat - GraxMonzo +- grzdev - guppy0356 - GuptaSiddhant - haivuw @@ -225,6 +226,7 @@ - KaranRandhir - kark - KAROTT7 +- karthik-idikuda - kddnewton - ken0x0a - kentcdodds 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..7e0f979f84 --- /dev/null +++ b/docs/api/hooks/useRouterState.md @@ -0,0 +1,76 @@ +--- +title: useRouterState +unstable: true +--- + +# unstable_useRouterState + + + +[MODES: framework, data] + +
+
+ +This API is experimental and subject to breaking changes in +minor/patch releases. Please use with caution and pay **very** close attention +to release notes for relevant changes. + +## Summary + +[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react-router.unstable_useRouterState.html) + +A unified hook for reading router state: current (`active`) and in-flight +(`pending`) locations, search params, params, matches, and navigation type. + +This hook consolidates the information you used to get from [`useLocation`](../hooks/useLocation), +[`useSearchParams`](../hooks/useSearchParams), [`useParams`](../hooks/useParams), [`useMatches`](../hooks/useMatches), [`useNavigation`](../hooks/useNavigation), +and [`useNavigationType`](../hooks/useNavigationType) into a single hook. + +```tsx +import { unstable_useRouterState as useRouterState } from "react-router"; + +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 +``` + +## Signature + +```tsx +function useRouterState(): unstable_RouterState +``` + +## Returns + +The current router state with `active` and `pending` variants + 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/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/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/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index f77842a7c9..da77e4e36d 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -221,6 +221,19 @@ 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 +434,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/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 2648035946..d2660654de 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,11 @@ # `create-react-router` +## v7.15.1 + +### Patch Changes + +- _No changes_ + ## v7.15.0 ### Patch Changes 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/package.json b/packages/create-react-router/package.json index c713d903ec..d07d29d524 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.15.0", + "version": "7.15.1", "description": "Create a new React Router app", "homepage": "https://reactrouter.com", "bugs": { 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-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index bc868a16df..70e3b9f2b9 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + - [`@react-router/node@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index 5b03818dfb..c4e00cc399 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.15.0", + "version": "7.15.1", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index 6bc00b9a99..9c4c30b91d 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 0bcfa7ff1a..2c7ec7d471 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.15.0", + "version": "7.15.1", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index 3381692575..e3670fd2bd 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,22 @@ # `@react-router/dev` +## v7.15.1 + +### Patch Changes + +- Fix `basename` conflicting with `app` directory name when Vite `base` is set ([#15027](https://github.com/remix-run/react-router/pull/15027)) + + 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. + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + - [`@react-router/node@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.15.1) + - [`@react-router/serve@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/serve@7.15.1) + ## v7.15.0 ### Minor Changes 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/package.json b/packages/react-router-dev/package.json index 61e32c46a3..73b3786aba 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.15.0", + "version": "7.15.1", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com", "bugs": { 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..14fbacb8e4 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", @@ -818,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) => { @@ -834,6 +833,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { resolveFileUrl( ctx, resolveRelativeRouteFilePath(route, ctx.reactRouterConfig), + { publicPath: ctx.publicPath }, ), )};`; } @@ -2833,7 +2833,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/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; }; 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-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 8568eb6efb..1d2f456bc6 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 20bcdf6209..fd24333062 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.15.0", + "version": "7.15.1", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index 3e3bd778b4..fcf70d9e0a 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + - [`@react-router/node@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index 113b274acb..d1d032cf5a 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.15.0", + "version": "7.15.1", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index 342d494f70..41576ed098 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`@react-router/dev@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 328242c6bc..d708724e0f 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.15.0", + "version": "7.15.1", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index 8829857f33..cb83fd3916 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 9e2a4a9bd2..9b1251a58e 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.15.0", + "version": "7.15.1", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/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-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index 8d36d19d9b..c1f94e351c 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/remix-config-routes-adapter` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`@react-router/dev@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/dev@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index a53efa460a..e64868b444 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.15.0", + "version": "7.15.1", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index 64d2fd37e6..af5e8c2b9a 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## v7.15.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router@7.15.1`](https://github.com/remix-run/react-router/releases/tag/react-router@7.15.1) + - [`@react-router/express@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/express@7.15.1) + - [`@react-router/node@7.15.1`](https://github.com/remix-run/react-router/releases/tag/@react-router/node@7.15.1) + ## v7.15.0 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 3737445020..de0c4f7c1c 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.15.0", + "version": "7.15.1", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 56620bed1c..df0d2788db 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,54 @@ # `react-router` +## v7.15.1 + +### Patch Changes + +- 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. ([#15028](https://github.com/remix-run/react-router/pull/15028)) +- Fix `serverLoader()` returning stale SSR data when a client navigation aborts pending hydration before the hydration `clientLoader` resolves ([#15022](https://github.com/remix-run/react-router/pull/15022)) +- Fix `RouterProvider` `onError` callback not being called for synchronous initial loader errors in SPA mode ([#15039](https://github.com/remix-run/react-router/pull/15039)) ([#14942](https://github.com/remix-run/react-router/pull/14942)) +- Memoize `useFetchers` to return a stable identity and only change if fetchers changed ([#15028](https://github.com/remix-run/react-router/pull/15028)) +- Internal refactor to consolidate mutation request detection through shared utility ([#15033](https://github.com/remix-run/react-router/pull/15033)) + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- Add a new `unstable_useRouterState()` hook that consolidates access to active and pending router states (RFC: #12358) ([#15017](https://github.com/remix-run/react-router/pull/15017)) + - 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 + ``` + ## v7.15.0 ### Minor Changes 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..fe6eeb948a 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,126 @@ 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("handles initial load synchronous loader errors in SPA mode (delayed render)", 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}

+ ), + }, + ]); + + // Make sure the router completely initializes + await tick(); + + 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(); + }); + + it("does not fire onError for errors from hydrationData (delayed render)", 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: {}, + }, + }, + ); + + // Make sure the router completely initializes + await tick(); + + render(); + + await waitFor(() => screen.getByText("Error:hydration error!")); + + expect(spy).not.toHaveBeenCalled(); + }); }); 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..0e4d4e4656 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,109 @@ 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 +6604,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__/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/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/__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__/unstable-useRouterState-test.tsx b/packages/react-router/__tests__/unstable-useRouterState-test.tsx new file mode 100644 index 0000000000..2df97afe88 --- /dev/null +++ b/packages/react-router/__tests__/unstable-useRouterState-test.tsx @@ -0,0 +1,335 @@ +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/__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/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/components.tsx b/packages/react-router/lib/components.tsx index 5234145af9..96c28284e6 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -616,23 +616,12 @@ export function RouterProvider({ ); // Need to use a layout effect here so we are subscribed early enough to - // pick up on any render-driven redirects/navigations (useEffect/) + // pick up on any render-driven redirects/navigations (useEffect/). + // If the router finished initializing (and emitted errors) before this + // subscriber was attached, `subscribe()` replays that notification so + // `onError` still fires for initial data load errors. React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); - // Track race conditions where we finish initializing prior to the layout - // effect above running to register our listener. If we manually detect a - // change in `state.initialized`, automatically sync state. - let initialized = state.initialized; - React.useLayoutEffect(() => { - if (!initialized && router.state.initialized) { - setState(router.state, { - deletedFetchers: [], - flushSync: false, - newErrors: null, - }); - } - }, [initialized, setState, router.state]); - // When we start a view transition, create a Deferred we can use for the // eventual "completed" render React.useEffect(() => { 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 bb819cb9fc..b303a22a49 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 } } @@ -3008,10 +3014,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"; @@ -3123,7 +3133,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 78f362796a..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; } } @@ -975,13 +978,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 +1045,6 @@ import(${JSON.stringify(manifest.entry.module)});`; ); } -function dedupe(array: any[]) { - return [...new Set(array)]; -} - export function mergeRefs( ...refs: Array | React.LegacyRef> ): React.RefCallback { 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/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/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/hooks.tsx b/packages/react-router/lib/hooks.tsx index f75de69745..9cd81ae763 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,164 @@ 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. + * + * This hook consolidates the information you used to get from {@link useLocation}, + * {@link useSearchParams}, {@link useParams}, {@link useMatches}, {@link useNavigation}, + * and {@link useNavigationType} into a single hook. + * + * + * @example + * import { unstable_useRouterState as useRouterState } from "react-router"; + * + * 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 + * + * @name unstable_useRouterState + * @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/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 c7b3d6a4c4..90a98040ec 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"]; @@ -835,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"]; } /** @@ -879,6 +889,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, @@ -1047,6 +1059,11 @@ export function createRouter(init: RouterInit): Router { let unlistenHistory: (() => void) | null = null; // Externally-provided functions to call on all state changes let subscribers = new Set(); + // Buffer the most recent state update when there are no subscribers, so + // the first subscriber to attach gets caught up. Without this, states + // that update during `initialize()` (before + // mounts and registers its subscriber) would be silently lost + let bufferedInitialStateUpdate: { newErrors: RouteData | null } | null = null; // Externally-provided object to hold scroll restoration locations during routing let savedScrollPositions: Record | null = null; // Externally-provided function to get scroll restoration keys @@ -1369,13 +1386,23 @@ 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)); } // Subscribe to state updates for the router function subscribe(fn: RouterSubscriber) { subscribers.add(fn); + if (bufferedInitialStateUpdate) { + let { newErrors } = bufferedInitialStateUpdate; + bufferedInitialStateUpdate = null; + fn(state, { + deletedFetchers: [], + newErrors, + viewTransitionOpts: undefined, + flushSync: false, + }); + } return () => subscribers.delete(fn); } @@ -1440,6 +1467,10 @@ export function createRouter(init: RouterInit): Router { } }); + if (subscribers.size === 0) { + bufferedInitialStateUpdate = { newErrors: newState.errors ?? null }; + } + // Iterate over a local copy so that if flushSync is used and we end up // removing and adding a new subscriber due to the useCallback dependencies, // we don't get ourselves into a loop calling the new subscriber immediately @@ -1452,8 +1483,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)); } @@ -1825,7 +1858,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 +1888,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 +1945,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 +1988,7 @@ export function createRouter(init: RouterInit): Router { location, opts.submission, matches, + historyAction, scopedContext, fogOfWar.active, opts && opts.initialHydration === true, @@ -1978,7 +2023,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; @@ -1997,10 +2047,12 @@ export function createRouter(init: RouterInit): Router { matches: updatedMatches, loaderData, errors, + workingFetchers, } = await handleLoaders( request, location, matches, + historyAction, scopedContext, fogOfWar.active, loadingNavigation, @@ -2027,6 +2079,7 @@ export function createRouter(init: RouterInit): Router { ...getActionDataForCommit(pendingActionResult), loaderData, errors, + ...(workingFetchers ? { fetchers: workingFetchers } : {}), }); } @@ -2037,6 +2090,7 @@ export function createRouter(init: RouterInit): Router { location: Location, submission: Submission, matches: DataRouteMatch[], + historyAction: NavigationType, scopedContext: RouterContextProvider, isFogOfWar: boolean, initialHydration: boolean, @@ -2045,7 +2099,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 +2271,7 @@ export function createRouter(init: RouterInit): Router { request: Request, location: Location, matches: DataRouteMatch[], + historyAction: NavigationType, scopedContext: RouterContextProvider, isFogOfWar: boolean, overrideNavigation?: Navigation, @@ -2225,7 +2285,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 @@ -2348,7 +2409,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, { @@ -2360,7 +2422,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 }, ); @@ -2450,6 +2512,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, @@ -2457,6 +2520,7 @@ export function createRouter(init: RouterInit): Router { pendingActionResult, revalidatingFetchers, fetcherResults, + workingFetchers, ); // Preserve SSR errors during partial hydration @@ -2464,16 +2528,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 } : {}), }; } @@ -2499,15 +2568,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 @@ -2774,9 +2844,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, @@ -2802,6 +2869,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 @@ -2809,19 +2884,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)); @@ -2853,15 +2928,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, @@ -2876,6 +2961,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, @@ -2884,6 +2970,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, @@ -2892,9 +2988,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 @@ -2910,7 +3007,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 @@ -2924,7 +3021,7 @@ export function createRouter(init: RouterInit): Router { matches, errors, ), - fetchers: new Map(state.fetchers), + fetchers: finalFetchers, }); isRevalidationRequired = false; } @@ -3193,9 +3290,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, { @@ -3370,9 +3471,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 }, ); } @@ -3384,13 +3486,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 }, ); @@ -3411,7 +3517,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 @@ -3427,7 +3533,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 { @@ -3449,35 +3555,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); @@ -3486,7 +3596,7 @@ export function createRouter(init: RouterInit): Router { } } } - markFetchersDone(yeetedKeys); + markFetchersDone(yeetedKeys, fetchers); return yeetedKeys.length > 0; } @@ -5087,7 +5197,10 @@ function normalizeNavigateOptions( text: undefined, }, }; - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { return getInvalidBodyError(); } } @@ -5117,7 +5230,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(); } } @@ -6429,7 +6545,10 @@ async function callDataStrategyImpl( m._lazyPromises?.route, ]), ); - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // No-op } @@ -6738,7 +6857,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; } @@ -6943,6 +7065,7 @@ function processLoaderData( pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], fetcherResults: Record, + workingFetchers: Map, ): { loaderData: RouterState["loaderData"]; errors?: RouterState["errors"]; @@ -6977,14 +7100,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); } }); @@ -7336,12 +7459,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 +7481,8 @@ function getLoadingNavigation( let navigation: NavigationStates["Loading"] = { state: "loading", location, + matches, + historyAction, formMethod: undefined, formAction: undefined, formEncType: undefined, @@ -7367,11 +7496,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, @@ -7460,7 +7593,10 @@ function restoreAppliedTransitions( } } } - } catch (e) { + } catch ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + e + ) { // no-op, use default empty object } } @@ -7496,13 +7632,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/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 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.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/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 ae90e90c00..40ab71d101 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, @@ -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/package.json b/packages/react-router/package.json index 8be662ef20..7498538c17 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.15.0", + "version": "7.15.1", "description": "Declarative routing for React", "keywords": [ "react", 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..27afcbef08 100644 --- a/scripts/release-comments.ts +++ b/scripts/release-comments.ts @@ -13,7 +13,7 @@ let DIRECTORY_TO_CHECK = "packages/."; let GITHUB_REPOSITORY = "remix-run/react-router"; let PR_LABELS_TO_REMOVE = "awaiting release"; let ISSUE_LABELS_TO_REMOVE = "awaiting release"; -let ISSUE_LABELS_TO_KEEP_OPEN = "🗺️Roadmap"; +let ISSUE_LABELS_TO_KEEP_OPEN = "🗺 Roadmap"; let DRY_RUN = process.argv.includes("--dry-run"); if (DRY_RUN) { @@ -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..ba3c9cdc2d 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); @@ -131,13 +132,21 @@ export function tagExists(tag: string): boolean { } /** - * Gets the git SHA of the commit that last modified a file. + * Gets the git SHA of the commit that introduced a file (the add commit), + * so subsequent edits don't steal credit from the PR that introduced it. * Falls back to HEAD if the file has no git history (e.g., untracked or newly staged). */ export function getFileSha(filePath: string): string { let normalizedPath = filePath.replaceAll("\\", "/"); try { - let sha = execGit(["log", "-1", "--format=%H", "--", normalizedPath]); + let sha = execGit([ + "log", + "-1", + "--diff-filter=A", + "--format=%H", + "--", + normalizedPath, + ]); if (sha) return sha; } catch {} return execGit(["rev-parse", "HEAD"]);