diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index ce95e5b4c1..8289903661 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 { trimPathRight } from '@tanstack/router-core' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' import type { @@ -10,6 +11,245 @@ import type { RegisteredRouter, UseNavigateResult, } from '@tanstack/router-core' +import type { HistoryState, ParsedHistoryState } from '@tanstack/history' + +type NavigateLocationKey = { + href: string + replace: boolean + 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, + __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 { + hashScrollIntoView: _hashScrollIntoView, + href, + ignoreBlocker: _ignoreBlocker, + reloadDocument: _reloadDocument, + replace, + resetScroll: _resetScroll, + startTransition: _startTransition, + viewTransition: _viewTransition, + ...toOptions + } = props + + const next = router.buildLocation({ + ...toOptions, + _includeValidateSearch: true, + } as Parameters[0]) + + return { + href: trimPathRight(href || next.href), + replace: replace ?? false, + state: getUserHistoryState(next.state), + } +} + +function isSameNavigateLocationKey( + a: NavigateLocationKey, + b: NavigateLocationKey, +) { + return ( + a.href === b.href && + a.replace === b.replace && + isEqualHistoryState(a.state, b.state) + ) +} /** * Imperative navigation hook. @@ -60,20 +300,22 @@ export function Navigate< const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >(props: NavigateOptions): null { - const router = useRouter() - const navigate = useNavigate() - - const previousPropsRef = React.useRef | null>(null) + const router = useRouter() + const navigate = useNavigate() + + 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 1ec1072a26..9c6ecf4de6 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,248 @@ 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(' 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(' 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) => {