From 9b3388f45f784ca62aa9b40b3e0079332383fbdb Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:40:00 +0800 Subject: [PATCH] fix(router): keep route reveal timeline monotonic (#614) --- env.d.ts | 2 -- .../usePagePresentationMotion.spec.ts | 32 +++++++++++++++++++ .../__tests__/useRouteEnterMotion.spec.ts | 11 ------- src/composables/usePagePresentationMotion.ts | 7 +++- src/composables/useRouteEnterMotion.ts | 22 ++++--------- src/layouts/default.vue | 17 +++------- src/router/index.ts | 1 - 7 files changed, 48 insertions(+), 44 deletions(-) diff --git a/env.d.ts b/env.d.ts index 32e8f68c..27d15a17 100644 --- a/env.d.ts +++ b/env.d.ts @@ -6,8 +6,6 @@ declare module 'vue-router' { subject?: string keepAlive?: boolean keepAliveKey?: string - /** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */ - pagePresentationHandoff?: 'staged' layoutWrapperClasses?: string navActiveLink?: RouteLocationRaw requiresAuth?: boolean diff --git a/src/composables/__tests__/usePagePresentationMotion.spec.ts b/src/composables/__tests__/usePagePresentationMotion.spec.ts index 3f68445b..6f144ba2 100644 --- a/src/composables/__tests__/usePagePresentationMotion.spec.ts +++ b/src/composables/__tests__/usePagePresentationMotion.spec.ts @@ -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') diff --git a/src/composables/__tests__/useRouteEnterMotion.spec.ts b/src/composables/__tests__/useRouteEnterMotion.spec.ts index d88e0f6b..4e0ea8cf 100644 --- a/src/composables/__tests__/useRouteEnterMotion.spec.ts +++ b/src/composables/__tests__/useRouteEnterMotion.spec.ts @@ -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() diff --git a/src/composables/usePagePresentationMotion.ts b/src/composables/usePagePresentationMotion.ts index dcaa862b..72014600 100644 --- a/src/composables/usePagePresentationMotion.ts +++ b/src/composables/usePagePresentationMotion.ts @@ -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) diff --git a/src/composables/useRouteEnterMotion.ts b/src/composables/useRouteEnterMotion.ts index 924801be..aa867e96 100644 --- a/src/composables/useRouteEnterMotion.ts +++ b/src/composables/useRouteEnterMotion.ts @@ -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() 会拒绝 finished;epoch 已负责丢弃过期事务。 }) - playAfterPaints(animation, options.stagedHandoff ? ROUTE_ENTER_STAGED_PAINT_BOUNDARIES : 1, motionEpoch) + playAfterPaint(animation, motionEpoch) return true } diff --git a/src/layouts/default.vue b/src/layouts/default.vue index 41e2be69..59028efc 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -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(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) diff --git a/src/router/index.ts b/src/router/index.ts index 8e6d024e..72434d22 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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,