Skip to content

fix(react-router): stop Navigate re-issuing its navigation on every render - #8066

Open
kamalbennani wants to merge 3 commits into
TanStack:mainfrom
kamalbennani:fix/navigate-layout-effect-loop
Open

fix(react-router): stop Navigate re-issuing its navigation on every render#8066
kamalbennani wants to merge 3 commits into
TanStack:mainfrom
kamalbennani:fix/navigate-layout-effect-loop

Conversation

@kamalbennani

@kamalbennani kamalbennani commented Aug 14, 2026

Copy link
Copy Markdown

Fixes #8060.

Problem

Navigate guards its navigation on an identity check against the props object:

const previousPropsRef = React.useRef(null)
useLayoutEffect(() => {
  if (previousPropsRef.current !== props) {
    navigate(props)
    previousPropsRef.current = props
  }
}, [router, props, navigate])

React allocates a fresh props object on every render, so this never holds and the navigation is re-issued on every render.

That guard came from #3465 ("only navigate once in StrictMode", fixing #3455). It works for StrictMode because both invocations of the double-invoked effect close over the same props object. Across a genuine re-render it cannot hold, so the general case was never covered.

The consequence is an unbounded loop. Subscribing to router state is sufficient on its own: issuing the navigation changes router state, which re-renders the component, which re-issues the navigation. No external input and no async destination required - with the fixture's stop raised to 100000 it reaches 100001 renders in under half a second.

An async destination makes it worse rather than being a precondition: it stays pending across the re-renders, so each re-issue supersedes the previous one and restarts its beforeLoad. That is the shape that produces unbounded requests. We hit it in production - a redirect component holding a data-fetching subscription, 4511 requests before the tab died.

This is not a regression. I checked #5905 (which moved this to useLayoutEffect) because it looked like the culprit. It isn't - 1.136.17 and 1.136.18 straddle it and behave identically, and flipping the line back to React.useEffect on main changes nothing. The defect predates 1.131.7, and this PR keeps useLayoutEffect so #5905's flicker fix stands.

Fix

Guard on the resolved destination instead of the props object.

A value comparison of the props is not sufficient: search and params accept updater functions, which are usually declared inline and so are a fresh value on every render too. Resolving the location collapses those to a concrete href. Link already builds the location on every render, so this is a cost the router is used to paying.

This does not stop Navigate from navigating on updates. A Navigate whose destination genuinely changes still re-navigates; only re-issuing the identical resolved destination is suppressed.

The effect closes over the committed render's props rather than reading a ref written during render, so a render React discards cannot influence which options get used.

Tests

New e2e fixture e2e/react-router/navigate-component, covering the three ways the redirect component gets re-rendered:

Case Destination What it shows
subscribes to router state sync self-sustaining, needs nothing else
external store emits during a pending navigation async re-issues restart the destination guard
search as an updater function sync value-comparing props would not fix this

All three fail on main and pass with this change.

Navigate now resolves its destination during render, which also runs on the server where the navigation effect does not, so packages/react-router/tests/navigate-component.test.tsx adds a server-render check for the plain and updater-function forms.

Verification: 1012 react-router unit tests pass (1010 before, +2 added), 0 lint errors, no type errors, and the basic and basic-file-based e2e suites pass.

Note on the lockfile

The pnpm-lock.yaml diff contains peer-resolution churn beyond the new fixture's own entry. That is pre-existing: I confirmed an empty workspace package produces a 2-line diff, but any package with dependencies triggers a full peer re-resolution, because the committed lockfile is stale relative to pnpm 11.9.0. Happy to drop the fixture into an existing e2e app instead if you'd rather avoid it.

Note on the other adapters

solid-router and vue-router run this in onMount / onMounted and issue the navigation exactly once. React's Navigate is the only adapter that re-issues. If you'd prefer React match them by firing once on mount, I'm glad to redo it that way - it's a larger behavior change, so I went with the smaller one that makes the existing guard work.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed <Navigate> repeatedly triggering navigation when components re-render.
    • Navigation now completes once even when search parameters are generated dynamically.
  • Tests

    • Added coverage for router-state, external-store, and server-rendering scenarios.
    • Added end-to-end checks to detect navigation loops and verify destination rendering.

`Navigate` guarded its navigation on an identity check against the props
object. React allocates a fresh props object on every render, so the guard
never held and the navigation was re-issued on every render.

That is only observable when the component rendering `Navigate` re-renders
while the navigation is still pending, which happens whenever it subscribes to
router state or to any external store. Each re-issue supersedes the in-flight
navigation before its `beforeLoad` can settle, so the navigation never commits:
the app stays on a loading state while requests pile up.

Guard on the resolved destination instead. A value comparison of the props is
not enough, because `search` and `params` accept updater functions that are
usually declared inline and so are fresh on every render too.

Adds an e2e fixture covering the three ways the redirect component gets
re-rendered during a pending navigation. All three fail before this change.
Close over the committed render's props instead of mirroring them into a ref
during render: a render React discards can still write the ref, so the effect
could navigate with options from an abandoned render.

`Navigate` now resolves its destination during render, which also runs on the
server where the navigation effect does not. Adds a server-render test for
both the plain and the updater-function form.
The async destination is not a precondition. Subscribing to router state is
enough on its own: issuing the navigation changes router state, which
re-renders the component, which re-issues the navigation. The async case now
sits on its own route, where it shows what it actually contributes - each
re-issue supersedes the pending navigation and restarts its beforeLoad.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e328ad4e-977f-443a-bdb7-5c7fcb04ce56

📥 Commits

Reviewing files that changed from the base of the PR and between 0ad38a7 and 5e53763.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .changeset/olive-jokes-hunt.md
  • e2e/react-router/navigate-component/index.html
  • e2e/react-router/navigate-component/package.json
  • e2e/react-router/navigate-component/playwright.config.ts
  • e2e/react-router/navigate-component/src/main.tsx
  • e2e/react-router/navigate-component/tests/navigate.spec.ts
  • e2e/react-router/navigate-component/tsconfig.json
  • e2e/react-router/navigate-component/vite.config.js
  • packages/react-router/src/useNavigate.tsx
  • packages/react-router/tests/navigate-component.test.tsx

📝 Walkthrough

Walkthrough

Navigate now compares resolved destination hrefs instead of props object identity. The change adds server-rendering tests and a Playwright fixture for router-state, external-store, asynchronous, and function-based search re-renders.

Changes

Navigate loop prevention

Layer / File(s) Summary
Resolved destination guard
packages/react-router/src/useNavigate.tsx, packages/react-router/tests/navigate-component.test.tsx, .changeset/olive-jokes-hunt.md
Navigate tracks resolved hrefs before issuing navigation. Server-rendering tests cover direct and updater-based destinations.
End-to-end test harness
e2e/react-router/navigate-component/index.html, e2e/react-router/navigate-component/package.json, e2e/react-router/navigate-component/tsconfig.json, e2e/react-router/navigate-component/vite.config.js, e2e/react-router/navigate-component/playwright.config.ts
The new E2E package defines the Vite app, TypeScript settings, package scripts, and Playwright Chromium configuration.
Re-render navigation scenarios
e2e/react-router/navigate-component/src/main.tsx, e2e/react-router/navigate-component/tests/navigate.spec.ts
The fixture covers router-state, external-store, and function-based search re-renders. Tests verify successful navigation, no loop detection, and one asynchronous destination load.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: 🔵 Low · up to 5e537

The PR fixes repeated navigation and adds focused coverage; the only remaining concern is a minor consistency issue in the new e2e package's internal dependency ranges, which is mergeable with owner awareness or a small follow-up.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant Navigate
  participant Router
  participant AsyncTarget
  Browser->>Navigate: Render redirect component
  Navigate->>Router: Resolve destination and start navigation
  Router->>AsyncTarget: Run beforeLoad
  AsyncTarget-->>Router: Complete destination loading
  Router-->>Browser: Render target route
  Browser->>Navigate: Trigger repeated re-render
  Navigate->>Navigate: Reuse resolved href guard
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary fix: preventing Navigate from re-issuing navigation on every render.
Linked Issues check ✅ Passed The implementation compares resolved destinations, preserves useLayoutEffect, and adds coverage for all issue #8060 scenarios.
Out of Scope Changes check ✅ Passed All changes support the Navigate fix, including implementation, unit tests, E2E coverage, configuration, and the release changeset.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

<Navigate> re-issues its navigation on every render, causing an unbounded loop

2 participants