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
7 changes: 7 additions & 0 deletions .changeset/replay-view-transition-on-traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/router-core': patch
---

feat: add `replayViewTransitionOnTraversal` router option

Replays the view transition a navigation opted into (`<Link viewTransition>` / `navigate({ viewTransition })`) when the user later traverses that entry with the browser Back/Forward buttons. Without it, those per-navigation opt-ins are not replayed on traversal (Back/Forward fall back to `defaultViewTransition`, if set, or no transition). Replay is symmetric (`A→B` plays on both back and forward). Opt-in, kept in-memory so a functional `types` survives, and does not affect `defaultViewTransition`.
7 changes: 7 additions & 0 deletions docs/router/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ const router = createRouter({
- See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) for more information on how this function works.
- See [Google](https://developer.chrome.com/docs/web-platform/view-transitions/same-document#view-transition-types) for more information on viewTransition types

### `replayViewTransitionOnTraversal` property

- Type: `boolean`
- Optional, defaults to `false`
- If `true`, replays the view transition a navigation opted into (`<Link viewTransition>` / `navigate({ viewTransition })`) when the user later traverses that entry with the browser Back/Forward buttons. Without it, those per-navigation opt-ins are not replayed on traversal, so Back/Forward fall back to the router's normal behavior (`defaultViewTransition` if set, otherwise no transition). Replay is symmetric: a transition opted into on `A → B` plays on both `B → A` (back) and a later `A → B` (forward).
- Recorded values are kept in-memory (lost on hard reload, degrading to no transition) so a functional [`ViewTransitionOptions`](./ViewTransitionOptionsType.md) `types` callback survives. Opt-in; does not change `defaultViewTransition` behavior.

### `defaultHashScrollIntoView` property

- Type: `boolean | ScrollIntoViewOptions`
Expand Down
43 changes: 43 additions & 0 deletions examples/react/view-transitions/src/directionAwareTransition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ViewTransitionOptions } from '@tanstack/react-router'

/**
* A direction-aware view transition based on PAGE ORDER, not history position.
*
* `__TSR_index` only tracks the history stack, so a "Previous Page" link (a
* forward PUSH) increments it even though you're moving to an earlier page.
* Instead we rank pages by their place in the app's sequence and slide toward
* the later page — so the same logical move always animates the same way,
* whether reached by a link or by browser Back/Forward.
*
* `types` is a FUNCTION, so it re-resolves against each navigation's from/to;
* `replayViewTransitionOnTraversal` keeps it live by reference so Back/Forward
* recompute the correct direction.
*/
const PAGE_ORDER = ['/', '/how-it-works', '/explore', '/posts']

/** Rank a pathname within `PAGE_ORDER` using the longest matching prefix. */
function pageRank(pathname: string): number {
// longest matching prefix so e.g. /posts/123 ranks with /posts
let best = -1
let bestLen = -1
PAGE_ORDER.forEach((p, i) => {
const matches = p === '/' ? pathname === '/' : pathname.startsWith(p)
if (matches && p.length > bestLen) {
best = i
bestLen = p.length
}
})
return best
}

export const slideByDirection: ViewTransitionOptions = {
types: ({ fromLocation, toLocation }) => {
if (!fromLocation) {
return ['slide-left']
}
const from = pageRank(fromLocation.pathname)
const to = pageRank(toLocation.pathname)
// Moving to a later page slides left; to an earlier page slides right.
return [to >= from ? 'slide-left' : 'slide-right']
},
}
2 changes: 2 additions & 0 deletions examples/react/view-transitions/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const router = createRouter({
defaultPreload: 'intent',
defaultStaleTime: 5000,
scrollRestoration: true,
// Replay each navigation's view transition on browser Back/Forward (PR #7697)
replayViewTransitionOnTraversal: true,
/*
Using defaultViewTransition would prevent the need to
manually add `viewTransition: true` to every navigation.
Expand Down
5 changes: 3 additions & 2 deletions examples/react/view-transitions/src/routes/explore.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { slideByDirection } from '../directionAwareTransition'

export const Route = createFileRoute('/explore')({
component: RouteComponent,
Expand All @@ -18,8 +19,8 @@ function RouteComponent() {
<div className="flex justify-center gap-10 mt-4">
<Link
to={'/how-it-works'}
// see styles.css for 'slide-right' transition
viewTransition={{ types: ['slide-right'] }}
// direction-aware: slides right going back, left on browser Forward
viewTransition={slideByDirection}
className="font-bold"
>
&lt;- Previous Page
Expand Down
9 changes: 5 additions & 4 deletions examples/react/view-transitions/src/routes/how-it-works.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { slideByDirection } from '../directionAwareTransition'

export const Route = createFileRoute('/how-it-works')({
component: RouteComponent,
Expand All @@ -11,16 +12,16 @@ function RouteComponent() {
<div className="flex justify-center gap-10 mt-4">
<Link
to={'/'}
// see styles.css for 'slide-right' transition
viewTransition={{ types: ['slide-right'] }}
// direction-aware: slides right going back, left on browser Forward
viewTransition={slideByDirection}
className="font-bold"
>
&lt;- Previous Page
</Link>
<Link
to={'/explore'}
// see styles.css for 'slide-left' transition
viewTransition={{ types: ['slide-left'] }}
// direction-aware: slides left going forward, right on browser Back
viewTransition={slideByDirection}
className="font-bold"
>
Next Page -&gt;
Expand Down
5 changes: 3 additions & 2 deletions examples/react/view-transitions/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react'
import { Link, createFileRoute } from '@tanstack/react-router'
import { slideByDirection } from '../directionAwareTransition'

export const Route = createFileRoute('/')({
component: Home,
Expand All @@ -12,8 +13,8 @@ function Home() {
<div className="flex justify-center mt-4">
<Link
to={'/how-it-works'}
// see styles.css for 'slide-left' transition
viewTransition={{ types: ['slide-left'] }}
// direction-aware: slides left going forward, right on browser Back
viewTransition={slideByDirection}
className="font-bold"
>
Next Page -&gt;
Expand Down
53 changes: 53 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,19 @@ export interface RouterOptions<
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultviewtransition-property)
*/
defaultViewTransition?: boolean | ViewTransitionOptions
/**
* If `true`, replays the view transition a navigation opted into (via `<Link viewTransition>`
* or `navigate({ viewTransition })`) when the user later traverses that entry with the browser
* Back/Forward buttons. Without it, per-navigation `viewTransition` opt-ins are not replayed on
* traversal, so Back/Forward fall back to the router's normal behavior (`defaultViewTransition`
* if set, otherwise no transition).
*
* Recorded values are kept in-memory (lost on hard reload, degrading to no transition) so a
* functional `ViewTransitionOptions["types"]` survives. Opt-in; does not affect `defaultViewTransition`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*
* @default false
*/
replayViewTransitionOnTraversal?: boolean
/**
* The default `hashScrollIntoView` a route should use if no hashScrollIntoView is provided while navigating
*
Expand Down Expand Up @@ -984,6 +997,11 @@ export class RouterCore<
} = { next: true }
shouldViewTransition?: boolean | ViewTransitionOptions = undefined
isViewTransitionTypesSupported?: boolean = undefined
// For `replayViewTransitionOnTraversal`: the transition each history entry (by `__TSR_index`)
// was committed with, kept in-memory so a functional `types` survives by reference.
viewTransitionsByIndex = new Map<number, boolean | ViewTransitionOptions>()
// The `__TSR_index` we were last at, used as the "leaving" entry on a traversal.
lastViewTransitionIndex: number | undefined = undefined
subscribers = new Set<RouterListener<RouterEvent>>()
viewTransitionPromise?: ControlledPromise<true>

Expand Down Expand Up @@ -2463,6 +2481,10 @@ export class RouterCore<

load: LoadFn = async (opts): Promise<void> => {
const historyAction = opts?.action?.type
if (this.options.replayViewTransitionOnTraversal && historyAction) {
// Runs before `startViewTransition` reads `this.shouldViewTransition` in `onReady`.
this.recordOrReplayViewTransition(historyAction)
}
let redirect: AnyRedirect | undefined
let notFound: NotFoundError | undefined
let loadPromise: Promise<void>
Expand Down Expand Up @@ -2662,6 +2684,37 @@ export class RouterCore<
}
}

/**
* On commit (PUSH/REPLACE) record the entry's transition (truthy only, so it never short-circuits
* `defaultViewTransition`; a non-transition commit clears any stale recording). On traversal
* (BACK/FORWARD/GO) replay it, checking the leaving then the arriving entry so a transition
* opted into on A→B replays on both B→A (back) and a later A→B (forward). Never clobbers an
* already-set `shouldViewTransition`.
*/
recordOrReplayViewTransition = (historyAction: HistoryAction) => {
const arrivingIndex = this.history.location.state.__TSR_index

if (historyAction === 'PUSH' || historyAction === 'REPLACE') {
if (this.shouldViewTransition) {
this.viewTransitionsByIndex.set(
arrivingIndex,
this.shouldViewTransition,
)
} else {
this.viewTransitionsByIndex.delete(arrivingIndex)
}
} else {
this.shouldViewTransition =
this.shouldViewTransition ??
(this.lastViewTransitionIndex !== undefined
? this.viewTransitionsByIndex.get(this.lastViewTransitionIndex)
: undefined) ??
this.viewTransitionsByIndex.get(arrivingIndex)
}

this.lastViewTransitionIndex = arrivingIndex
}

startViewTransition = (fn: () => Promise<void>) => {
// Determine if we should start a view transition from the navigation
// or from the router default
Expand Down
Loading