fix(router): keep route reveal timeline monotonic (#614)

This commit is contained in:
InfinityPacer
2026-07-31 16:40:00 +08:00
committed by GitHub
parent eaf6b5427c
commit 9b3388f45f
7 changed files with 48 additions and 44 deletions
Vendored
-2
View File
@@ -6,8 +6,6 @@ declare module 'vue-router' {
subject?: string subject?: string
keepAlive?: boolean keepAlive?: boolean
keepAliveKey?: string keepAliveKey?: string
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
pagePresentationHandoff?: 'staged'
layoutWrapperClasses?: string layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw navActiveLink?: RouteLocationRaw
requiresAuth?: boolean requiresAuth?: boolean
@@ -143,6 +143,38 @@ describe('page presentation motion', () => {
routeRoot.remove() routeRoot.remove()
}) })
it('ignores a late geometry acknowledgement after reveal has started', () => {
const routeRoot = document.createElement('div')
Object.defineProperties(routeRoot, {
offsetHeight: { configurable: true, get: () => 2096 },
offsetWidth: { configurable: true, get: () => 1200 },
scrollHeight: { configurable: true, get: () => 2096 },
scrollWidth: { configurable: true, get: () => 1200 },
})
document.body.append(routeRoot)
expect(motion.start('/dashboard', routeRoot)).toBe(true)
const motionEpoch = motion.epoch.value
expect(motion.reader.acknowledgeGeometryReady(motionEpoch, 1040)).toBe(true)
const revealFrame = [...callbacks.values()].at(-1)!
revealFrame(1120)
const currentFrame = [...callbacks.values()].at(-1)!
const currentOpacity = motion.opacity.value
const currentProgress = motion.progress.value
const currentRevision = motion.revision.value
const currentTranslateY = motion.translateY.value
expect(motion.reader.acknowledgeGeometryReady(motionEpoch, 1160)).toBe(false)
expect(motion.opacity.value).toBe(currentOpacity)
expect(motion.progress.value).toBe(currentProgress)
expect(motion.revision.value).toBe(currentRevision)
expect(motion.translateY.value).toBe(currentTranslateY)
expect([...callbacks.values()].at(-1)).toBe(currentFrame)
routeRoot.remove()
})
it('keeps frosted material fully composed when the renderer releases its geometry hold', () => { it('keeps frosted material fully composed when the renderer releases its geometry hold', () => {
document.documentElement.dataset.glassAppearance = 'frosted' document.documentElement.dataset.glassAppearance = 'frosted'
const routeRoot = document.createElement('div') const routeRoot = document.createElement('div')
@@ -88,17 +88,6 @@ describe('route enter motion', () => {
expect(motion.phase.value).toBe('running') expect(motion.phase.value).toBe('running')
}) })
it('commits one paint boundary before a staged handoff', () => {
const root = document.createElement('div')
const stub = createAnimationStub()
root.animate = vi.fn(() => stub.animation)
const motion = createMotion()
motion.start(root, { stagedHandoff: true })
runNextFrame()
expect(stub.play).toHaveBeenCalledOnce()
})
it('cancels the previous animation and pending frame on rapid navigation', () => { it('cancels the previous animation and pending frame on rapid navigation', () => {
const root = document.createElement('div') const root = document.createElement('div')
const first = createAnimationStub() const first = createAnimationStub()
+6 -1
View File
@@ -29,6 +29,7 @@ const revision = ref(0)
const routeKey = ref('') const routeKey = ref('')
const translateY = ref(0) const translateY = ref(0)
let animationFrame: number | null = null let animationFrame: number | null = null
let layoutHoldActive = false
let layoutHoldStartedAt = 0 let layoutHoldStartedAt = 0
let layoutStableSince = 0 let layoutStableSince = 0
let layoutSignature = '' let layoutSignature = ''
@@ -105,6 +106,7 @@ function getLayoutSignature(root: HTMLElement) {
function beginReveal(timestamp: number, motionEpoch: number) { function beginReveal(timestamp: number, motionEpoch: number) {
if (!active.value || epoch.value !== motionEpoch) return if (!active.value || epoch.value !== motionEpoch) return
layoutHoldActive = false
startedAt = timestamp startedAt = timestamp
applyMotionFrame(0) applyMotionFrame(0)
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch)) animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
@@ -112,7 +114,7 @@ function beginReveal(timestamp: number, motionEpoch: number) {
/** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */ /** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */
function acknowledgeGeometryReady(motionEpoch: number, timestamp = performance.now()) { function acknowledgeGeometryReady(motionEpoch: number, timestamp = performance.now()) {
if (!active.value || epoch.value !== motionEpoch) return false if (!active.value || epoch.value !== motionEpoch || !layoutHoldActive) return false
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame) if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
animationFrame = null animationFrame = null
@@ -144,6 +146,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
} }
function settleMotion() { function settleMotion() {
layoutHoldActive = false
active.value = false active.value = false
opacity.value = 1 opacity.value = 1
progress.value = 1 progress.value = 1
@@ -191,6 +194,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame) if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
animationFrame = null animationFrame = null
layoutHoldActive = false
epoch.value += 1 epoch.value += 1
const motionEpoch = epoch.value const motionEpoch = epoch.value
routeKey.value = nextRouteKey routeKey.value = nextRouteKey
@@ -222,6 +226,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
active.value = true active.value = true
const timestamp = performance.now() const timestamp = performance.now()
if (layoutRoot && !usesCssQuality) { if (layoutRoot && !usesCssQuality) {
layoutHoldActive = true
layoutHoldStartedAt = timestamp layoutHoldStartedAt = timestamp
layoutStableSince = timestamp layoutStableSince = timestamp
layoutSignature = getLayoutSignature(layoutRoot) layoutSignature = getLayoutSignature(layoutRoot)
+6 -16
View File
@@ -2,15 +2,9 @@ import { onScopeDispose, readonly, ref } from 'vue'
export const ROUTE_ENTER_MOTION_DURATION_MS = 180 export const ROUTE_ENTER_MOTION_DURATION_MS = 180
export const ROUTE_ENTER_MOTION_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)' export const ROUTE_ENTER_MOTION_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
export const ROUTE_ENTER_STAGED_PAINT_BOUNDARIES = 1
export type RouteEnterMotionPhase = 'idle' | 'armed' | 'running' export type RouteEnterMotionPhase = 'idle' | 'armed' | 'running'
export interface RouteEnterMotionOptions {
/** 重页面离场时多保留一个绘制边界,确保目标页起始态已交给 compositor。 */
stagedHandoff?: boolean
}
function shouldSkipRouteEnterMotion() { function shouldSkipRouteEnterMotion() {
const launchScreenActive = const launchScreenActive =
document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg')) document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg'))
@@ -39,22 +33,18 @@ export function useRouteEnterMotion() {
phase.value = 'idle' phase.value = 'idle'
} }
function playAfterPaints(animation: Animation, remainingPaints: number, motionEpoch: number) { function playAfterPaint(animation: Animation, motionEpoch: number) {
if (motionEpoch !== epoch || animation !== activeAnimation) return if (motionEpoch !== epoch || animation !== activeAnimation) return
if (remainingPaints <= 0) {
phase.value = 'running'
animation.play()
return
}
animationFrame = window.requestAnimationFrame(() => { animationFrame = window.requestAnimationFrame(() => {
animationFrame = null animationFrame = null
playAfterPaints(animation, remainingPaints - 1, motionEpoch) if (motionEpoch !== epoch || animation !== activeAnimation) return
phase.value = 'running'
animation.play()
}) })
} }
function start(root: HTMLElement | null | undefined, options: RouteEnterMotionOptions = {}) { function start(root: HTMLElement | null | undefined) {
cancel() cancel()
if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false
@@ -94,7 +84,7 @@ export function useRouteEnterMotion() {
// cancel() 会拒绝 finishedepoch 已负责丢弃过期事务。 // cancel() 会拒绝 finishedepoch 已负责丢弃过期事务。
}) })
playAfterPaints(animation, options.stagedHandoff ? ROUTE_ENTER_STAGED_PAINT_BOUNDARIES : 1, motionEpoch) playAfterPaint(animation, motionEpoch)
return true return true
} }
+4 -13
View File
@@ -19,26 +19,17 @@ const routeCacheKey = computed(() => {
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。 // 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath)) const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
const routePresentationState = computed(() => ({
handoff: route.meta.pagePresentationHandoff,
key: routeTransitionKey.value,
}))
const pageRouteRef = ref<HTMLElement | null>(null) const pageRouteRef = ref<HTMLElement | null>(null)
// 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画。 // 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画。
function playPageEnterMotion( function playPageEnterMotion() {
nextPresentation = routePresentationState.value,
previousPresentation?: typeof routePresentationState.value,
) {
routeEnterMotion.cancel() routeEnterMotion.cancel()
if (pagePresentationMotion.start(nextPresentation.key, pageRouteRef.value)) return if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
routeEnterMotion.start(pageRouteRef.value, { routeEnterMotion.start(pageRouteRef.value)
stagedHandoff: previousPresentation?.handoff === 'staged',
})
} }
watch(routePresentationState, playPageEnterMotion, { flush: 'post' }) watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
onMounted(playPageEnterMotion) onMounted(playPageEnterMotion)
-1
View File
@@ -50,7 +50,6 @@ const router = createRouter({
component: () => import('../pages/recommend.vue'), component: () => import('../pages/recommend.vue'),
meta: { meta: {
keepAlive: true, keepAlive: true,
pagePresentationHandoff: 'staged',
requiresAuth: true, requiresAuth: true,
permission: 'discovery', permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND, feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,