From c81867eaf00b57ef06f4c83797f4c9a48648a510 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Fri, 14 Aug 2026 12:06:20 +0800 Subject: [PATCH 1/3] Fix repeated React Navigate rerenders --- packages/react-router/src/useNavigate.tsx | 69 ++++++++++++++++--- .../react-router/tests/useNavigate.test.tsx | 50 +++++++++++++- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index ce95e5b4c1c..cdcdc1b910b 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { deepEqual, trimPathRight } from '@tanstack/router-core' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' import type { @@ -10,6 +11,50 @@ import type { RegisteredRouter, UseNavigateResult, } from '@tanstack/router-core' +import type { HistoryState, ParsedHistoryState } from '@tanstack/history' + +type NavigateLocationKey = { + href: string + state: HistoryState +} + +function getUserHistoryState({ + key: _key, + __TSR_key: _tsrKey, + __TSR_index: _tsrIndex, + __hashScrollIntoViewOptions: _hashScroll, + ...state +}: ParsedHistoryState): HistoryState { + return state +} + +function getNavigateLocationKey< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string = string, + const TTo extends string | undefined = undefined, + const TMaskFrom extends string = TFrom, + const TMaskTo extends string = '', +>( + router: TRouter, + props: NavigateOptions, +): NavigateLocationKey { + const next = router.buildLocation({ + ...(props as any), + _includeValidateSearch: true, + }) + + return { + href: trimPathRight(next.href), + state: getUserHistoryState(next.state), + } +} + +function isSameNavigateLocationKey( + a: NavigateLocationKey, + b: NavigateLocationKey, +) { + return a.href === b.href && deepEqual(a.state, b.state) +} /** * Imperative navigation hook. @@ -60,20 +105,22 @@ export function Navigate< const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >(props: NavigateOptions): null { - const router = useRouter() - const navigate = useNavigate() + const router = useRouter() + const navigate = useNavigate() - const previousPropsRef = React.useRef | null>(null) + const previousLocationKeyRef = React.useRef(null) useLayoutEffect(() => { - if (previousPropsRef.current !== props) { + const nextLocationKey = getNavigateLocationKey(router, props) + + if ( + previousLocationKeyRef.current === null || + !isSameNavigateLocationKey( + previousLocationKeyRef.current, + nextLocationKey, + ) + ) { + previousLocationKeyRef.current = nextLocationKey navigate(props) - previousPropsRef.current = props } }, [router, props, navigate]) return null diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 1ec1072a26c..f38fc55bdd5 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -25,8 +25,9 @@ import { getRouteApi, useNavigate, useParams, + useRouterState, } from '../src' -import type { RouterHistory } from '../src' +import type { RouterHistory, RouterState } from '../src' let history: RouterHistory @@ -1369,6 +1370,53 @@ test(' navigates only once in ', async () => { expect(navigateSpy.mock.calls.length).toBe(1) }) +test(' does not re-issue navigation when its component re-renders', async () => { + let rootRenderCount = 0 + + const rootRoute = createRootRoute({ + component: function RootComponent() { + useRouterState({ select: (state: RouterState) => state.location.href }) + rootRenderCount++ + + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + const navigateSpy = vi.spyOn(router, 'navigate') + + render() + + expect(await screen.findByTestId('posts-title')).toBeInTheDocument() + + await waitFor(() => { + expect(rootRenderCount).toBeGreaterThan(1) + }) + + expect(navigateSpy).toHaveBeenCalledTimes(1) +}) + test.each([true, false])( 'should navigate to current route with search params when using "." in nested route structure from Index Route', async (trailingSlash: boolean) => { From 9ac2892a625b438eeda6ded9c8c25567fdcfc8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=BC=E5=81=A5=E8=81=AA?= Date: Fri, 14 Aug 2026 15:23:48 +0800 Subject: [PATCH 2/3] Support structured history state in Navigate guard --- packages/react-router/src/useNavigate.tsx | 197 +++++++++++++++++- .../react-router/tests/useNavigate.test.tsx | 100 +++++++++ 2 files changed, 293 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index cdcdc1b910b..26ee5aec02c 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { deepEqual, trimPathRight } from '@tanstack/router-core' +import { trimPathRight } from '@tanstack/router-core' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' import type { @@ -18,6 +18,183 @@ type NavigateLocationKey = { state: HistoryState } +function isEqualArrayBuffer(a: ArrayBuffer, b: ArrayBuffer): boolean { + if (a.byteLength !== b.byteLength) { + return false + } + + const aBytes = new Uint8Array(a) + const bBytes = new Uint8Array(b) + for (let i = 0; i < aBytes.length; i++) { + if (aBytes[i] !== bBytes[i]) { + return false + } + } + + return true +} + +function isEqualArrayBufferView(a: ArrayBufferView, b: ArrayBufferView) { + if (a.byteLength !== b.byteLength) { + return false + } + + const aBytes = new Uint8Array( + a.buffer as ArrayBuffer, + a.byteOffset, + a.byteLength, + ) + const bBytes = new Uint8Array( + b.buffer as ArrayBuffer, + b.byteOffset, + b.byteLength, + ) + for (let i = 0; i < aBytes.length; i++) { + if (aBytes[i] !== bBytes[i]) { + return false + } + } + + return true +} + +function hasSeenPair( + seen: WeakMap>, + a: object, + b: object, +) { + const paired = seen.get(a) + if (paired?.has(b)) { + return true + } + + if (paired) { + paired.add(b) + } else { + seen.set(a, new WeakSet([b])) + } + + return false +} + +function isEqualHistoryState( + a: unknown, + b: unknown, + seen = new WeakMap>(), +): boolean { + if (Object.is(a, b)) { + return true + } + + if ( + a === null || + b === null || + typeof a !== 'object' || + typeof b !== 'object' + ) { + return false + } + + if (hasSeenPair(seen, a, b)) { + return true + } + + const aTag = Object.prototype.toString.call(a) + if (aTag !== Object.prototype.toString.call(b)) { + return false + } + + if (a instanceof Date && b instanceof Date) { + return Object.is(a.getTime(), b.getTime()) + } + + if (a instanceof RegExp && b instanceof RegExp) { + return a.source === b.source && a.flags === b.flags + } + + if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) { + return isEqualArrayBuffer(a, b) + } + + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + return isEqualArrayBufferView(a, b) + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!isEqualHistoryState(a[i], b[i], seen)) { + return false + } + } + + return true + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) { + return false + } + + const bEntries = Array.from(b.entries()) + let index = 0 + for (const [aKey, aValue] of a.entries()) { + const [bKey, bValue] = bEntries[index++]! + if ( + !isEqualHistoryState(aKey, bKey, seen) || + !isEqualHistoryState(aValue, bValue, seen) + ) { + return false + } + } + + return true + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) { + return false + } + + const bValues = Array.from(b.values()) + let index = 0 + for (const aValue of a.values()) { + if (!isEqualHistoryState(aValue, bValues[index++], seen)) { + return false + } + } + + return true + } + + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false + } + + if ( + !isEqualHistoryState( + (a as Record)[key], + (b as Record)[key], + seen, + ) + ) { + return false + } + } + + return true +} + function getUserHistoryState({ key: _key, __TSR_key: _tsrKey, @@ -38,10 +215,22 @@ function getNavigateLocationKey< router: TRouter, props: NavigateOptions, ): NavigateLocationKey { + const { + hashScrollIntoView: _hashScrollIntoView, + href: _href, + ignoreBlocker: _ignoreBlocker, + reloadDocument: _reloadDocument, + replace: _replace, + resetScroll: _resetScroll, + startTransition: _startTransition, + viewTransition: _viewTransition, + ...toOptions + } = props + const next = router.buildLocation({ - ...(props as any), + ...toOptions, _includeValidateSearch: true, - }) + } as Parameters[0]) return { href: trimPathRight(next.href), @@ -53,7 +242,7 @@ function isSameNavigateLocationKey( a: NavigateLocationKey, b: NavigateLocationKey, ) { - return a.href === b.href && deepEqual(a.state, b.state) + return a.href === b.href && isEqualHistoryState(a.state, b.state) } /** diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index f38fc55bdd5..94abaefb689 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1417,6 +1417,106 @@ test(' does not re-issue navigation when its component re-renders', as expect(navigateSpy).toHaveBeenCalledTimes(1) }) +test(' does not re-issue navigation for inline Date state', async () => { + let rootRenderCount = 0 + + const rootRoute = createRootRoute({ + component: function RootComponent() { + useRouterState({ select: (state: RouterState) => state.location.href }) + rootRenderCount++ + + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + const navigateSpy = vi.spyOn(router, 'navigate') + + render() + + expect(await screen.findByTestId('posts-title')).toBeInTheDocument() + + await waitFor(() => { + expect(rootRenderCount).toBeGreaterThan(1) + }) + + expect(navigateSpy).toHaveBeenCalledTimes(1) +}) + +test(' does not re-issue navigation for cyclic state', async () => { + let rootRenderCount = 0 + + const makeState = () => { + const state: { label: string; self?: unknown } = { label: 'stable' } + state.self = state + return state + } + + const rootRoute = createRootRoute({ + component: function RootComponent() { + useRouterState({ select: (state: RouterState) => state.location.href }) + rootRenderCount++ + + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + const navigateSpy = vi.spyOn(router, 'navigate') + + render() + + expect(await screen.findByTestId('posts-title')).toBeInTheDocument() + + await waitFor(() => { + expect(rootRenderCount).toBeGreaterThan(1) + }) + + expect(navigateSpy).toHaveBeenCalledTimes(1) +}) + test.each([true, false])( 'should navigate to current route with search params when using "." in nested route structure from Index Route', async (trailingSlash: boolean) => { From 9361a83e61f4fc59394fff3b083dd7a07a2808ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=BC=E5=81=A5=E8=81=AA?= Date: Fri, 14 Aug 2026 15:36:58 +0800 Subject: [PATCH 3/3] Preserve href and replace in Navigate guard --- packages/react-router/src/useNavigate.tsx | 14 ++- .../react-router/tests/useNavigate.test.tsx | 95 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 26ee5aec02c..8289903661d 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -15,6 +15,7 @@ import type { HistoryState, ParsedHistoryState } from '@tanstack/history' type NavigateLocationKey = { href: string + replace: boolean state: HistoryState } @@ -217,10 +218,10 @@ function getNavigateLocationKey< ): NavigateLocationKey { const { hashScrollIntoView: _hashScrollIntoView, - href: _href, + href, ignoreBlocker: _ignoreBlocker, reloadDocument: _reloadDocument, - replace: _replace, + replace, resetScroll: _resetScroll, startTransition: _startTransition, viewTransition: _viewTransition, @@ -233,7 +234,8 @@ function getNavigateLocationKey< } as Parameters[0]) return { - href: trimPathRight(next.href), + href: trimPathRight(href || next.href), + replace: replace ?? false, state: getUserHistoryState(next.state), } } @@ -242,7 +244,11 @@ function isSameNavigateLocationKey( a: NavigateLocationKey, b: NavigateLocationKey, ) { - return a.href === b.href && isEqualHistoryState(a.state, b.state) + return ( + a.href === b.href && + a.replace === b.replace && + isEqualHistoryState(a.state, b.state) + ) } /** diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 94abaefb689..9c6ecf4de6c 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1517,6 +1517,101 @@ test(' does not re-issue navigation for cyclic state', async () => { expect(navigateSpy).toHaveBeenCalledTimes(1) }) +test(' re-issues navigation when href changes', async () => { + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [href, setHref] = React.useState('/posts') + + return ( + <> + + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>

About

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, aboutRoute]), + history, + }) + + const navigateSpy = vi.spyOn(router, 'navigate') + + render() + + expect(await screen.findByTestId('posts-title')).toBeInTheDocument() + + fireEvent.click(await screen.findByRole('button', { name: 'About' })) + + expect(await screen.findByTestId('about-title')).toBeInTheDocument() + expect(navigateSpy).toHaveBeenCalledTimes(2) +}) + +test(' re-issues navigation when replace changes', async () => { + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [replace, setReplace] = React.useState(false) + + return ( + <> + + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + const navigateSpy = vi.spyOn(router, 'navigate') + + render() + + expect(await screen.findByTestId('posts-title')).toBeInTheDocument() + + fireEvent.click(await screen.findByRole('button', { name: 'Replace' })) + + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledTimes(2) + }) +}) + test.each([true, false])( 'should navigate to current route with search params when using "." in nested route structure from Index Route', async (trailingSlash: boolean) => {