mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-09 07:33:24 +08:00
fix(router): keep route reveal timeline monotonic (#614)
This commit is contained in:
2
env.d.ts
vendored
2
env.d.ts
vendored
@@ -6,8 +6,6 @@ declare module 'vue-router' {
|
||||
subject?: string
|
||||
keepAlive?: boolean
|
||||
keepAliveKey?: string
|
||||
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
|
||||
pagePresentationHandoff?: 'staged'
|
||||
layoutWrapperClasses?: string
|
||||
navActiveLink?: RouteLocationRaw
|
||||
requiresAuth?: boolean
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user