-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(react-router): history-aware links via preferBack prop on Link
#7700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
KurtGokhan
wants to merge
4
commits into
TanStack:main
Choose a base branch
from
KurtGokhan:feat/prefer-back-links
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+799
−2
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
fa652cd
feat(react-router): history-aware links via `preferBack` prop on `Link`
KurtGokhan be7f0c7
add example
KurtGokhan e9fb62d
fix(react-router): carry viewTransition on preferBack back-nav; memoi…
KurtGokhan be42371
address coderabbit comments
KurtGokhan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ], | ||
| ) | ||
|
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]) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.