|
| 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