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

2
env.d.ts vendored
View File

@@ -6,8 +6,6 @@ declare module 'vue-router' {
subject?: string
keepAlive?: boolean
keepAliveKey?: string
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
pagePresentationHandoff?: 'staged'
layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw
requiresAuth?: boolean

View File

@@ -143,6 +143,38 @@ describe('page presentation motion', () => {
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', () => {
document.documentElement.dataset.glassAppearance = 'frosted'
const routeRoot = document.createElement('div')

View File

@@ -88,17 +88,6 @@ describe('route enter motion', () => {
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', () => {
const root = document.createElement('div')
const first = createAnimationStub()

View File

@@ -29,6 +29,7 @@ const revision = ref(0)
const routeKey = ref('')
const translateY = ref(0)
let animationFrame: number | null = null
let layoutHoldActive = false
let layoutHoldStartedAt = 0
let layoutStableSince = 0
let layoutSignature = ''
@@ -105,6 +106,7 @@ function getLayoutSignature(root: HTMLElement) {
function beginReveal(timestamp: number, motionEpoch: number) {
if (!active.value || epoch.value !== motionEpoch) return
layoutHoldActive = false
startedAt = timestamp
applyMotionFrame(0)
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
@@ -112,7 +114,7 @@ function beginReveal(timestamp: number, motionEpoch: number) {
/** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */
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)
animationFrame = null
@@ -144,6 +146,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
}
function settleMotion() {
layoutHoldActive = false
active.value = false
opacity.value = 1
progress.value = 1
@@ -191,6 +194,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
animationFrame = null
layoutHoldActive = false
epoch.value += 1
const motionEpoch = epoch.value
routeKey.value = nextRouteKey
@@ -222,6 +226,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
active.value = true
const timestamp = performance.now()
if (layoutRoot && !usesCssQuality) {
layoutHoldActive = true
layoutHoldStartedAt = timestamp
layoutStableSince = timestamp
layoutSignature = getLayoutSignature(layoutRoot)

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_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 interface RouteEnterMotionOptions {
/** 重页面离场时多保留一个绘制边界,确保目标页起始态已交给 compositor。 */
stagedHandoff?: boolean
}
function shouldSkipRouteEnterMotion() {
const launchScreenActive =
document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg'))
@@ -39,22 +33,18 @@ export function useRouteEnterMotion() {
phase.value = 'idle'
}
function playAfterPaints(animation: Animation, remainingPaints: number, motionEpoch: number) {
function playAfterPaint(animation: Animation, motionEpoch: number) {
if (motionEpoch !== epoch || animation !== activeAnimation) return
if (remainingPaints <= 0) {
phase.value = 'running'
animation.play()
return
}
animationFrame = window.requestAnimationFrame(() => {
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()
if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false
@@ -94,7 +84,7 @@ export function useRouteEnterMotion() {
// cancel() 会拒绝 finishedepoch 已负责丢弃过期事务。
})
playAfterPaints(animation, options.stagedHandoff ? ROUTE_ENTER_STAGED_PAINT_BOUNDARIES : 1, motionEpoch)
playAfterPaint(animation, motionEpoch)
return true
}

View File

@@ -19,26 +19,17 @@ const routeCacheKey = computed(() => {
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
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)
// 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画。
function playPageEnterMotion(
nextPresentation = routePresentationState.value,
previousPresentation?: typeof routePresentationState.value,
) {
function playPageEnterMotion() {
routeEnterMotion.cancel()
if (pagePresentationMotion.start(nextPresentation.key, pageRouteRef.value)) return
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
routeEnterMotion.start(pageRouteRef.value, {
stagedHandoff: previousPresentation?.handoff === 'staged',
})
routeEnterMotion.start(pageRouteRef.value)
}
watch(routePresentationState, playPageEnterMotion, { flush: 'post' })
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
onMounted(playPageEnterMotion)

View File

@@ -50,7 +50,6 @@ const router = createRouter({
component: () => import('../pages/recommend.vue'),
meta: {
keepAlive: true,
pagePresentationHandoff: 'staged',
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,