Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/direction-aware-links-prefer-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@tanstack/router-core': minor
'@tanstack/react-router': minor
---

feat: history-aware links via a `preferBack` prop on `Link`

`<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.

`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.
1 change: 1 addition & 0 deletions docs/router/api/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions docs/router/api/router/useIsBackNavigation.md
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={() =>
isBack ? router.history.back() : router.navigate({ to: '/issues' })
}
>
Back to issues
</button>
)
}
```

For the common case, prefer the `preferBack` prop on `Link`, which renders a real `<a>` and wires this up for you:

```tsx
<Link to="/issues" preferBack>
Back to issues
</Link>
```
18 changes: 18 additions & 0 deletions docs/router/guide/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export type LinkOptions<
preloadDelay?: number
// If true, will render the link without the href attribute
disabled?: 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'
}
```

Expand Down Expand Up @@ -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 `<a href>`, so keyboard nav, "copy link", and middle/modifier-click keep working.

```tsx
<Link to="/issues" preferBack>
Back to issues
</Link>
```

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).
Expand Down
8 changes: 8 additions & 0 deletions examples/react/basic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ function PostComponent() {
<h4 className="text-xl font-bold">{post.title}</h4>
<hr className="opacity-20" />
<div className="text-sm">{post.body}</div>

<Link
to="/posts"
preferBack
className="py-1 px-2 rounded bg-green-600 text-white hover:opacity-90"
>
← Back to posts
</Link>
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion examples/react/basic/src/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const fetchPosts = async () => {
await new Promise((r) => setTimeout(r, 500))
return axios
.get<Array<PostType>>('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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
28 changes: 27 additions & 1 deletion packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,6 +74,7 @@ export function useLinkProps<
startTransition,
resetScroll,
viewTransition,
preferBack,
// element props
children,
target,
Expand Down Expand Up @@ -403,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
Expand All @@ -412,6 +416,18 @@ export function useLinkProps<
return router.buildLocation(opts as any)
}, [router, currentLocation, _options])

// 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 &&
!_reloadDocument &&
resolveIsBackNavigation(
router,
currentLocation,
next,
preferBack === 'exact' ? 'exact' : 'pathname',
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -626,6 +642,16 @@ export function useLinkProps<
setIsTransitioning(false)
})

// 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
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// All is well? Navigate!
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
router.navigate({
Expand Down
105 changes: 105 additions & 0 deletions packages/react-router/src/useIsBackNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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'

/**
* 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,
currentLocation: ParsedLocation,
target: Pick<ParsedLocation, 'pathname' | 'searchStr'>,
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 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.
*
* 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 ? router.history.back() : router.navigate({ to: '/issues' })
*/
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<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
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 &&
prev.state.__TSR_index === next.state.__TSR_index,
)

// 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,
],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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])
}
Loading