From fa652cd32a07e9488673a9071e476125eb19921c Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 03:19:51 +0300 Subject: [PATCH 1/4] feat(react-router): history-aware links via `preferBack` prop on `Link` `` navigates via `history.back()` when `/x` resolves to the previous history entry, instead of always pushing a new one. This preserves forward history and the browser's native per-entry scroll restoration for "Back to X" links. Best-effort: falls back to a normal push (or replace) when the target isn't the previous entry or the previous entry is unknown (fresh load / deep link). Always renders a real ``, so keyboard nav, "copy link", and middle/modifier-click keep working. `preferBack` accepts a match mode: `true`/`'pathname'` (default) matches by pathname only (restoring the previous entry's exact search + scroll), `'exact'` also requires search to match. New public APIs: - router-core: in-memory per-index history tracking + `router.getHistoryEntry(index)` - react-router: `useIsBackNavigation(options, match?)` hook (the primitive behind `preferBack`) Includes tests (router-core tracking; react-router decision + click guards), docs, and a changeset. Discussion: TanStack/router#7699 --- .../direction-aware-links-prefer-back.md | 15 + docs/router/api/router.md | 1 + docs/router/api/router/useIsBackNavigation.md | 62 ++++ docs/router/guide/navigation.md | 18 + packages/react-router/src/index.tsx | 2 + packages/react-router/src/link.tsx | 24 ++ .../react-router/src/useIsBackNavigation.ts | 94 +++++ .../tests/useIsBackNavigation.test.tsx | 349 ++++++++++++++++++ packages/router-core/src/index.ts | 1 + packages/router-core/src/link.ts | 22 ++ packages/router-core/src/router.ts | 54 +++ .../router-core/tests/historyEntries.test.ts | 110 ++++++ 12 files changed, 752 insertions(+) create mode 100644 .changeset/direction-aware-links-prefer-back.md create mode 100644 docs/router/api/router/useIsBackNavigation.md create mode 100644 packages/react-router/src/useIsBackNavigation.ts create mode 100644 packages/react-router/tests/useIsBackNavigation.test.tsx create mode 100644 packages/router-core/tests/historyEntries.test.ts diff --git a/.changeset/direction-aware-links-prefer-back.md b/.changeset/direction-aware-links-prefer-back.md new file mode 100644 index 0000000000..b6819f019d --- /dev/null +++ b/.changeset/direction-aware-links-prefer-back.md @@ -0,0 +1,15 @@ +--- +'@tanstack/router-core': minor +'@tanstack/react-router': minor +--- + +feat: history-aware links via a `preferBack` prop on `Link` + +`` now navigates via `history.back()` when `/x` resolves to the previous history entry, instead of always pushing a new one. This preserves forward history and the browser's native per-entry scroll restoration for "Back to X" links. It is best-effort: when the target isn't the previous entry — or the previous entry is unknown (e.g. a fresh page load or deep link) — it falls back to a normal push (or `replace`, if set). The element always renders a real ``, so keyboard nav, "copy link", and middle/modifier-click keep working. + +`preferBack` accepts a match mode: `true`/`'pathname'` (default) matches by pathname only (restoring the previous entry's exact search and scroll), while `'exact'` also requires the search to match. + +New public APIs: + +- `useIsBackNavigation(options, match?)` (react-router) — the primitive behind `preferBack`; returns whether navigating to `options` would resolve to the previous history entry. `match` is `'pathname'` (default) or `'exact'`. +- `router.getHistoryEntry(index)` (router-core) — read a visited history entry by its `__TSR_index` from the router's in-memory per-index tracking. diff --git a/docs/router/api/router.md b/docs/router/api/router.md index 2c5ede186c..5180d7db6f 100644 --- a/docs/router/api/router.md +++ b/docs/router/api/router.md @@ -39,6 +39,7 @@ title: Router API - [`useBlocker`](./router/useBlockerHook.md) - [`useCanGoBack`](./router/useCanGoBack.md) - [`useChildMatches`](./router/useChildMatchesHook.md) + - [`useIsBackNavigation`](./router/useIsBackNavigation.md) - [`useLinkProps`](./router/useLinkPropsHook.md) - [`useLoaderData`](./router/useLoaderDataHook.md) - [`useLoaderDeps`](./router/useLoaderDepsHook.md) diff --git a/docs/router/api/router/useIsBackNavigation.md b/docs/router/api/router/useIsBackNavigation.md new file mode 100644 index 0000000000..f4b186a12b --- /dev/null +++ b/docs/router/api/router/useIsBackNavigation.md @@ -0,0 +1,62 @@ +--- +id: useIsBackNavigation +title: useIsBackNavigation hook +--- + +The `useIsBackNavigation` hook returns a boolean representing whether navigating to the given options would resolve to the **previous** history entry — i.e. whether clicking it should go "back" rather than push a new entry. + +It is the primitive behind the [`preferBack`](../../guide/navigation.md#history-aware-links-preferback) prop on [`Link`](./linkComponent.md). Use it directly when building custom links or buttons that want the same history-aware behavior. + +> ⚠️ The following `useIsBackNavigation` API is currently _experimental_. + +## useIsBackNavigation options + +The `useIsBackNavigation` hook accepts the same navigation options as `Link`/`useNavigate` (`to`, `params`, `search`, `hash`, `from`, etc.) as its first argument. + +The optional second argument is the **match mode**: + +- `'pathname'` (default) — match by pathname only, so going back restores the previous entry's exact search params and scroll position. +- `'exact'` — match by pathname **and** search. + +## useIsBackNavigation returns + +- `true` if the resolved target's pathname equals the previous history entry's pathname. +- `false` otherwise, including: + - when the router is at history index `0` (nothing behind it), + - when the previous entry is **unknown** to the router (e.g. a fresh page load or deep link, where the router never recorded the entry behind the current one), + - on the server. + +Because it returns `false` whenever a back navigation can't be determined, it is always safe to branch on: a `false` simply means "navigate normally". + +## How it works + +The browser only exposes the *current* history entry, so the router maintains an in-memory map of visited entries keyed by their history index (`__TSR_index`). `useIsBackNavigation` compares the resolved target against the entry at `currentIndex - 1`, by pathname (and search, in `'exact'` mode). You can read these entries directly via [`router.getHistoryEntry(index)`](./RouterType.md). + +## Examples + +```tsx +import { useRouter, useIsBackNavigation } from '@tanstack/react-router' + +function BackToIssues() { + const router = useRouter() + const isBack = useIsBackNavigation({ to: '/issues' }) + + return ( + + ) +} +``` + +For the common case, prefer the `preferBack` prop on `Link`, which renders a real `` and wires this up for you: + +```tsx + + Back to issues + +``` diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index 3dd09cba47..80ca808720 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -133,6 +133,8 @@ export type LinkOptions< preloadDelay?: number // If true, will render the link without the href attribute disabled?: boolean + // If true, clicking goes back via `history.back()` when the resolved target is the previous history entry (see "Direction-aware links" below) + preferBack?: boolean } ``` @@ -733,6 +735,22 @@ const link = ( ) ``` +### History-aware links (`preferBack`) + +> ⚠️ The `preferBack` prop and the `useIsBackNavigation` hook are currently _experimental_. + +A regular "Back to X" link always **pushes** a new history entry — even when `/x` is the entry the user just came from — discarding forward history and the browser's native per-entry scroll restoration. + +Setting `preferBack` makes the link history-aware: when its resolved target matches the previous history entry, clicking it calls `router.history.back()` instead of pushing. Otherwise it behaves like a normal link, falling back to a push (or `replace`, if also set) — including when the previous entry is unknown to the router (e.g. a fresh load or deep link). It always renders a real ``, so keyboard nav, "copy link", and middle/modifier-click keep working. + +```tsx + + Back to issues + +``` + +By default (`preferBack` / `preferBack="pathname"`) the target is matched by **pathname only**, so going back restores the user's exact prior search params and scroll position. Use `preferBack="exact"` to also require the search to match (it then pushes when the search differs). For custom links or buttons, the underlying [`useIsBackNavigation`](../api/router/useIsBackNavigation.md) hook returns whether navigating to the given options would go back. + ## `useNavigate` > ⚠️ Because of the `Link` component's built-in affordances around `href`, cmd/ctrl + click-ability, and active/inactive capabilities, it's recommended to use the `Link` component instead of `useNavigate` for anything the user can interact with (e.g. links, buttons). However, there are some cases where `useNavigate` is necessary to handle side-effect navigations (e.g. a successful async action that results in a navigation). diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6637c7542b..d39c0460d9 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -298,6 +298,8 @@ export { useRouter } from './useRouter' export { useRouterState } from './useRouterState' export { useLocation } from './useLocation' export { useCanGoBack } from './useCanGoBack' +export { useIsBackNavigation } from './useIsBackNavigation' +export type { BackNavigationMatch } from './useIsBackNavigation' export { CatchNotFound, DefaultGlobalNotFound } from './not-found' export { notFound, isNotFound } from '@tanstack/router-core' diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index eddc0ac0b3..2ac218c969 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -18,6 +18,7 @@ import { useRouter } from './useRouter' import { useForwardedRef, useIntersectionObserver } from './utils' import { useHydrated } from './ClientOnly' +import { resolveIsBackNavigation } from './useIsBackNavigation' import type { AnyRouter, Constrain, @@ -73,6 +74,7 @@ export function useLinkProps< startTransition, resetScroll, viewTransition, + preferBack, // element props children, target, @@ -412,6 +414,19 @@ export function useLinkProps< return router.buildLocation(opts as any) }, [router, currentLocation, _options]) + // History-aware links: when `preferBack` is set and the resolved target is + // the previous history entry, a primary click should go back rather than push. + // `true`/`'pathname'` match by pathname; `'exact'` also requires search. + // Reuses the already-built `next`, so no extra `buildLocation` call. + const isBackNavigation = + !!preferBack && + resolveIsBackNavigation( + router, + currentLocation, + next, + preferBack === 'exact' ? 'exact' : 'pathname', + ) + // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL // Otherwise it's the origin-stripped path @@ -626,6 +641,15 @@ export function useLinkProps< setIsTransitioning(false) }) + // History-aware: the target is the previous history entry, so go back + // instead of pushing. This preserves forward history and the browser's + // native per-entry scroll restoration. Scroll/viewTransition behavior is + // handled by the router's existing popstate handling. + if (isBackNavigation) { + router.history.back({ ignoreBlocker }) + return + } + // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate({ diff --git a/packages/react-router/src/useIsBackNavigation.ts b/packages/react-router/src/useIsBackNavigation.ts new file mode 100644 index 0000000000..93ecb04210 --- /dev/null +++ b/packages/react-router/src/useIsBackNavigation.ts @@ -0,0 +1,94 @@ +import * as React from 'react' +import { useStore } from '@tanstack/react-store' +import { isServer } from '@tanstack/router-core/isServer' +import { useRouter } from './useRouter' +import type { + AnyRouter, + LinkOptions, + ParsedLocation, + RegisteredRouter, +} from '@tanstack/router-core' + +/** + * How a target is matched against the previous history entry: + * - `'pathname'` — match by pathname only (so going back restores the previous + * entry's exact search params and scroll position). + * - `'exact'` — match by pathname **and** search. + */ +export type BackNavigationMatch = 'pathname' | 'exact' + +/** + * Decide whether navigating to `target` would land on the *previous* history + * entry, meaning a `history.back()` is preferable to a push. + * + * Pure and framework-agnostic: it relies only on the router's in-memory + * per-index entry tracking (`router.getHistoryEntry`). Returns `false` at the + * start of history (index 0) and when the previous entry is unknown (e.g. a + * fresh page load or deep link), so callers degrade gracefully to a normal push. + */ +export function resolveIsBackNavigation( + router: AnyRouter, + currentLocation: ParsedLocation, + target: Pick, + match: BackNavigationMatch = 'pathname', +): boolean { + const currentIndex = currentLocation.state.__TSR_index ?? 0 + if (currentIndex === 0) return false + const previous = router.getHistoryEntry(currentIndex - 1) + if (!previous || previous.pathname !== target.pathname) return false + if (match === 'exact') return previous.searchStr === target.searchStr + return true +} + +/** + * Returns `true` when a link/navigation to the given options would resolve to + * the *previous* history entry — i.e. clicking it should go "back" rather than + * push a new entry. + * + * This is the primitive behind the `preferBack` prop on `Link`. Use it directly + * when building custom links or buttons that want the same history-aware + * behavior. It returns `false` on the server and falls back to `false` whenever + * the previous entry is unknown to the router, so it is always safe to branch on. + * + * Pass `match: 'exact'` to also require the search to match (defaults to + * `'pathname'`, which restores the previous entry's exact search and scroll). + * + * @example + * const isBack = useIsBackNavigation({ to: '/issues' }) + * // isBack === true -> call router.history.back() + * // isBack === false -> navigate normally + */ +export function useIsBackNavigation< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string = string, + const TTo extends string | undefined = undefined, + const TMaskFrom extends string = TFrom, + const TMaskTo extends string = '', +>( + options: LinkOptions, + match: BackNavigationMatch = 'pathname', +): boolean { + const router = useRouter() + + // On the server there is no client-side history to pop, and the previous + // entry is never known, so back-navigation is always false. + if (isServer ?? router.isServer) { + return false + } + + // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static (compile-time `isServer`) + const currentLocation = useStore( + router.stores.location, + (l) => l, + (prev, next) => prev.href === next.href, + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + return React.useMemo(() => { + const next = router.buildLocation({ + _fromLocation: currentLocation, + ...options, + } as any) + return resolveIsBackNavigation(router, currentLocation, next, match) + }, [router, currentLocation, options, match]) +} diff --git a/packages/react-router/tests/useIsBackNavigation.test.tsx b/packages/react-router/tests/useIsBackNavigation.test.tsx new file mode 100644 index 0000000000..45b856b03b --- /dev/null +++ b/packages/react-router/tests/useIsBackNavigation.test.tsx @@ -0,0 +1,349 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useIsBackNavigation, +} from '../src' + +beforeEach(() => { + cleanup() + vi.restoreAllMocks() +}) +afterEach(() => { + cleanup() +}) + +function HookProbe() { + const backToHome = useIsBackNavigation({ to: '/' }) + const backToContact = useIsBackNavigation({ to: '/contact' }) + return ( + <> + {String(backToHome)} + {String(backToContact)} + + ) +} + +function RootComponent() { + return ( + <> + Home + About + Contact + + Back home + + + Prefer contact + + + + + ) +} + +function setup(initialEntries: Array = ['/']) { + const rootRoute = createRootRoute({ component: RootComponent }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

AboutTitle

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

ContactTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute, contactRoute]), + history: createMemoryHistory({ initialEntries }), + }) + + const utils = render() + return { router, ...utils } +} + +describe('useIsBackNavigation', () => { + test('is false at the start of history (no previous entry)', async () => { + setup(['/']) + await screen.findByText('IndexTitle') + + // index 0 — nothing behind, so neither target is a back navigation. + expect(screen.getByTestId('hook-home')).toHaveTextContent('false') + expect(screen.getByTestId('hook-contact')).toHaveTextContent('false') + }) + + test('is true only when the target equals the previous entry', async () => { + const { router } = setup(['/']) + await screen.findByText('IndexTitle') + + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + + // Previous entry (index 0) is "/", so a link to "/" is a back navigation, + // but a link to "/contact" is not. + expect(router.history.location.state.__TSR_index).toBe(1) + expect(screen.getByTestId('hook-home')).toHaveTextContent('true') + expect(screen.getByTestId('hook-contact')).toHaveTextContent('false') + }) + + test('falls back to false when the previous entry is unknown (deep link)', async () => { + // Router starts at index 1; entry 0 ("/") was never recorded by the router. + const { router } = setup(['/', '/about']) + await screen.findByText('AboutTitle') + + expect(router.history.location.state.__TSR_index).toBe(1) + expect(router.getHistoryEntry(0)).toBeUndefined() + expect(screen.getByTestId('hook-home')).toHaveTextContent('false') + }) +}) + +describe('Link preferBack', () => { + test('keeps a real
regardless of decision', async () => { + setup(['/']) + await screen.findByText('IndexTitle') + + const link = screen.getByTestId('preferback-home') + expect(link.tagName).toBe('A') + expect(link.getAttribute('href')).toBe('/') + }) + + test('goes back when the target is the previous entry', async () => { + const { router } = setup(['/']) + await screen.findByText('IndexTitle') + + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + expect(router.history.location.state.__TSR_index).toBe(1) + + const backSpy = vi.spyOn(router.history, 'back') + + await act(() => fireEvent.click(screen.getByTestId('preferback-home'))) + await screen.findByText('IndexTitle') + + expect(backSpy).toHaveBeenCalledTimes(1) + // Popped rather than pushed: index decreased to 0, forward history kept. + expect(router.history.location.state.__TSR_index).toBe(0) + }) + + test('pushes normally when the target is not the previous entry', async () => { + const { router } = setup(['/']) + await screen.findByText('IndexTitle') + + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + + const backSpy = vi.spyOn(router.history, 'back') + + // Previous entry is "/", target is "/contact" → no match → push. + await act(() => fireEvent.click(screen.getByTestId('preferback-contact'))) + await screen.findByText('ContactTitle') + + expect(backSpy).not.toHaveBeenCalled() + expect(router.history.location.state.__TSR_index).toBe(2) + }) + + test('pushes normally when the previous entry is unknown (deep link)', async () => { + const { router } = setup(['/', '/about']) + await screen.findByText('AboutTitle') + + const backSpy = vi.spyOn(router.history, 'back') + + await act(() => fireEvent.click(screen.getByTestId('preferback-home'))) + await screen.findByText('IndexTitle') + + expect(backSpy).not.toHaveBeenCalled() + // Fell back to a push: a new entry at index 2, not a pop to index 0. + expect(router.history.location.state.__TSR_index).toBe(2) + }) + + describe('match modes (pathname vs exact)', () => { + function ModeProbe() { + const exactMatch = useIsBackNavigation({ to: '/about', search: { page: 3 } }, 'exact') + const exactNoMatch = useIsBackNavigation({ to: '/about', search: { page: 9 } }, 'exact') + const pathnameDiffSearch = useIsBackNavigation({ to: '/about', search: { page: 9 } }, 'pathname') + return ( + <> + {String(exactMatch)} + {String(exactNoMatch)} + {String(pathnameDiffSearch)} + + ) + } + + function setupModes() { + const rootRoute = createRootRoute({ + component: () => ( + <> + + About 3 + + Contact + + Exact back (match) + + + Exact back (no match) + + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: search.page as number | undefined, + }), + component: () =>

AboutTitle

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

ContactTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute, contactRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const utils = render() + return { router, ...utils } + } + + // Sets up history so the previous entry is /about?page=3. + async function arrangePreviousAboutPage3() { + const utils = setupModes() + await screen.findByText('IndexTitle') + await act(() => fireEvent.click(screen.getByText('About 3'))) + await screen.findByText('AboutTitle') + await act(() => fireEvent.click(screen.getByText('Contact'))) + await screen.findByText('ContactTitle') + expect(utils.router.history.location.state.__TSR_index).toBe(2) + return utils + } + + test('exact: true only when pathname AND search match the previous entry', async () => { + await arrangePreviousAboutPage3() + + expect(screen.getByTestId('exact-match')).toHaveTextContent('true') + expect(screen.getByTestId('exact-nomatch')).toHaveTextContent('false') + // pathname mode ignores search, so a differing search still matches. + expect(screen.getByTestId('pathname-diffsearch')).toHaveTextContent('true') + }) + + test('preferBack="exact" goes back when search matches', async () => { + const { router } = await arrangePreviousAboutPage3() + const backSpy = vi.spyOn(router.history, 'back') + + await act(() => fireEvent.click(screen.getByTestId('exact-back'))) + await screen.findByText('AboutTitle') + + expect(backSpy).toHaveBeenCalledTimes(1) + expect(router.history.location.state.__TSR_index).toBe(1) + }) + + test('preferBack="exact" pushes when search differs', async () => { + const { router } = await arrangePreviousAboutPage3() + const backSpy = vi.spyOn(router.history, 'back') + + await act(() => fireEvent.click(screen.getByTestId('exact-push'))) + await screen.findByText('AboutTitle') + + expect(backSpy).not.toHaveBeenCalled() + expect(router.history.location.state.__TSR_index).toBe(3) + }) + }) + + describe('click guards do not trigger back navigation', () => { + async function setupAtAbout() { + const utils = setup(['/']) + await screen.findByText('IndexTitle') + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + const backSpy = vi.spyOn(utils.router.history, 'back') + return { ...utils, backSpy } + } + + test('middle-click (button !== 0) is ignored', async () => { + const { backSpy } = await setupAtAbout() + await act(() => + fireEvent.click(screen.getByTestId('preferback-home'), { button: 1 }), + ) + expect(backSpy).not.toHaveBeenCalled() + }) + + test('modifier-click (metaKey) is ignored', async () => { + const { backSpy } = await setupAtAbout() + await act(() => + fireEvent.click(screen.getByTestId('preferback-home'), { + metaKey: true, + }), + ) + expect(backSpy).not.toHaveBeenCalled() + }) + + test('a user onClick calling preventDefault suppresses back navigation', async () => { + const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault()) + + const rootRoute = createRootRoute({ + component: () => ( + <> + About + + Back home + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

AboutTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + render() + await screen.findByText('IndexTitle') + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + + const backSpy = vi.spyOn(router.history, 'back') + await act(() => fireEvent.click(screen.getByTestId('preferback-home'))) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(backSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..9b8b748595 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -285,6 +285,7 @@ export type { ClearCacheFn, CreateRouterFn, SSROption, + RouterHistoryEntry, } from './router' export * from './config' diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 55d5a79ce8..1bde3e8308 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -691,6 +691,28 @@ export interface LinkOptionsProps { * If the user exits this proximity before this delay, the preload will be cancelled. */ preloadIntentProximity?: number + /** + * Makes the link history-aware: when its resolved target matches the previous + * history entry, clicking it calls `history.back()` instead of pushing a new + * entry. This preserves forward history and the browser's native per-entry + * scroll restoration (typical for "Back to X" links). + * + * Controls how the target is matched against the previous entry: + * - `false` (default) — disabled; behaves like a normal link. + * - `true` / `'pathname'` — match by **pathname** only, so a plain + * `` pops back to the previous `/x` entry and + * restores its exact search params and scroll position. + * - `'exact'` — match by **pathname + search**, so the link only goes back + * when its resolved search also equals the previous entry's. + * + * It is best-effort: the link falls back to a normal push (or replace, if `replace` + * is also set) when the target does not match the previous entry, or when the + * previous entry is unknown to the router (e.g. a fresh page load or deep link). + * The element always renders a real `
`, so keyboard navigation, "copy + * link", and middle/modifier-click (open in new tab) keep working. + * @default false + */ + preferBack?: boolean | 'pathname' | 'exact' } export type LinkOptions< diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..7b384b8c62 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -956,6 +956,23 @@ declare global { | undefined } +/** + * A snapshot of a history entry the router has visited, keyed by its + * `__TSR_index`. Used to reconstruct navigation direction (e.g. whether a + * link's target is the previous entry) since the browser only exposes the + * current entry. + */ +export interface RouterHistoryEntry { + /** The entry's `__TSR_index` in the history stack. */ + index: number + /** The parsed (basepath-stripped) pathname, comparable to `buildLocation().pathname`. */ + pathname: string + /** The search string including the leading `?`, comparable to `buildLocation().searchStr`. */ + searchStr: string + /** The full href (pathname + search + hash), without origin. */ + href: string +} + /** * Core, framework-agnostic router engine that powers TanStack Router. * @@ -1007,6 +1024,16 @@ export class RouterCore< origin?: string latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> + /** + * In-memory map of visited history entries keyed by their `__TSR_index`. + * + * The browser only ever exposes the *current* history entry, so to answer + * "what was the previous entry?" (used by history-aware links via + * {@link getHistoryEntry}) the router records every location it commits, + * keyed by index. Maintained from {@link updateLatestLocation}, the single + * place `latestLocation` is recomputed from history. + */ + historyEntries = new Map() basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -1238,8 +1265,35 @@ export class RouterCore< this.history.location, this.latestLocation, ) + + // Record the committed entry by its history index. `replace` keeps the same + // index, so it overwrites the slot in place; a push after going back reuses + // the truncated forward slot and overwrites it on the next call. The + // back-navigation decision only ever reads `index - 1` (always the true + // previous entry on the current linear branch), so stale higher indices + // left behind by truncation are harmless and need no pruning. We store the + // parsed (basepath-stripped) pathname so it compares directly against + // `buildLocation().pathname`. + const index = this.latestLocation.state.__TSR_index ?? 0 + this.historyEntries.set(index, { + index, + pathname: this.latestLocation.pathname, + searchStr: this.latestLocation.searchStr, + href: this.latestLocation.href, + }) } + /** + * Look up a previously-visited history entry by its `__TSR_index`. + * + * Returns `undefined` for entries the router has not seen — e.g. entries that + * existed before the app loaded (a fresh page load or deep link). Consumers + * such as history-aware links should degrade gracefully when this is + * `undefined`. + */ + getHistoryEntry = (index: number): RouterHistoryEntry | undefined => + this.historyEntries.get(index) + buildRouteTree = () => { const result = processRouteTree( this.routeTree, diff --git a/packages/router-core/tests/historyEntries.test.ts b/packages/router-core/tests/historyEntries.test.ts new file mode 100644 index 0000000000..78ec7552ae --- /dev/null +++ b/packages/router-core/tests/historyEntries.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +function setup(initialEntries: Array = ['/']) { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: search.page as number | undefined, + }), + }) + const contactRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/contact', + }) + const replacedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/replaced', + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + aboutRoute, + contactRoute, + replacedRoute, + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries }), + }) + + return router +} + +describe('router.getHistoryEntry - per-index entry tracking', () => { + test('records each visited entry keyed by its __TSR_index', async () => { + const router = setup() + await router.load() + + expect(router.getHistoryEntry(0)).toMatchObject({ index: 0, pathname: '/' }) + + await router.navigate({ to: '/about' }) + await router.navigate({ to: '/contact' }) + + expect(router.getHistoryEntry(0)).toMatchObject({ pathname: '/' }) + expect(router.getHistoryEntry(1)).toMatchObject({ index: 1, pathname: '/about' }) + expect(router.getHistoryEntry(2)).toMatchObject({ index: 2, pathname: '/contact' }) + }) + + test('replace reuses the same index and overwrites the entry', async () => { + const router = setup() + await router.load() + + await router.navigate({ to: '/about' }) + expect(router.getHistoryEntry(1)).toMatchObject({ pathname: '/about' }) + + await router.navigate({ to: '/replaced', replace: true }) + + // Same index, updated pathname — replace does not create a new slot. + expect(router.latestLocation.state.__TSR_index).toBe(1) + expect(router.getHistoryEntry(1)).toMatchObject({ index: 1, pathname: '/replaced' }) + expect(router.getHistoryEntry(2)).toBeUndefined() + }) + + test('start of history has no previous entry', async () => { + const router = setup() + await router.load() + + expect(router.latestLocation.state.__TSR_index).toBe(0) + expect(router.getHistoryEntry(-1)).toBeUndefined() + }) + + test('unknown / unvisited indices return undefined', async () => { + const router = setup() + await router.load() + + expect(router.getHistoryEntry(99)).toBeUndefined() + expect(router.getHistoryEntry(1)).toBeUndefined() + }) + + test('records the search string alongside the pathname', async () => { + const router = setup() + await router.load() + + await router.navigate({ to: '/about', search: { page: 3 } }) + + const entry = router.getHistoryEntry(1) + expect(entry).toMatchObject({ pathname: '/about' }) + expect(entry?.searchStr).toBe('?page=3') + }) + + test('the previous entry reflects the entry the user came from', async () => { + const router = setup() + await router.load() + await router.navigate({ to: '/about' }) + await router.navigate({ to: '/contact' }) + + const currentIndex = router.latestLocation.state.__TSR_index + expect(currentIndex).toBe(2) + // Going back from /contact should land on /about (index 1). + expect(router.getHistoryEntry(currentIndex - 1)).toMatchObject({ + pathname: '/about', + }) + }) +}) From be7f0c701b6859e03069a3b28822ca0e5dfa99d9 Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 03:23:02 +0300 Subject: [PATCH 2/4] add example --- docs/router/guide/navigation.md | 2 +- examples/react/basic/src/main.tsx | 8 ++++ examples/react/basic/src/posts.ts | 2 +- packages/react-router/src/link.tsx | 12 ++---- .../react-router/src/useIsBackNavigation.ts | 29 +++++-------- packages/router-core/src/link.ts | 21 ++-------- packages/router-core/src/router.ts | 42 +++++++------------ 7 files changed, 41 insertions(+), 75 deletions(-) diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index 80ca808720..a350530381 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -133,7 +133,7 @@ export type LinkOptions< preloadDelay?: number // If true, will render the link without the href attribute disabled?: boolean - // If true, clicking goes back via `history.back()` when the resolved target is the previous history entry (see "Direction-aware links" below) + // If true, clicking goes back via `history.back()` when the resolved target is the previous history entry (see "History-aware links" below) preferBack?: boolean } ``` diff --git a/examples/react/basic/src/main.tsx b/examples/react/basic/src/main.tsx index ca6c41b709..c5ca5f8c8f 100644 --- a/examples/react/basic/src/main.tsx +++ b/examples/react/basic/src/main.tsx @@ -123,6 +123,14 @@ function PostComponent() {

{post.title}


{post.body}
+ + + ← Back to posts + ) } diff --git a/examples/react/basic/src/posts.ts b/examples/react/basic/src/posts.ts index 54d62e5788..baa70a7f96 100644 --- a/examples/react/basic/src/posts.ts +++ b/examples/react/basic/src/posts.ts @@ -13,7 +13,7 @@ export const fetchPosts = async () => { await new Promise((r) => setTimeout(r, 500)) return axios .get>('https://jsonplaceholder.typicode.com/posts') - .then((r) => r.data.slice(0, 10)) + .then((r) => r.data.slice(0, 50)) } export const fetchPost = async (postId: string) => { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 2ac218c969..a1229028c0 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -414,10 +414,8 @@ export function useLinkProps< return router.buildLocation(opts as any) }, [router, currentLocation, _options]) - // History-aware links: when `preferBack` is set and the resolved target is - // the previous history entry, a primary click should go back rather than push. - // `true`/`'pathname'` match by pathname; `'exact'` also requires search. - // Reuses the already-built `next`, so no extra `buildLocation` call. + // History-aware: go back instead of pushing when the target is the previous + // entry. `'exact'` also matches search. Reuses `next`, so no extra buildLocation. const isBackNavigation = !!preferBack && resolveIsBackNavigation( @@ -641,10 +639,8 @@ export function useLinkProps< setIsTransitioning(false) }) - // History-aware: the target is the previous history entry, so go back - // instead of pushing. This preserves forward history and the browser's - // native per-entry scroll restoration. Scroll/viewTransition behavior is - // handled by the router's existing popstate handling. + // Target is the previous entry — pop instead of pushing to preserve forward + // history and native scroll restoration (handled via the router's popstate). if (isBackNavigation) { router.history.back({ ignoreBlocker }) return diff --git a/packages/react-router/src/useIsBackNavigation.ts b/packages/react-router/src/useIsBackNavigation.ts index 93ecb04210..7073503e15 100644 --- a/packages/react-router/src/useIsBackNavigation.ts +++ b/packages/react-router/src/useIsBackNavigation.ts @@ -18,13 +18,9 @@ import type { export type BackNavigationMatch = 'pathname' | 'exact' /** - * Decide whether navigating to `target` would land on the *previous* history - * entry, meaning a `history.back()` is preferable to a push. - * - * Pure and framework-agnostic: it relies only on the router's in-memory - * per-index entry tracking (`router.getHistoryEntry`). Returns `false` at the - * start of history (index 0) and when the previous entry is unknown (e.g. a - * fresh page load or deep link), so callers degrade gracefully to a normal push. + * Whether navigating to `target` lands on the previous history entry (so a + * `history.back()` is preferable to a push). Returns `false` at index 0 or when + * the previous entry is unknown, so callers degrade gracefully to a push. */ export function resolveIsBackNavigation( router: AnyRouter, @@ -41,22 +37,17 @@ export function resolveIsBackNavigation( } /** - * Returns `true` when a link/navigation to the given options would resolve to - * the *previous* history entry — i.e. clicking it should go "back" rather than - * push a new entry. - * - * This is the primitive behind the `preferBack` prop on `Link`. Use it directly - * when building custom links or buttons that want the same history-aware - * behavior. It returns `false` on the server and falls back to `false` whenever - * the previous entry is unknown to the router, so it is always safe to branch on. + * Returns `true` when navigating to the given options would resolve to the + * previous history entry — i.e. it should go "back" rather than push. The + * primitive behind `preferBack`, for use in custom links or buttons. * - * Pass `match: 'exact'` to also require the search to match (defaults to - * `'pathname'`, which restores the previous entry's exact search and scroll). + * Returns `false` on the server and whenever the previous entry is unknown, so + * it's always safe to branch on. Pass `match: 'exact'` to also require search to + * match (defaults to `'pathname'`). * * @example * const isBack = useIsBackNavigation({ to: '/issues' }) - * // isBack === true -> call router.history.back() - * // isBack === false -> navigate normally + * // isBack ? router.history.back() : router.navigate({ to: '/issues' }) */ export function useIsBackNavigation< TRouter extends AnyRouter = RegisteredRouter, diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 1bde3e8308..1fb018a466 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -692,24 +692,9 @@ export interface LinkOptionsProps { */ preloadIntentProximity?: number /** - * Makes the link history-aware: when its resolved target matches the previous - * history entry, clicking it calls `history.back()` instead of pushing a new - * entry. This preserves forward history and the browser's native per-entry - * scroll restoration (typical for "Back to X" links). - * - * Controls how the target is matched against the previous entry: - * - `false` (default) — disabled; behaves like a normal link. - * - `true` / `'pathname'` — match by **pathname** only, so a plain - * `` pops back to the previous `/x` entry and - * restores its exact search params and scroll position. - * - `'exact'` — match by **pathname + search**, so the link only goes back - * when its resolved search also equals the previous entry's. - * - * It is best-effort: the link falls back to a normal push (or replace, if `replace` - * is also set) when the target does not match the previous entry, or when the - * previous entry is unknown to the router (e.g. a fresh page load or deep link). - * The element always renders a real `
`, so keyboard navigation, "copy - * link", and middle/modifier-click (open in new tab) keep working. + * Makes the link history-aware: when its target is the previous history entry, + * clicking goes back instead of pushing (preserving forward history + scroll). + * `'exact'` also requires search to match; falls back to a normal push otherwise. * @default false */ preferBack?: boolean | 'pathname' | 'exact' diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7b384b8c62..ccad08539b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -957,19 +957,17 @@ declare global { } /** - * A snapshot of a history entry the router has visited, keyed by its - * `__TSR_index`. Used to reconstruct navigation direction (e.g. whether a - * link's target is the previous entry) since the browser only exposes the - * current entry. + * A visited history entry keyed by its `__TSR_index`, used to resolve navigation + * direction (the browser only exposes the current entry). */ export interface RouterHistoryEntry { /** The entry's `__TSR_index` in the history stack. */ index: number - /** The parsed (basepath-stripped) pathname, comparable to `buildLocation().pathname`. */ + /** Parsed (basepath-stripped) pathname, comparable to `buildLocation().pathname`. */ pathname: string - /** The search string including the leading `?`, comparable to `buildLocation().searchStr`. */ + /** Search string incl. leading `?`, comparable to `buildLocation().searchStr`. */ searchStr: string - /** The full href (pathname + search + hash), without origin. */ + /** Full href (pathname + search + hash), without origin. */ href: string } @@ -1025,13 +1023,9 @@ export class RouterCore< latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> /** - * In-memory map of visited history entries keyed by their `__TSR_index`. - * - * The browser only ever exposes the *current* history entry, so to answer - * "what was the previous entry?" (used by history-aware links via - * {@link getHistoryEntry}) the router records every location it commits, - * keyed by index. Maintained from {@link updateLatestLocation}, the single - * place `latestLocation` is recomputed from history. + * Visited history entries keyed by `__TSR_index` (recorded in + * {@link updateLatestLocation}), so the router can resolve the previous entry — + * which the browser doesn't expose. Read via {@link getHistoryEntry}. */ historyEntries = new Map() basepath!: string @@ -1266,14 +1260,9 @@ export class RouterCore< this.latestLocation, ) - // Record the committed entry by its history index. `replace` keeps the same - // index, so it overwrites the slot in place; a push after going back reuses - // the truncated forward slot and overwrites it on the next call. The - // back-navigation decision only ever reads `index - 1` (always the true - // previous entry on the current linear branch), so stale higher indices - // left behind by truncation are harmless and need no pruning. We store the - // parsed (basepath-stripped) pathname so it compares directly against - // `buildLocation().pathname`. + // Record the committed entry by index. `replace` overwrites the same index; + // truncated forward entries are harmless since the back-decision only reads + // `index - 1`. Stored pathname/search compare directly with buildLocation(). const index = this.latestLocation.state.__TSR_index ?? 0 this.historyEntries.set(index, { index, @@ -1284,12 +1273,9 @@ export class RouterCore< } /** - * Look up a previously-visited history entry by its `__TSR_index`. - * - * Returns `undefined` for entries the router has not seen — e.g. entries that - * existed before the app loaded (a fresh page load or deep link). Consumers - * such as history-aware links should degrade gracefully when this is - * `undefined`. + * Look up a visited history entry by its `__TSR_index`. Returns `undefined` for + * entries the router never recorded (e.g. a fresh page load or deep link), so + * consumers should degrade gracefully. */ getHistoryEntry = (index: number): RouterHistoryEntry | undefined => this.historyEntries.get(index) From e9fb62df0d1dea4af96810f495758544d70c9c30 Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 13:00:40 +0300 Subject: [PATCH 3/4] fix(react-router): carry viewTransition on preferBack back-nav; memoize hook options - preferBack: when the back path is taken, forward the link's `viewTransition` intent to the popstate-driven navigation (set `router.shouldViewTransition` before `history.back()`), so a `viewTransition` link behaves consistently on both its push and pop paths instead of only honoring `defaultViewTransition`. - useIsBackNavigation: memoize the navigation options on their primitive fields (mirroring `useLinkProps`) so `buildLocation` no longer re-runs on every render when callers pass a fresh inline options object. - Add a test asserting the link's `viewTransition` is set when going back. --- packages/react-router/src/link.tsx | 3 +++ .../react-router/src/useIsBackNavigation.ts | 22 +++++++++++++++++-- .../tests/useIsBackNavigation.test.tsx | 22 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index a1229028c0..fc5c3e3923 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -642,6 +642,9 @@ export function useLinkProps< // Target is the previous entry — pop instead of pushing to preserve forward // history and native scroll restoration (handled via the router's popstate). if (isBackNavigation) { + if (viewTransition !== undefined) { + router.shouldViewTransition = viewTransition + } router.history.back({ ignoreBlocker }) return } diff --git a/packages/react-router/src/useIsBackNavigation.ts b/packages/react-router/src/useIsBackNavigation.ts index 7073503e15..d691a40f04 100644 --- a/packages/react-router/src/useIsBackNavigation.ts +++ b/packages/react-router/src/useIsBackNavigation.ts @@ -74,12 +74,30 @@ export function useIsBackNavigation< (prev, next) => prev.href === next.href, ) + // eslint-disable-next-line react-hooks/rules-of-hooks + const _options = React.useMemo( + () => options, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + router, + options.from, + options._fromLocation, + options.hash, + options.to, + options.search, + options.params, + options.state, + options.mask, + options.unsafeRelative, + ], + ) + // eslint-disable-next-line react-hooks/rules-of-hooks return React.useMemo(() => { const next = router.buildLocation({ _fromLocation: currentLocation, - ...options, + ..._options, } as any) return resolveIsBackNavigation(router, currentLocation, next, match) - }, [router, currentLocation, options, match]) + }, [router, currentLocation, _options, match]) } diff --git a/packages/react-router/tests/useIsBackNavigation.test.tsx b/packages/react-router/tests/useIsBackNavigation.test.tsx index 45b856b03b..d16a7bea2c 100644 --- a/packages/react-router/tests/useIsBackNavigation.test.tsx +++ b/packages/react-router/tests/useIsBackNavigation.test.tsx @@ -43,6 +43,9 @@ function RootComponent() { Prefer contact + + Back home (view transition) + @@ -170,6 +173,25 @@ describe('Link preferBack', () => { expect(router.history.location.state.__TSR_index).toBe(2) }) + test('carries the link\'s viewTransition intent onto the back navigation', async () => { + const { router } = setup(['/']) + await screen.findByText('IndexTitle') + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + + const origBack = router.history.back.bind(router.history) + let vtAtBack: unknown = 'unset' + vi.spyOn(router.history, 'back').mockImplementation((opts) => { + vtAtBack = router.shouldViewTransition + return origBack(opts) + }) + + await act(() => fireEvent.click(screen.getByTestId('preferback-home-vt'))) + await screen.findByText('IndexTitle') + + expect(vtAtBack).toBe(true) + }) + describe('match modes (pathname vs exact)', () => { function ModeProbe() { const exactMatch = useIsBackNavigation({ to: '/about', search: { page: 3 } }, 'exact') From be42371983eed19a5538f978b02d68aa86fada6c Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 13:16:56 +0300 Subject: [PATCH 4/4] address coderabbit comments --- docs/router/guide/navigation.md | 4 +-- packages/react-router/src/link.tsx | 5 ++- .../react-router/src/useIsBackNavigation.ts | 4 ++- .../tests/useIsBackNavigation.test.tsx | 31 +++++++++++++++++++ packages/router-core/src/link.ts | 2 +- 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index a350530381..952fcba20c 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -133,8 +133,8 @@ export type LinkOptions< preloadDelay?: number // If true, will render the link without the href attribute disabled?: boolean - // If true, clicking goes back via `history.back()` when the resolved target is the previous history entry (see "History-aware links" below) - preferBack?: boolean + // History-aware back navigation when the resolved target is the previous history entry (see "History-aware links" below). `true`/`'pathname'` match by pathname; `'exact'` also requires search to match. + preferBack?: boolean | 'pathname' | 'exact' } ``` diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index fc5c3e3923..828d959f10 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -405,7 +405,9 @@ export function useLinkProps< const currentLocation = useStore( router.stores.location, (l) => l, - (prev, next) => prev.href === next.href, + (prev, next) => + prev.href === next.href && + prev.state.__TSR_index === next.state.__TSR_index, ) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -418,6 +420,7 @@ export function useLinkProps< // entry. `'exact'` also matches search. Reuses `next`, so no extra buildLocation. const isBackNavigation = !!preferBack && + !_reloadDocument && resolveIsBackNavigation( router, currentLocation, diff --git a/packages/react-router/src/useIsBackNavigation.ts b/packages/react-router/src/useIsBackNavigation.ts index d691a40f04..6a163b0b1d 100644 --- a/packages/react-router/src/useIsBackNavigation.ts +++ b/packages/react-router/src/useIsBackNavigation.ts @@ -71,7 +71,9 @@ export function useIsBackNavigation< const currentLocation = useStore( router.stores.location, (l) => l, - (prev, next) => prev.href === next.href, + (prev, next) => + prev.href === next.href && + prev.state.__TSR_index === next.state.__TSR_index, ) // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/tests/useIsBackNavigation.test.tsx b/packages/react-router/tests/useIsBackNavigation.test.tsx index d16a7bea2c..70d1fe129c 100644 --- a/packages/react-router/tests/useIsBackNavigation.test.tsx +++ b/packages/react-router/tests/useIsBackNavigation.test.tsx @@ -46,6 +46,14 @@ function RootComponent() { Back home (view transition) + + Back home (reload) + @@ -192,6 +200,29 @@ describe('Link preferBack', () => { expect(vtAtBack).toBe(true) }) + test('reloadDocument takes precedence: no back shortcut, full navigate', async () => { + const { router } = setup(['/']) + await screen.findByText('IndexTitle') + await act(() => fireEvent.click(screen.getByText('About'))) + await screen.findByText('AboutTitle') + + const backSpy = vi.spyOn(router.history, 'back') + // Stub navigate so the full-document reload path doesn't touch window.location. + const navigateSpy = vi + .spyOn(router, 'navigate') + .mockResolvedValue(undefined) + + await act(() => + fireEvent.click(screen.getByTestId('preferback-home-reload')), + ) + + // The back shortcut is skipped; reloadDocument flows through to navigate. + expect(backSpy).not.toHaveBeenCalled() + expect(navigateSpy).toHaveBeenCalledWith( + expect.objectContaining({ reloadDocument: true }), + ) + }) + describe('match modes (pathname vs exact)', () => { function ModeProbe() { const exactMatch = useIsBackNavigation({ to: '/about', search: { page: 3 } }, 'exact') diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 1fb018a466..3e9a6034c5 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -694,7 +694,7 @@ export interface LinkOptionsProps { /** * Makes the link history-aware: when its target is the previous history entry, * clicking goes back instead of pushing (preserving forward history + scroll). - * `'exact'` also requires search to match; falls back to a normal push otherwise. + * `'exact'` also requires search to match; otherwise falls back to a normal navigation * @default false */ preferBack?: boolean | 'pathname' | 'exact'