From b640f6b3b641ca2c6443c8a8bce7c1884511fe10 Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 02:10:59 +0300 Subject: [PATCH 1/3] 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 / navigate({ viewTransition }) no longer hard-cuts on browser Back/Forward. Includes tests, docs and a changeset. --- .../replay-view-transition-on-traversal.md | 7 + docs/router/api/router/RouterOptionsType.md | 7 + packages/router-core/src/router.ts | 48 +++++ .../tests/view-transition-traversal.test.ts | 189 ++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 .changeset/replay-view-transition-on-traversal.md create mode 100644 packages/router-core/tests/view-transition-traversal.test.ts diff --git a/.changeset/replay-view-transition-on-traversal.md b/.changeset/replay-view-transition-on-traversal.md new file mode 100644 index 0000000000..add76686e2 --- /dev/null +++ b/.changeset/replay-view-transition-on-traversal.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': patch +--- + +feat: add `replayViewTransitionOnTraversal` router option + +Replays the view transition a navigation opted into (`` / `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`. diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 7127e1191e..82c7561210 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -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 (`` / `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` diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..1c871a65f4 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -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 `` + * 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`. + * + * @default false + */ + replayViewTransitionOnTraversal?: boolean /** * The default `hashScrollIntoView` a route should use if no hashScrollIntoView is provided while navigating * @@ -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() + // The `__TSR_index` we were last at, used as the "leaving" entry on a traversal. + lastViewTransitionIndex: number | undefined = undefined subscribers = new Set>() viewTransitionPromise?: ControlledPromise @@ -2463,6 +2479,10 @@ export class RouterCore< load: LoadFn = async (opts): Promise => { 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 @@ -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) => { // Determine if we should start a view transition from the navigation // or from the router default diff --git a/packages/router-core/tests/view-transition-traversal.test.ts b/packages/router-core/tests/view-transition-traversal.test.ts new file mode 100644 index 0000000000..944a8d89a3 --- /dev/null +++ b/packages/router-core/tests/view-transition-traversal.test.ts @@ -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 + +beforeEach(() => { + // jsdom has no document.startViewTransition; mock one that runs the update callback + // synchronously and records how it was invoked. + startViewTransitionSpy = vi.fn((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) { + router.history.subscribe(router.load) + await router.load() +} + +/** Drive a browser-style traversal and await the load it triggers. */ +async function traverse(router: ReturnType, 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) + }) +}) From 972f99325cb474469bb25e7d8df0750e28f1578a Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 02:35:21 +0300 Subject: [PATCH 2/3] docs(example): demonstrate Back/Forward replay in view-transitions example Enable replayViewTransitionOnTraversal in the view-transitions example and make its links direction-aware via a page-order-based types function, so browser Back/Forward replay the transition in the correct direction. --- .../src/directionAwareTransition.ts | 40 +++++++++++++++++++ examples/react/view-transitions/src/main.tsx | 2 + .../view-transitions/src/routes/explore.tsx | 5 ++- .../src/routes/how-it-works.tsx | 9 +++-- .../view-transitions/src/routes/index.tsx | 5 ++- 5 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 examples/react/view-transitions/src/directionAwareTransition.ts diff --git a/examples/react/view-transitions/src/directionAwareTransition.ts b/examples/react/view-transitions/src/directionAwareTransition.ts new file mode 100644 index 0000000000..bf1ba617c7 --- /dev/null +++ b/examples/react/view-transitions/src/directionAwareTransition.ts @@ -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'] + }, +} diff --git a/examples/react/view-transitions/src/main.tsx b/examples/react/view-transitions/src/main.tsx index 065d69a2cf..d901de51b5 100644 --- a/examples/react/view-transitions/src/main.tsx +++ b/examples/react/view-transitions/src/main.tsx @@ -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. diff --git a/examples/react/view-transitions/src/routes/explore.tsx b/examples/react/view-transitions/src/routes/explore.tsx index b70a6f3bd5..c1476dae2d 100644 --- a/examples/react/view-transitions/src/routes/explore.tsx +++ b/examples/react/view-transitions/src/routes/explore.tsx @@ -1,4 +1,5 @@ import { Link, createFileRoute } from '@tanstack/react-router' +import { slideByDirection } from '../directionAwareTransition' export const Route = createFileRoute('/explore')({ component: RouteComponent, @@ -18,8 +19,8 @@ function RouteComponent() {
<- Previous Page diff --git a/examples/react/view-transitions/src/routes/how-it-works.tsx b/examples/react/view-transitions/src/routes/how-it-works.tsx index ab4dd4c714..2590dd46f0 100644 --- a/examples/react/view-transitions/src/routes/how-it-works.tsx +++ b/examples/react/view-transitions/src/routes/how-it-works.tsx @@ -1,4 +1,5 @@ import { Link, createFileRoute } from '@tanstack/react-router' +import { slideByDirection } from '../directionAwareTransition' export const Route = createFileRoute('/how-it-works')({ component: RouteComponent, @@ -11,16 +12,16 @@ function RouteComponent() {
<- Previous Page Next Page -> diff --git a/examples/react/view-transitions/src/routes/index.tsx b/examples/react/view-transitions/src/routes/index.tsx index 132ba18dbb..36e6b154c0 100644 --- a/examples/react/view-transitions/src/routes/index.tsx +++ b/examples/react/view-transitions/src/routes/index.tsx @@ -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, @@ -12,8 +13,8 @@ function Home() {
Next Page -> From d9f327a3e96f5ad38eb427bd4dd883ab39cf4c6a Mon Sep 17 00:00:00 2001 From: Gokhan Kurt Date: Fri, 26 Jun 2026 03:01:11 +0300 Subject: [PATCH 3/3] address review: clarify traversal wording, brace style, add GO + default-fallback tests - Clarify in JSDoc/docs/changeset that without the option only per-navigation viewTransition opt-ins are not replayed; traversals still fall back to defaultViewTransition. - Add curly braces to the example helper's early return (repo style) + docstring. - Add tests for a multi-step history.go() traversal and the defaultViewTransition fallback contract. --- .../replay-view-transition-on-traversal.md | 2 +- docs/router/api/router/RouterOptionsType.md | 2 +- .../src/directionAwareTransition.ts | 5 +- packages/router-core/src/router.ts | 9 +++- .../tests/view-transition-traversal.test.ts | 51 +++++++++++++++++-- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/.changeset/replay-view-transition-on-traversal.md b/.changeset/replay-view-transition-on-traversal.md index add76686e2..c920e8a018 100644 --- a/.changeset/replay-view-transition-on-traversal.md +++ b/.changeset/replay-view-transition-on-traversal.md @@ -4,4 +4,4 @@ feat: add `replayViewTransitionOnTraversal` router option -Replays the view transition a navigation opted into (`` / `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`. +Replays the view transition a navigation opted into (`` / `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`. diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 82c7561210..d727ba4fea 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -204,7 +204,7 @@ const router = createRouter({ - Type: `boolean` - Optional, defaults to `false` -- If `true`, replays the view transition a navigation opted into (`` / `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). +- If `true`, replays the view transition a navigation opted into (`` / `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 diff --git a/examples/react/view-transitions/src/directionAwareTransition.ts b/examples/react/view-transitions/src/directionAwareTransition.ts index bf1ba617c7..d8ed67b71c 100644 --- a/examples/react/view-transitions/src/directionAwareTransition.ts +++ b/examples/react/view-transitions/src/directionAwareTransition.ts @@ -15,6 +15,7 @@ import type { ViewTransitionOptions } from '@tanstack/react-router' */ 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 @@ -31,7 +32,9 @@ function pageRank(pathname: string): number { export const slideByDirection: ViewTransitionOptions = { types: ({ fromLocation, toLocation }) => { - if (!fromLocation) return ['slide-left'] + 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. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1c871a65f4..53fb0a2c40 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -283,7 +283,9 @@ export interface RouterOptions< /** * If `true`, replays the view transition a navigation opted into (via `` * 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. + * 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`. @@ -2694,7 +2696,10 @@ export class RouterCore< if (historyAction === 'PUSH' || historyAction === 'REPLACE') { if (this.shouldViewTransition) { - this.viewTransitionsByIndex.set(arrivingIndex, this.shouldViewTransition) + this.viewTransitionsByIndex.set( + arrivingIndex, + this.shouldViewTransition, + ) } else { this.viewTransitionsByIndex.delete(arrivingIndex) } diff --git a/packages/router-core/tests/view-transition-traversal.test.ts b/packages/router-core/tests/view-transition-traversal.test.ts index 944a8d89a3..cd90830822 100644 --- a/packages/router-core/tests/view-transition-traversal.test.ts +++ b/packages/router-core/tests/view-transition-traversal.test.ts @@ -35,9 +35,17 @@ afterEach(() => { vi.restoreAllMocks() }) -function createRouter(options: { replayViewTransitionOnTraversal?: boolean } = {}) { +function createRouter( + options: { + replayViewTransitionOnTraversal?: boolean + defaultViewTransition?: boolean + } = {}, +) { const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) const aRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/a' }) const bRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/b' }) @@ -55,7 +63,10 @@ async function mount(router: ReturnType) { } /** Drive a browser-style traversal and await the load it triggers. */ -async function traverse(router: ReturnType, fn: () => void) { +async function traverse( + router: ReturnType, + fn: () => void, +) { fn() await router.latestLoadPromise } @@ -186,4 +197,38 @@ describe('replayViewTransitionOnTraversal', () => { expect(startViewTransitionSpy).not.toHaveBeenCalled() expect(router.viewTransitionsByIndex.size).toBe(0) }) + + test('replays on a multi-step GO traversal', async () => { + const router = createRouter({ replayViewTransitionOnTraversal: true }) + await mount(router) + + await router.navigate({ to: '/a' }) // index 1, plain + await router.navigate({ to: '/b', viewTransition: true }) // index 2, recorded + startViewTransitionSpy.mockClear() + + // history.go(-2): "/b" (2) -> "/" (0). The transitioned entry (2) is the leaving + // endpoint, so the GO branch replays it. + await traverse(router, () => router.history.go(-2)) + + expect(router.state.location.pathname).toBe('/') + expect(startViewTransitionSpy).toHaveBeenCalledTimes(1) + }) + + test('falls back to defaultViewTransition when nothing is recorded', async () => { + const router = createRouter({ + replayViewTransitionOnTraversal: true, + defaultViewTransition: true, + }) + await mount(router) + + await router.navigate({ to: '/a' }) // plain: no per-navigation opt-in recorded + expect(router.viewTransitionsByIndex.has(1)).toBe(false) + startViewTransitionSpy.mockClear() + + await traverse(router, () => router.history.back()) + + // The traversal still transitions, but via defaultViewTransition — the option + // does not suppress it. + expect(startViewTransitionSpy).toHaveBeenCalledTimes(1) + }) })