Skip to content

Commit afdb6e0

Browse files
committed
feat: add native view transition built-in support
1 parent 7c54940 commit afdb6e0

File tree

6 files changed

+296
-6
lines changed

6 files changed

+296
-6
lines changed

packages/router/src/RouterView.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { assign, isArray, isBrowser } from './utils'
3131
import { warn } from './warning'
3232
import { isSameRouteRecord } from './location'
33+
import { transitionModeKey } from './transition'
3334

3435
export interface RouterViewProps {
3536
name?: string
@@ -62,6 +63,7 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
6263
__DEV__ && warnDeprecatedUsage()
6364

6465
const injectedRoute = inject(routerViewLocationKey)!
66+
const transitionMode = inject(transitionModeKey)!
6567
const routeToDisplay = computed<RouteLocationNormalizedLoaded>(
6668
() => props.route || injectedRoute.value
6769
)
@@ -199,8 +201,11 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
199201
return (
200202
// pass the vnode to the slot as a prop.
201203
// h and <component :is="..."> both accept vnodes
202-
normalizeSlot(slots.default, { Component: component, route }) ||
203-
component
204+
normalizeSlot(slots.default, {
205+
Component: component,
206+
route,
207+
transitionMode,
208+
}) || component
204209
)
205210
}
206211
},
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Router } from './router'
2+
import type {
3+
RouterApiOptions,
4+
createNavigationApiRouter,
5+
} from './navigation-api'
6+
import type { RouterViewTransition, TransitionMode } from './transition'
7+
8+
export interface ClientRouterOptions {
9+
/**
10+
* Factory function that creates a legacy router instance.
11+
* Typically: () => createRouter({ history: createWebHistory(), routes })
12+
*/
13+
legacy: {
14+
factory: (transitionMode: TransitionMode) => Router
15+
}
16+
/**
17+
* Options for the new Navigation API based router.
18+
* If provided and the browser supports it, this will be used.
19+
*/
20+
navigationApi?: {
21+
options: RouterApiOptions
22+
}
23+
/**
24+
* Enable native View Transitions.
25+
*/
26+
viewTransition?: true | RouterViewTransition
27+
}
28+
29+
export function createClientRouter(options: ClientRouterOptions): Router {
30+
let transitionMode: TransitionMode = 'auto'
31+
32+
if (
33+
options?.viewTransition &&
34+
typeof document !== 'undefined' &&
35+
document.startViewTransition
36+
) {
37+
transitionMode = 'view-transition'
38+
}
39+
40+
const useNavigationApi =
41+
options.navigationApi && inBrowser && window.navigation
42+
43+
if (useNavigationApi) {
44+
return createNavigationApiRouter(
45+
options.navigationApi!.options,
46+
transitionMode
47+
)
48+
} else {
49+
return options.legacy.factory(transitionMode)
50+
}
51+
}

packages/router/src/navigation-api/index.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { App } from 'vue'
1+
import { App, shallowReactive, unref } from 'vue'
22
import { shallowReactive, shallowRef, unref } from 'vue'
33
import {
44
parseURL,
@@ -59,13 +59,17 @@ import {
5959
RouterHistory,
6060
} from '../history/common'
6161
import { RouteRecordNormalized } from '../matcher/types'
62+
import { TransitionMode, transitionModeKey } from '../transition'
6263

6364
export interface RouterApiOptions extends Omit<RouterOptions, 'history'> {
6465
base?: string
6566
location: string
6667
}
6768

68-
export function createNavigationApiRouter(options: RouterApiOptions): Router {
69+
export function createNavigationApiRouter(
70+
options: RouterApiOptions,
71+
transitionMode: TransitionMode = 'auto'
72+
): Router {
6973
const matcher = createRouterMatcher(options.routes, options)
7074
const parseQuery = options.parseQuery || originalParseQuery
7175
const stringifyQuery = options.stringifyQuery || originalStringifyQuery
@@ -789,6 +793,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router {
789793
replace: (to: RouteLocationRaw) => navigate(to, { replace: true }),
790794
}
791795

796+
let beforeResolveTransitionGuard: (() => void) | undefined
797+
let afterEachTransitionGuard: (() => void) | undefined
798+
let onErrorTransitionGuard: (() => void) | undefined
799+
792800
const router: Router = {
793801
currentRoute,
794802
listening: true,
@@ -817,6 +825,70 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router {
817825
onError: errorListeners.add,
818826
isReady,
819827

828+
enableViewTransition(options) {
829+
beforeResolveTransitionGuard?.()
830+
afterEachTransitionGuard?.()
831+
onErrorTransitionGuard?.()
832+
833+
if (typeof document === 'undefined' || !document.startViewTransition) {
834+
return
835+
}
836+
837+
const defaultTransitionSetting =
838+
options.transition?.defaultViewTransition ?? true
839+
840+
let finishTransition: (() => void) | undefined
841+
let abortTransition: (() => void) | undefined
842+
843+
const resetTransitionState = () => {
844+
finishTransition = undefined
845+
abortTransition = undefined
846+
}
847+
848+
beforeResolveTransitionGuard = this.beforeResolve(
849+
(to, from, next, info) => {
850+
const transitionMode =
851+
to.meta.viewTransition ?? defaultTransitionSetting
852+
if (
853+
info?.isBackBrowserButton ||
854+
info?.isForwardBrowserButton ||
855+
transitionMode === false ||
856+
(transitionMode !== 'always' &&
857+
window.matchMedia('(prefers-reduced-motion: reduce)').matches)
858+
) {
859+
next(true)
860+
return
861+
}
862+
863+
const promise = new Promise<void>((resolve, reject) => {
864+
finishTransition = resolve
865+
abortTransition = reject
866+
})
867+
868+
const transition = document.startViewTransition(() => promise)
869+
870+
options.onStart?.(transition)
871+
transition.finished
872+
.then(() => options.onFinished?.(transition))
873+
.catch(() => options.onAborted?.(transition))
874+
.finally(resetTransitionState)
875+
876+
next(true)
877+
878+
return promise
879+
}
880+
)
881+
882+
afterEachTransitionGuard = this.afterEach(() => {
883+
finishTransition?.()
884+
})
885+
886+
onErrorTransitionGuard = this.onError((error, to, from) => {
887+
abortTransition?.()
888+
resetTransitionState()
889+
})
890+
},
891+
820892
install(app) {
821893
app.component('RouterLink', RouterLink)
822894
app.component('RouterView', RouterView)
@@ -859,6 +931,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router {
859931
app.provide(routerKey, router)
860932
app.provide(routeLocationKey, shallowReactive(reactiveRoute))
861933
app.provide(routerViewLocationKey, currentRoute)
934+
app.provide(transitionModeKey, transitionMode)
862935

863936
const unmountApp = app.unmount
864937
installedApps.add(app)

packages/router/src/router-factory.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/router/src/router.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ import { addDevtools } from './devtools'
6969
import { _LiteralUnion } from './types/utils'
7070
import { RouteLocationAsRelativeTyped } from './typed-routes/route-location'
7171
import { RouteMap } from './typed-routes/route-map'
72+
import {
73+
RouterViewTransition,
74+
TransitionMode,
75+
transitionModeKey,
76+
} from './transition'
7277

7378
/**
7479
* Internal type to define an ErrorHandler
@@ -205,6 +210,15 @@ export interface Router {
205210
*/
206211
listening: boolean
207212

213+
/**
214+
* Enable native view transition.
215+
*
216+
* NOTE: will be a no-op if the browser does not support it.
217+
*
218+
* @param options The options to use.
219+
*/
220+
enableViewTransition(options: RouterViewTransition)
221+
208222
/**
209223
* Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
210224
*
@@ -216,6 +230,7 @@ export interface Router {
216230
parentName: NonNullable<RouteRecordNameGeneric>,
217231
route: RouteRecordRaw
218232
): () => void
233+
219234
/**
220235
* Add a new {@link RouteRecordRaw | route record} to the router.
221236
*
@@ -382,7 +397,10 @@ export interface Router {
382397
*
383398
* @param options - {@link RouterOptions}
384399
*/
385-
export function createRouter(options: RouterOptions): Router {
400+
export function createRouter(
401+
options: RouterOptions,
402+
transitionMode: TransitionMode = 'auto'
403+
): Router {
386404
const matcher = createRouterMatcher(options.routes, options)
387405
const parseQuery = options.parseQuery || originalParseQuery
388406
const stringifyQuery = options.stringifyQuery || originalStringifyQuery
@@ -1230,6 +1248,11 @@ export function createRouter(options: RouterOptions): Router {
12301248
let started: boolean | undefined
12311249
const installedApps = new Set<App>()
12321250

1251+
let beforeResolveTransitionGuard: (() => void) | undefined
1252+
let afterEachTransitionGuard: (() => void) | undefined
1253+
let onErrorTransitionGuard: (() => void) | undefined
1254+
let popStateListener: (() => void) | undefined
1255+
12331256
const router: Router = {
12341257
currentRoute,
12351258
listening: true,
@@ -1255,6 +1278,26 @@ export function createRouter(options: RouterOptions): Router {
12551278
onError: errorListeners.add,
12561279
isReady,
12571280

1281+
enableViewTransition(options) {
1282+
beforeResolveTransitionGuard?.()
1283+
afterEachTransitionGuard?.()
1284+
onErrorTransitionGuard?.()
1285+
if (popStateListener) {
1286+
window.removeEventListener('popstate', popStateListener)
1287+
}
1288+
1289+
if (typeof document === 'undefined' || !document.startViewTransition) {
1290+
return
1291+
}
1292+
1293+
;[
1294+
beforeResolveTransitionGuard,
1295+
afterEachTransitionGuard,
1296+
onErrorTransitionGuard,
1297+
popStateListener,
1298+
] = enableViewTransition(this, options)
1299+
},
1300+
12581301
install(app: App) {
12591302
app.component('RouterLink', RouterLink)
12601303
app.component('RouterView', RouterView)
@@ -1293,6 +1336,7 @@ export function createRouter(options: RouterOptions): Router {
12931336
app.provide(routerKey, router)
12941337
app.provide(routeLocationKey, shallowReactive(reactiveRoute))
12951338
app.provide(routerViewLocationKey, currentRoute)
1339+
app.provide(transitionModeKey, transitionMode)
12961340

12971341
const unmountApp = app.unmount
12981342
installedApps.add(app)
@@ -1356,3 +1400,94 @@ function extractChangingRecords(
13561400

13571401
return [leavingRecords, updatingRecords, enteringRecords]
13581402
}
1403+
1404+
function isChangingPage(
1405+
to: RouteLocationNormalized,
1406+
from: RouteLocationNormalized
1407+
) {
1408+
if (to === from || from === START_LOCATION) {
1409+
return false
1410+
}
1411+
1412+
// If route keys are different then it will result in a rerender
1413+
if (generateRouteKey(to) !== generateRouteKey(from)) {
1414+
return true
1415+
}
1416+
1417+
const areComponentsSame = to.matched.every(
1418+
(comp, index) =>
1419+
comp.components &&
1420+
comp.components.default === from.matched[index]?.components?.default
1421+
)
1422+
return !areComponentsSame
1423+
}
1424+
1425+
function enableViewTransition(router: Router, options: RouterViewTransition) {
1426+
let transition: undefined | ViewTransition
1427+
let hasUAVisualTransition = false
1428+
let finishTransition: (() => void) | undefined
1429+
let abortTransition: (() => void) | undefined
1430+
1431+
const defaultTransitionSetting =
1432+
options.transition?.defaultViewTransition ?? true
1433+
1434+
const resetTransitionState = () => {
1435+
transition = undefined
1436+
hasUAVisualTransition = false
1437+
abortTransition = undefined
1438+
finishTransition = undefined
1439+
}
1440+
1441+
function popStateListener(event: PopStateEvent) {
1442+
hasUAVisualTransition = event.hasUAVisualTransition
1443+
if (hasUAVisualTransition) {
1444+
transition?.skipTransition()
1445+
}
1446+
}
1447+
1448+
window.addEventListener('popstate', popStateListener)
1449+
1450+
const beforeResolveTransitionGuard = router.beforeResolve((to, from) => {
1451+
const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting
1452+
if (
1453+
hasUAVisualTransition ||
1454+
transitionMode === false ||
1455+
(transitionMode !== 'always' &&
1456+
window.matchMedia('(prefers-reduced-motion: reduce)').matches) ||
1457+
!isChangingPage(to, from)
1458+
) {
1459+
return
1460+
}
1461+
1462+
const promise = new Promise<void>((resolve, reject) => {
1463+
finishTransition = resolve
1464+
abortTransition = reject
1465+
})
1466+
1467+
const transition = document.startViewTransition(() => promise)
1468+
1469+
options.onStart?.(transition)
1470+
transition.finished
1471+
.then(() => options.onFinished?.(transition))
1472+
.catch(() => options.onAborted?.(transition))
1473+
.finally(resetTransitionState)
1474+
1475+
return promise
1476+
})
1477+
1478+
const afterEachTransitionGuard = router.afterEach(() => {
1479+
finishTransition?.()
1480+
})
1481+
1482+
const onErrorTransitionGuard = router.onError(() => {
1483+
abortTransition?.()
1484+
resetTransitionState()
1485+
})
1486+
1487+
return [
1488+
beforeResolveTransitionGuard,
1489+
afterEachTransitionGuard,
1490+
onErrorTransitionGuard,
1491+
popStateListener,
1492+
]
1493+
}

0 commit comments

Comments
 (0)