Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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, instead of a hard cut. 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, traversals arrive as `popstate` and play a hard cut. 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
40 changes: 40 additions & 0 deletions examples/react/view-transitions/src/directionAwareTransition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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']

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
48 changes: 48 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ 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, traversals arrive as `popstate` and play a hard cut.
*
* 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 +995,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 +2479,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 +2682,34 @@ 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
189 changes: 189 additions & 0 deletions packages/router-core/tests/view-transition-traversal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute } from '../src'
import { createTestRouter } from './routerTestUtils'
import type { ViewTransitionOptions } from '../src'

/**
* Tests for `replayViewTransitionOnTraversal`: a view transition opted into during a
* navigation (PUSH/REPLACE) should be replayed when the user traverses that entry with the
* browser Back/Forward buttons (BACK/FORWARD/GO), and should be a no-op otherwise.
*/

type StartVT = (arg: any) => any

let startViewTransitionSpy: ReturnType<typeof vi.fn>

beforeEach(() => {
// jsdom has no document.startViewTransition; mock one that runs the update callback
// synchronously and records how it was invoked.
startViewTransitionSpy = vi.fn<StartVT>((arg) => {
const update = typeof arg === 'function' ? arg : arg.update
update?.()
return {
ready: Promise.resolve(),
finished: Promise.resolve(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
}
})
;(document as any).startViewTransition = startViewTransitionSpy
})

afterEach(() => {
delete (document as any).startViewTransition
vi.restoreAllMocks()
})

function createRouter(options: { replayViewTransitionOnTraversal?: boolean } = {}) {
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' })
const aRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/a' })
const bRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/b' })

return createTestRouter({
routeTree: rootRoute.addChildren([indexRoute, aRoute, bRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
...options,
})
}

/** Mimics the Transitioner: load runs on every history event. */
async function mount(router: ReturnType<typeof createRouter>) {
router.history.subscribe(router.load)
await router.load()
}

/** Drive a browser-style traversal and await the load it triggers. */
async function traverse(router: ReturnType<typeof createRouter>, fn: () => void) {
fn()
await router.latestLoadPromise
}

describe('replayViewTransitionOnTraversal', () => {
test('replays the view transition on browser back', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
await mount(router)

await router.navigate({ to: '/a', viewTransition: true })
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1) // the forward navigation itself
startViewTransitionSpy.mockClear()

await traverse(router, () => router.history.back())

// Back to "/" replays the transition recorded for the "/a" entry.
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
expect(router.state.location.pathname).toBe('/')
})

test('replays the view transition on browser forward', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
await mount(router)

await router.navigate({ to: '/a', viewTransition: true })
await traverse(router, () => router.history.back())
startViewTransitionSpy.mockClear()

await traverse(router, () => router.history.forward())

// Forward to "/a" replays via the arriving entry's recorded transition.
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
expect(router.state.location.pathname).toBe('/a')
})

test('does not transition a traversal across an edge that never opted in', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
await mount(router)

await router.navigate({ to: '/a' }) // plain navigation, no viewTransition
expect(startViewTransitionSpy).not.toHaveBeenCalled()

await traverse(router, () => router.history.back())

expect(startViewTransitionSpy).not.toHaveBeenCalled()
expect(router.state.location.pathname).toBe('/')
})

test('does not clobber an explicitly-set shouldViewTransition during a traversal', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
router.isViewTransitionTypesSupported = true
await mount(router)

const recorded: ViewTransitionOptions = { types: ['recorded'] }
await router.navigate({ to: '/a', viewTransition: recorded })
startViewTransitionSpy.mockClear()

// Something set a transition for this traversal explicitly; replay must not override it.
const explicit: ViewTransitionOptions = { types: ['explicit'] }
router.shouldViewTransition = explicit

await traverse(router, () => router.history.back())

expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
expect(startViewTransitionSpy.mock.calls[0]![0]).toMatchObject({
types: ['explicit'],
})
})

test('preserves a ViewTransitionOptions object with functional types by identity', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
router.isViewTransitionTypesSupported = true
await mount(router)

// A function is NOT structured-cloneable, so this value could not survive being written
// to history.state β€” it survives only because the map holds it by reference.
const typesFn = vi.fn(() => ['slide'])
const vt: ViewTransitionOptions = { types: typesFn }

await router.navigate({ to: '/a', viewTransition: vt })

// The exact object is held by reference for the "/a" entry (index 1).
expect(router.viewTransitionsByIndex.get(1)).toBe(vt)

startViewTransitionSpy.mockClear()
typesFn.mockClear()

await traverse(router, () => router.history.back())

// The functional `types` was invoked and resolved on replay.
expect(typesFn).toHaveBeenCalledTimes(1)
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
expect(startViewTransitionSpy.mock.calls[0]![0]).toMatchObject({
types: ['slide'],
})
})

test('only traversals touching the transitioned entry replay', async () => {
const router = createRouter({ replayViewTransitionOnTraversal: true })
await mount(router)

await router.navigate({ to: '/a' }) // "/a" = index 1, plain
await router.navigate({ to: '/b', viewTransition: true }) // "/b" = index 2, recorded
startViewTransitionSpy.mockClear()

// Leaving the transitioned "/b" entry replays.
await traverse(router, () => router.history.back())
expect(router.state.location.pathname).toBe('/a')
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
startViewTransitionSpy.mockClear()

// A traversal between two non-transitioned entries ("/a" -> "/") does not.
await traverse(router, () => router.history.back())
expect(router.state.location.pathname).toBe('/')
expect(startViewTransitionSpy).not.toHaveBeenCalled()
})

test('is a no-op when the option is disabled (default)', async () => {
const router = createRouter() // option not set
await mount(router)

await router.navigate({ to: '/a', viewTransition: true })
startViewTransitionSpy.mockClear()

await traverse(router, () => router.history.back())

// No replay: browser back is a hard cut by default.
expect(startViewTransitionSpy).not.toHaveBeenCalled()
expect(router.viewTransitionsByIndex.size).toBe(0)
})
})