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..952fcba20c 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 + // 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' } ``` @@ -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/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/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..828d959f10 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, @@ -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 @@ -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', + ) + // 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 +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 + } + // 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..6a163b0b1d --- /dev/null +++ b/packages/react-router/src/useIsBackNavigation.ts @@ -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, + 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, + 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, + ], + ) + + // 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..70d1fe129c --- /dev/null +++ b/packages/react-router/tests/useIsBackNavigation.test.tsx @@ -0,0 +1,402 @@ +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 + + + Back home (view transition) + + + Back home (reload) + + + + + ) +} + +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) + }) + + 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) + }) + + 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') + 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..3e9a6034c5 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -691,6 +691,13 @@ 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 target is the previous history entry, + * clicking goes back instead of pushing (preserving forward history + scroll). + * `'exact'` also requires search to match; otherwise falls back to a normal navigation + * @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..ccad08539b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -956,6 +956,21 @@ declare global { | undefined } +/** + * 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 + /** Parsed (basepath-stripped) pathname, comparable to `buildLocation().pathname`. */ + pathname: string + /** Search string incl. leading `?`, comparable to `buildLocation().searchStr`. */ + searchStr: string + /** Full href (pathname + search + hash), without origin. */ + href: string +} + /** * Core, framework-agnostic router engine that powers TanStack Router. * @@ -1007,6 +1022,12 @@ export class RouterCore< origin?: string latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> + /** + * 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 routeTree!: TRouteTree routesById!: RoutesById @@ -1238,8 +1259,27 @@ export class RouterCore< this.history.location, this.latestLocation, ) + + // 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, + pathname: this.latestLocation.pathname, + searchStr: this.latestLocation.searchStr, + href: this.latestLocation.href, + }) } + /** + * 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) + 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', + }) + }) +})