Skip to content

Commit b640f6b

Browse files
committed
feat(router-core): replay view transitions on Back/Forward traversal
Add the opt-in `replayViewTransitionOnTraversal` router option. The router records the view-transition value each history entry was committed with (in memory, keyed by __TSR_index) and replays it on BACK/FORWARD/GO, so a transition opted into via <Link viewTransition> / navigate({ viewTransition }) no longer hard-cuts on browser Back/Forward. Includes tests, docs and a changeset.
1 parent bb2daa6 commit b640f6b

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/router-core': patch
3+
---
4+
5+
feat: add `replayViewTransitionOnTraversal` router option
6+
7+
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`.

docs/router/api/router/RouterOptionsType.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ const router = createRouter({
200200
- See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) for more information on how this function works.
201201
- See [Google](https://developer.chrome.com/docs/web-platform/view-transitions/same-document#view-transition-types) for more information on viewTransition types
202202

203+
### `replayViewTransitionOnTraversal` property
204+
205+
- Type: `boolean`
206+
- Optional, defaults to `false`
207+
- 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).
208+
- 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.
209+
203210
### `defaultHashScrollIntoView` property
204211

205212
- Type: `boolean | ScrollIntoViewOptions`

packages/router-core/src/router.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ export interface RouterOptions<
280280
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultviewtransition-property)
281281
*/
282282
defaultViewTransition?: boolean | ViewTransitionOptions
283+
/**
284+
* If `true`, replays the view transition a navigation opted into (via `<Link viewTransition>`
285+
* or `navigate({ viewTransition })`) when the user later traverses that entry with the browser
286+
* Back/Forward buttons. Without it, traversals arrive as `popstate` and play a hard cut.
287+
*
288+
* Recorded values are kept in-memory (lost on hard reload, degrading to no transition) so a
289+
* functional `ViewTransitionOptions["types"]` survives. Opt-in; does not affect `defaultViewTransition`.
290+
*
291+
* @default false
292+
*/
293+
replayViewTransitionOnTraversal?: boolean
283294
/**
284295
* The default `hashScrollIntoView` a route should use if no hashScrollIntoView is provided while navigating
285296
*
@@ -984,6 +995,11 @@ export class RouterCore<
984995
} = { next: true }
985996
shouldViewTransition?: boolean | ViewTransitionOptions = undefined
986997
isViewTransitionTypesSupported?: boolean = undefined
998+
// For `replayViewTransitionOnTraversal`: the transition each history entry (by `__TSR_index`)
999+
// was committed with, kept in-memory so a functional `types` survives by reference.
1000+
viewTransitionsByIndex = new Map<number, boolean | ViewTransitionOptions>()
1001+
// The `__TSR_index` we were last at, used as the "leaving" entry on a traversal.
1002+
lastViewTransitionIndex: number | undefined = undefined
9871003
subscribers = new Set<RouterListener<RouterEvent>>()
9881004
viewTransitionPromise?: ControlledPromise<true>
9891005

@@ -2463,6 +2479,10 @@ export class RouterCore<
24632479

24642480
load: LoadFn = async (opts): Promise<void> => {
24652481
const historyAction = opts?.action?.type
2482+
if (this.options.replayViewTransitionOnTraversal && historyAction) {
2483+
// Runs before `startViewTransition` reads `this.shouldViewTransition` in `onReady`.
2484+
this.recordOrReplayViewTransition(historyAction)
2485+
}
24662486
let redirect: AnyRedirect | undefined
24672487
let notFound: NotFoundError | undefined
24682488
let loadPromise: Promise<void>
@@ -2662,6 +2682,34 @@ export class RouterCore<
26622682
}
26632683
}
26642684

2685+
/**
2686+
* On commit (PUSH/REPLACE) record the entry's transition (truthy only, so it never short-circuits
2687+
* `defaultViewTransition`; a non-transition commit clears any stale recording). On traversal
2688+
* (BACK/FORWARD/GO) replay it, checking the leaving then the arriving entry so a transition
2689+
* opted into on A→B replays on both B→A (back) and a later A→B (forward). Never clobbers an
2690+
* already-set `shouldViewTransition`.
2691+
*/
2692+
recordOrReplayViewTransition = (historyAction: HistoryAction) => {
2693+
const arrivingIndex = this.history.location.state.__TSR_index
2694+
2695+
if (historyAction === 'PUSH' || historyAction === 'REPLACE') {
2696+
if (this.shouldViewTransition) {
2697+
this.viewTransitionsByIndex.set(arrivingIndex, this.shouldViewTransition)
2698+
} else {
2699+
this.viewTransitionsByIndex.delete(arrivingIndex)
2700+
}
2701+
} else {
2702+
this.shouldViewTransition =
2703+
this.shouldViewTransition ??
2704+
(this.lastViewTransitionIndex !== undefined
2705+
? this.viewTransitionsByIndex.get(this.lastViewTransitionIndex)
2706+
: undefined) ??
2707+
this.viewTransitionsByIndex.get(arrivingIndex)
2708+
}
2709+
2710+
this.lastViewTransitionIndex = arrivingIndex
2711+
}
2712+
26652713
startViewTransition = (fn: () => Promise<void>) => {
26662714
// Determine if we should start a view transition from the navigation
26672715
// or from the router default
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { createMemoryHistory } from '@tanstack/history'
3+
import { BaseRootRoute, BaseRoute } from '../src'
4+
import { createTestRouter } from './routerTestUtils'
5+
import type { ViewTransitionOptions } from '../src'
6+
7+
/**
8+
* Tests for `replayViewTransitionOnTraversal`: a view transition opted into during a
9+
* navigation (PUSH/REPLACE) should be replayed when the user traverses that entry with the
10+
* browser Back/Forward buttons (BACK/FORWARD/GO), and should be a no-op otherwise.
11+
*/
12+
13+
type StartVT = (arg: any) => any
14+
15+
let startViewTransitionSpy: ReturnType<typeof vi.fn>
16+
17+
beforeEach(() => {
18+
// jsdom has no document.startViewTransition; mock one that runs the update callback
19+
// synchronously and records how it was invoked.
20+
startViewTransitionSpy = vi.fn<StartVT>((arg) => {
21+
const update = typeof arg === 'function' ? arg : arg.update
22+
update?.()
23+
return {
24+
ready: Promise.resolve(),
25+
finished: Promise.resolve(),
26+
updateCallbackDone: Promise.resolve(),
27+
skipTransition: () => {},
28+
}
29+
})
30+
;(document as any).startViewTransition = startViewTransitionSpy
31+
})
32+
33+
afterEach(() => {
34+
delete (document as any).startViewTransition
35+
vi.restoreAllMocks()
36+
})
37+
38+
function createRouter(options: { replayViewTransitionOnTraversal?: boolean } = {}) {
39+
const rootRoute = new BaseRootRoute({})
40+
const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' })
41+
const aRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/a' })
42+
const bRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/b' })
43+
44+
return createTestRouter({
45+
routeTree: rootRoute.addChildren([indexRoute, aRoute, bRoute]),
46+
history: createMemoryHistory({ initialEntries: ['/'] }),
47+
...options,
48+
})
49+
}
50+
51+
/** Mimics the Transitioner: load runs on every history event. */
52+
async function mount(router: ReturnType<typeof createRouter>) {
53+
router.history.subscribe(router.load)
54+
await router.load()
55+
}
56+
57+
/** Drive a browser-style traversal and await the load it triggers. */
58+
async function traverse(router: ReturnType<typeof createRouter>, fn: () => void) {
59+
fn()
60+
await router.latestLoadPromise
61+
}
62+
63+
describe('replayViewTransitionOnTraversal', () => {
64+
test('replays the view transition on browser back', async () => {
65+
const router = createRouter({ replayViewTransitionOnTraversal: true })
66+
await mount(router)
67+
68+
await router.navigate({ to: '/a', viewTransition: true })
69+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1) // the forward navigation itself
70+
startViewTransitionSpy.mockClear()
71+
72+
await traverse(router, () => router.history.back())
73+
74+
// Back to "/" replays the transition recorded for the "/a" entry.
75+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
76+
expect(router.state.location.pathname).toBe('/')
77+
})
78+
79+
test('replays the view transition on browser forward', async () => {
80+
const router = createRouter({ replayViewTransitionOnTraversal: true })
81+
await mount(router)
82+
83+
await router.navigate({ to: '/a', viewTransition: true })
84+
await traverse(router, () => router.history.back())
85+
startViewTransitionSpy.mockClear()
86+
87+
await traverse(router, () => router.history.forward())
88+
89+
// Forward to "/a" replays via the arriving entry's recorded transition.
90+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
91+
expect(router.state.location.pathname).toBe('/a')
92+
})
93+
94+
test('does not transition a traversal across an edge that never opted in', async () => {
95+
const router = createRouter({ replayViewTransitionOnTraversal: true })
96+
await mount(router)
97+
98+
await router.navigate({ to: '/a' }) // plain navigation, no viewTransition
99+
expect(startViewTransitionSpy).not.toHaveBeenCalled()
100+
101+
await traverse(router, () => router.history.back())
102+
103+
expect(startViewTransitionSpy).not.toHaveBeenCalled()
104+
expect(router.state.location.pathname).toBe('/')
105+
})
106+
107+
test('does not clobber an explicitly-set shouldViewTransition during a traversal', async () => {
108+
const router = createRouter({ replayViewTransitionOnTraversal: true })
109+
router.isViewTransitionTypesSupported = true
110+
await mount(router)
111+
112+
const recorded: ViewTransitionOptions = { types: ['recorded'] }
113+
await router.navigate({ to: '/a', viewTransition: recorded })
114+
startViewTransitionSpy.mockClear()
115+
116+
// Something set a transition for this traversal explicitly; replay must not override it.
117+
const explicit: ViewTransitionOptions = { types: ['explicit'] }
118+
router.shouldViewTransition = explicit
119+
120+
await traverse(router, () => router.history.back())
121+
122+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
123+
expect(startViewTransitionSpy.mock.calls[0]![0]).toMatchObject({
124+
types: ['explicit'],
125+
})
126+
})
127+
128+
test('preserves a ViewTransitionOptions object with functional types by identity', async () => {
129+
const router = createRouter({ replayViewTransitionOnTraversal: true })
130+
router.isViewTransitionTypesSupported = true
131+
await mount(router)
132+
133+
// A function is NOT structured-cloneable, so this value could not survive being written
134+
// to history.state — it survives only because the map holds it by reference.
135+
const typesFn = vi.fn(() => ['slide'])
136+
const vt: ViewTransitionOptions = { types: typesFn }
137+
138+
await router.navigate({ to: '/a', viewTransition: vt })
139+
140+
// The exact object is held by reference for the "/a" entry (index 1).
141+
expect(router.viewTransitionsByIndex.get(1)).toBe(vt)
142+
143+
startViewTransitionSpy.mockClear()
144+
typesFn.mockClear()
145+
146+
await traverse(router, () => router.history.back())
147+
148+
// The functional `types` was invoked and resolved on replay.
149+
expect(typesFn).toHaveBeenCalledTimes(1)
150+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
151+
expect(startViewTransitionSpy.mock.calls[0]![0]).toMatchObject({
152+
types: ['slide'],
153+
})
154+
})
155+
156+
test('only traversals touching the transitioned entry replay', async () => {
157+
const router = createRouter({ replayViewTransitionOnTraversal: true })
158+
await mount(router)
159+
160+
await router.navigate({ to: '/a' }) // "/a" = index 1, plain
161+
await router.navigate({ to: '/b', viewTransition: true }) // "/b" = index 2, recorded
162+
startViewTransitionSpy.mockClear()
163+
164+
// Leaving the transitioned "/b" entry replays.
165+
await traverse(router, () => router.history.back())
166+
expect(router.state.location.pathname).toBe('/a')
167+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
168+
startViewTransitionSpy.mockClear()
169+
170+
// A traversal between two non-transitioned entries ("/a" -> "/") does not.
171+
await traverse(router, () => router.history.back())
172+
expect(router.state.location.pathname).toBe('/')
173+
expect(startViewTransitionSpy).not.toHaveBeenCalled()
174+
})
175+
176+
test('is a no-op when the option is disabled (default)', async () => {
177+
const router = createRouter() // option not set
178+
await mount(router)
179+
180+
await router.navigate({ to: '/a', viewTransition: true })
181+
startViewTransitionSpy.mockClear()
182+
183+
await traverse(router, () => router.history.back())
184+
185+
// No replay: browser back is a hard cut by default.
186+
expect(startViewTransitionSpy).not.toHaveBeenCalled()
187+
expect(router.viewTransitionsByIndex.size).toBe(0)
188+
})
189+
})

0 commit comments

Comments
 (0)