Skip to content

Commit fa652cd

Browse files
committed
feat(react-router): history-aware links via preferBack prop on Link
`<Link to="/x" preferBack>` 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 `<a href>`, 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: #7699
1 parent bb2daa6 commit fa652cd

12 files changed

Lines changed: 752 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@tanstack/router-core': minor
3+
'@tanstack/react-router': minor
4+
---
5+
6+
feat: history-aware links via a `preferBack` prop on `Link`
7+
8+
`<Link to="/x" preferBack>` 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 `<a href>`, so keyboard nav, "copy link", and middle/modifier-click keep working.
9+
10+
`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.
11+
12+
New public APIs:
13+
14+
- `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'`.
15+
- `router.getHistoryEntry(index)` (router-core) — read a visited history entry by its `__TSR_index` from the router's in-memory per-index tracking.

docs/router/api/router.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ title: Router API
3939
- [`useBlocker`](./router/useBlockerHook.md)
4040
- [`useCanGoBack`](./router/useCanGoBack.md)
4141
- [`useChildMatches`](./router/useChildMatchesHook.md)
42+
- [`useIsBackNavigation`](./router/useIsBackNavigation.md)
4243
- [`useLinkProps`](./router/useLinkPropsHook.md)
4344
- [`useLoaderData`](./router/useLoaderDataHook.md)
4445
- [`useLoaderDeps`](./router/useLoaderDepsHook.md)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
id: useIsBackNavigation
3+
title: useIsBackNavigation hook
4+
---
5+
6+
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.
7+
8+
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.
9+
10+
> ⚠️ The following `useIsBackNavigation` API is currently _experimental_.
11+
12+
## useIsBackNavigation options
13+
14+
The `useIsBackNavigation` hook accepts the same navigation options as `Link`/`useNavigate` (`to`, `params`, `search`, `hash`, `from`, etc.) as its first argument.
15+
16+
The optional second argument is the **match mode**:
17+
18+
- `'pathname'` (default) — match by pathname only, so going back restores the previous entry's exact search params and scroll position.
19+
- `'exact'` — match by pathname **and** search.
20+
21+
## useIsBackNavigation returns
22+
23+
- `true` if the resolved target's pathname equals the previous history entry's pathname.
24+
- `false` otherwise, including:
25+
- when the router is at history index `0` (nothing behind it),
26+
- 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),
27+
- on the server.
28+
29+
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".
30+
31+
## How it works
32+
33+
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).
34+
35+
## Examples
36+
37+
```tsx
38+
import { useRouter, useIsBackNavigation } from '@tanstack/react-router'
39+
40+
function BackToIssues() {
41+
const router = useRouter()
42+
const isBack = useIsBackNavigation({ to: '/issues' })
43+
44+
return (
45+
<button
46+
onClick={() =>
47+
isBack ? router.history.back() : router.navigate({ to: '/issues' })
48+
}
49+
>
50+
Back to issues
51+
</button>
52+
)
53+
}
54+
```
55+
56+
For the common case, prefer the `preferBack` prop on `Link`, which renders a real `<a>` and wires this up for you:
57+
58+
```tsx
59+
<Link to="/issues" preferBack>
60+
Back to issues
61+
</Link>
62+
```

docs/router/guide/navigation.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export type LinkOptions<
133133
preloadDelay?: number
134134
// If true, will render the link without the href attribute
135135
disabled?: boolean
136+
// If true, clicking goes back via `history.back()` when the resolved target is the previous history entry (see "Direction-aware links" below)
137+
preferBack?: boolean
136138
}
137139
```
138140
@@ -733,6 +735,22 @@ const link = (
733735
)
734736
```
735737

738+
### History-aware links (`preferBack`)
739+
740+
> ⚠️ The `preferBack` prop and the `useIsBackNavigation` hook are currently _experimental_.
741+
742+
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.
743+
744+
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 `<a href>`, so keyboard nav, "copy link", and middle/modifier-click keep working.
745+
746+
```tsx
747+
<Link to="/issues" preferBack>
748+
Back to issues
749+
</Link>
750+
```
751+
752+
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.
753+
736754
## `useNavigate`
737755

738756
> ⚠️ 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).

packages/react-router/src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ export { useRouter } from './useRouter'
298298
export { useRouterState } from './useRouterState'
299299
export { useLocation } from './useLocation'
300300
export { useCanGoBack } from './useCanGoBack'
301+
export { useIsBackNavigation } from './useIsBackNavigation'
302+
export type { BackNavigationMatch } from './useIsBackNavigation'
301303

302304
export { CatchNotFound, DefaultGlobalNotFound } from './not-found'
303305
export { notFound, isNotFound } from '@tanstack/router-core'

packages/react-router/src/link.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useRouter } from './useRouter'
1818
import { useForwardedRef, useIntersectionObserver } from './utils'
1919

2020
import { useHydrated } from './ClientOnly'
21+
import { resolveIsBackNavigation } from './useIsBackNavigation'
2122
import type {
2223
AnyRouter,
2324
Constrain,
@@ -73,6 +74,7 @@ export function useLinkProps<
7374
startTransition,
7475
resetScroll,
7576
viewTransition,
77+
preferBack,
7678
// element props
7779
children,
7880
target,
@@ -412,6 +414,19 @@ export function useLinkProps<
412414
return router.buildLocation(opts as any)
413415
}, [router, currentLocation, _options])
414416

417+
// History-aware links: when `preferBack` is set and the resolved target is
418+
// the previous history entry, a primary click should go back rather than push.
419+
// `true`/`'pathname'` match by pathname; `'exact'` also requires search.
420+
// Reuses the already-built `next`, so no extra `buildLocation` call.
421+
const isBackNavigation =
422+
!!preferBack &&
423+
resolveIsBackNavigation(
424+
router,
425+
currentLocation,
426+
next,
427+
preferBack === 'exact' ? 'exact' : 'pathname',
428+
)
429+
415430
// Use publicHref - it contains the correct href for display
416431
// When a rewrite changes the origin, publicHref is the full URL
417432
// Otherwise it's the origin-stripped path
@@ -626,6 +641,15 @@ export function useLinkProps<
626641
setIsTransitioning(false)
627642
})
628643

644+
// History-aware: the target is the previous history entry, so go back
645+
// instead of pushing. This preserves forward history and the browser's
646+
// native per-entry scroll restoration. Scroll/viewTransition behavior is
647+
// handled by the router's existing popstate handling.
648+
if (isBackNavigation) {
649+
router.history.back({ ignoreBlocker })
650+
return
651+
}
652+
629653
// All is well? Navigate!
630654
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
631655
router.navigate({
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as React from 'react'
2+
import { useStore } from '@tanstack/react-store'
3+
import { isServer } from '@tanstack/router-core/isServer'
4+
import { useRouter } from './useRouter'
5+
import type {
6+
AnyRouter,
7+
LinkOptions,
8+
ParsedLocation,
9+
RegisteredRouter,
10+
} from '@tanstack/router-core'
11+
12+
/**
13+
* How a target is matched against the previous history entry:
14+
* - `'pathname'` — match by pathname only (so going back restores the previous
15+
* entry's exact search params and scroll position).
16+
* - `'exact'` — match by pathname **and** search.
17+
*/
18+
export type BackNavigationMatch = 'pathname' | 'exact'
19+
20+
/**
21+
* Decide whether navigating to `target` would land on the *previous* history
22+
* entry, meaning a `history.back()` is preferable to a push.
23+
*
24+
* Pure and framework-agnostic: it relies only on the router's in-memory
25+
* per-index entry tracking (`router.getHistoryEntry`). Returns `false` at the
26+
* start of history (index 0) and when the previous entry is unknown (e.g. a
27+
* fresh page load or deep link), so callers degrade gracefully to a normal push.
28+
*/
29+
export function resolveIsBackNavigation(
30+
router: AnyRouter,
31+
currentLocation: ParsedLocation,
32+
target: Pick<ParsedLocation, 'pathname' | 'searchStr'>,
33+
match: BackNavigationMatch = 'pathname',
34+
): boolean {
35+
const currentIndex = currentLocation.state.__TSR_index ?? 0
36+
if (currentIndex === 0) return false
37+
const previous = router.getHistoryEntry(currentIndex - 1)
38+
if (!previous || previous.pathname !== target.pathname) return false
39+
if (match === 'exact') return previous.searchStr === target.searchStr
40+
return true
41+
}
42+
43+
/**
44+
* Returns `true` when a link/navigation to the given options would resolve to
45+
* the *previous* history entry — i.e. clicking it should go "back" rather than
46+
* push a new entry.
47+
*
48+
* This is the primitive behind the `preferBack` prop on `Link`. Use it directly
49+
* when building custom links or buttons that want the same history-aware
50+
* behavior. It returns `false` on the server and falls back to `false` whenever
51+
* the previous entry is unknown to the router, so it is always safe to branch on.
52+
*
53+
* Pass `match: 'exact'` to also require the search to match (defaults to
54+
* `'pathname'`, which restores the previous entry's exact search and scroll).
55+
*
56+
* @example
57+
* const isBack = useIsBackNavigation({ to: '/issues' })
58+
* // isBack === true -> call router.history.back()
59+
* // isBack === false -> navigate normally
60+
*/
61+
export function useIsBackNavigation<
62+
TRouter extends AnyRouter = RegisteredRouter,
63+
const TFrom extends string = string,
64+
const TTo extends string | undefined = undefined,
65+
const TMaskFrom extends string = TFrom,
66+
const TMaskTo extends string = '',
67+
>(
68+
options: LinkOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
69+
match: BackNavigationMatch = 'pathname',
70+
): boolean {
71+
const router = useRouter()
72+
73+
// On the server there is no client-side history to pop, and the previous
74+
// entry is never known, so back-navigation is always false.
75+
if (isServer ?? router.isServer) {
76+
return false
77+
}
78+
79+
// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static (compile-time `isServer`)
80+
const currentLocation = useStore(
81+
router.stores.location,
82+
(l) => l,
83+
(prev, next) => prev.href === next.href,
84+
)
85+
86+
// eslint-disable-next-line react-hooks/rules-of-hooks
87+
return React.useMemo(() => {
88+
const next = router.buildLocation({
89+
_fromLocation: currentLocation,
90+
...options,
91+
} as any)
92+
return resolveIsBackNavigation(router, currentLocation, next, match)
93+
}, [router, currentLocation, options, match])
94+
}

0 commit comments

Comments
 (0)