Fix frame pacing
This commit is contained in:
@@ -160,6 +160,56 @@ describe('native router transactions', () => {
|
||||
expect(native.entries.value.some((entry) => entry.route.path === '/right')).toBe(false)
|
||||
})
|
||||
|
||||
it('prepares a newly mounted destination for a paint before animation can begin', async () => {
|
||||
vi.mocked(window.matchMedia).mockReturnValue({ matches: false } as MediaQueryList)
|
||||
let paint: FrameRequestCallback | undefined
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
paint = callback
|
||||
return 1
|
||||
})
|
||||
const { native } = await harness()
|
||||
|
||||
let resolved = false
|
||||
const beginning = native.beginInteractive('push', '/b').then((id) => {
|
||||
resolved = true
|
||||
return id
|
||||
})
|
||||
await vi.waitFor(() => expect(paint).toBeTypeOf('function'))
|
||||
|
||||
expect(resolved).toBe(false)
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/b')?.mounted).toBe(true)
|
||||
paint?.(performance.now())
|
||||
expect(await beginning).not.toBeNull()
|
||||
vi.mocked(window.matchMedia).mockReturnValue({ matches: true } as MediaQueryList)
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('collapses pushed history when a tab replaces it with an existing root', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.push('/b')
|
||||
expect(native.canGoBack.value).toBe(true)
|
||||
|
||||
await native.sibling('/a', { replace: true })
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.canGoBack.value).toBe(false)
|
||||
expect(await native.beginInteractive('pop')).toBeNull()
|
||||
})
|
||||
|
||||
it('manually unloads inactive route instances but never the active view', async () => {
|
||||
const { native } = await harness()
|
||||
await native.replace('/left', { presentation: 'none' })
|
||||
await native.sibling('/middle', { replace: true })
|
||||
|
||||
expect(native.unload('/left')).toBe(1)
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({
|
||||
mounted: false,
|
||||
evictionReason: 'manual',
|
||||
})
|
||||
expect(native.unload('/middle')).toBe(0)
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts the least-recently-used inactive view when the cache limit is exceeded', async () => {
|
||||
let clock = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => ++clock)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
ref,
|
||||
shallowRef,
|
||||
type App,
|
||||
@@ -226,6 +227,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
|
||||
let target: NativeViewEntry | undefined
|
||||
let synthetic = false
|
||||
let needsMount = false
|
||||
const isBack = kind === 'pop' || kind === 'dismiss'
|
||||
|
||||
if (isBack) {
|
||||
@@ -239,6 +241,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||
target = entryFor(route, 'preview', true)
|
||||
synthetic = true
|
||||
needsMount = true
|
||||
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
||||
} else if (!target.mounted) {
|
||||
const route = await this.preload(target.route.fullPath)
|
||||
@@ -247,6 +250,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
target.mounted = true
|
||||
target.status = 'inactive'
|
||||
target.evictionReason = undefined
|
||||
needsMount = true
|
||||
this.touchEntries()
|
||||
}
|
||||
} else {
|
||||
@@ -255,8 +259,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (resolved.fullPath === from.route.fullPath) return null
|
||||
const route = await loadRouteLocation(resolved)
|
||||
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||
target = this.findReusable(route.fullPath)
|
||||
const replace = options.replace ?? (route.meta.native?.siblingHistory === 'replace')
|
||||
target = replace ? this.findHistoryEntry(route.fullPath) : undefined
|
||||
target ??= this.findReusable(route.fullPath)
|
||||
if (target) {
|
||||
needsMount = !target.mounted
|
||||
target.route = route
|
||||
target.mounted = true
|
||||
target.status = 'preview'
|
||||
@@ -266,6 +273,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.touchEntries()
|
||||
} else {
|
||||
target = entryFor(route, 'preview')
|
||||
needsMount = true
|
||||
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
||||
}
|
||||
}
|
||||
@@ -300,6 +308,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||
this.mutableTransaction.value = transaction
|
||||
if (synthetic) target.synthetic = true
|
||||
if (needsMount) {
|
||||
await this.prepareMountedView()
|
||||
if (!this.isCurrent(transaction.id)) return null
|
||||
}
|
||||
return transaction.id
|
||||
}
|
||||
|
||||
@@ -353,6 +365,21 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.finalizeCancelled(current)
|
||||
}
|
||||
|
||||
unload(to: RouteLocationRaw) {
|
||||
const fullPath = this.router.resolve(to).fullPath
|
||||
const transaction = this.mutableTransaction.value
|
||||
let unloaded = 0
|
||||
for (const entry of this.mutableEntries.value) {
|
||||
if (entry.route.fullPath !== fullPath || !entry.mounted) continue
|
||||
if (entry.key === this.mutableActiveKey.value) continue
|
||||
if (entry.key === transaction?.fromKey || entry.key === transaction?.toKey) continue
|
||||
this.evictEntry(entry, 'manual')
|
||||
unloaded += 1
|
||||
}
|
||||
if (unloaded) this.touchEntries()
|
||||
return unloaded
|
||||
}
|
||||
|
||||
trimCache(options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}) {
|
||||
const reason = options.reason ?? 'trimmed'
|
||||
for (const entry of this.mutableEntries.value) {
|
||||
@@ -493,22 +520,33 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (!target) return true
|
||||
if (transaction.kind === 'pop' || transaction.kind === 'dismiss') {
|
||||
if (target.synthetic) return await this.router.replace(target.route.fullPath)
|
||||
return await new Promise<NavigationFailure | void>((resolve) => {
|
||||
this.pendingPop = resolve
|
||||
this.router.back()
|
||||
window.setTimeout(() => {
|
||||
if (this.pendingPop === resolve) {
|
||||
this.pendingPop = undefined
|
||||
resolve()
|
||||
}
|
||||
}, 1200)
|
||||
})
|
||||
return await this.navigateHistory(-1)
|
||||
}
|
||||
if (transaction.replace) {
|
||||
const history = this.mutableHistoryKeys.value
|
||||
const targetIndex = history.lastIndexOf(target.key)
|
||||
if (targetIndex >= 0 && targetIndex < history.length - 1) {
|
||||
return await this.navigateHistory(targetIndex - (history.length - 1))
|
||||
}
|
||||
}
|
||||
return transaction.replace
|
||||
? await this.router.replace(target.route.fullPath)
|
||||
: await this.router.push(target.route.fullPath)
|
||||
}
|
||||
|
||||
private async navigateHistory(delta: number) {
|
||||
return await new Promise<NavigationFailure | void>((resolve) => {
|
||||
this.pendingPop = resolve
|
||||
this.router.go(delta)
|
||||
window.setTimeout(() => {
|
||||
if (this.pendingPop === resolve) {
|
||||
this.pendingPop = undefined
|
||||
resolve()
|
||||
}
|
||||
}, 1200)
|
||||
})
|
||||
}
|
||||
|
||||
private acceptRoute(to: RouteLocationNormalizedLoaded, _from: RouteLocationNormalizedLoaded) {
|
||||
const transaction = this.mutableTransaction.value
|
||||
let target = transaction ? this.entryByKey(transaction.toKey) : undefined
|
||||
@@ -550,7 +588,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
return
|
||||
}
|
||||
if (transaction?.replace) {
|
||||
this.mutableHistoryKeys.value = [...history.slice(0, -1), target.key]
|
||||
const targetIndex = history.lastIndexOf(target.key)
|
||||
this.mutableHistoryKeys.value = targetIndex >= 0
|
||||
? history.slice(0, targetIndex + 1)
|
||||
: [...history.slice(0, -1), target.key]
|
||||
return
|
||||
}
|
||||
const existingIndex = history.lastIndexOf(target.key)
|
||||
@@ -631,6 +672,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.touchEntries()
|
||||
}
|
||||
|
||||
private async prepareMountedView() {
|
||||
await nextTick()
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
private animateProgress(target: number, initialVelocity: number) {
|
||||
const transaction = this.mutableTransaction.value
|
||||
if (!transaction) return Promise.resolve()
|
||||
|
||||
@@ -37,6 +37,19 @@ body,
|
||||
will-change: transform, opacity, border-radius;
|
||||
}
|
||||
|
||||
/* A composited overlay avoids applying a changing CSS filter to an entire
|
||||
route subtree, which is especially expensive in iOS WebKit. */
|
||||
.nvr-view::after {
|
||||
position: absolute;
|
||||
z-index: 2147483646;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
content: '';
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.nvr-view--active {
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -58,25 +71,32 @@ body,
|
||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from,
|
||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from {
|
||||
transform: translate3d(calc(var(--native-progress) * -28%), 0, 0);
|
||||
filter: brightness(calc(1 - var(--native-progress) * .12));
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from::after,
|
||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from::after {
|
||||
opacity: calc(var(--native-progress) * .12);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--to,
|
||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--to {
|
||||
z-index: 4;
|
||||
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, calc(var(--native-progress) * .28));
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--from {
|
||||
z-index: 4;
|
||||
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, calc((1 - var(--native-progress)) * .25));
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to {
|
||||
transform: translate3d(calc((var(--native-progress) - 1) * 28%), 0, 0);
|
||||
filter: brightness(calc(.88 + var(--native-progress) * .12));
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to::after {
|
||||
opacity: calc((1 - var(--native-progress)) * .12);
|
||||
}
|
||||
|
||||
/* Sibling routes are adjacent pages, not a foreground/background stack. */
|
||||
@@ -99,7 +119,6 @@ body,
|
||||
.nvr-router-view[data-native-presentation="slide"] :is(.nvr-view--from, .nvr-view--to) {
|
||||
z-index: 3;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"] .nvr-view--to,
|
||||
@@ -114,7 +133,11 @@ body,
|
||||
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from {
|
||||
transform: scale(calc(1 - var(--native-progress) * .04));
|
||||
border-radius: calc(var(--native-progress) * 18px);
|
||||
filter: brightness(calc(1 - var(--native-progress) * .24));
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from::after,
|
||||
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from::after {
|
||||
opacity: calc(var(--native-progress) * .24);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="fade"] .nvr-view--from {
|
||||
@@ -140,7 +163,6 @@ body,
|
||||
z-index: 4;
|
||||
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
|
||||
border-radius: 22px 22px 0 0;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--to,
|
||||
@@ -148,10 +170,13 @@ body,
|
||||
z-index: 2;
|
||||
transform: scale(calc(.96 + var(--native-progress) * .04));
|
||||
border-radius: calc((1 - var(--native-progress)) * 18px);
|
||||
filter: brightness(calc(.76 + var(--native-progress) * .24));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="modal"], [data-native-presentation="sheet"])[data-native-direction="back"] .nvr-view--to::after {
|
||||
opacity: calc((1 - var(--native-progress)) * .24);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nvr-view { will-change: auto; }
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type NativeEvictionReason =
|
||||
| 'cache-limit'
|
||||
| 'navigation-rejected'
|
||||
| 'popped'
|
||||
| 'manual'
|
||||
| 'trimmed'
|
||||
| 'memory-pressure'
|
||||
|
||||
@@ -161,6 +162,8 @@ export interface NativeRouterRuntime {
|
||||
updateInteractive(progress: number, velocity?: number): void
|
||||
finishInteractive(forceCommit?: boolean): Promise<boolean>
|
||||
cancelInteractive(): Promise<void>
|
||||
/** Unmount every inactive instance matching this location. Active and transitioning views are never unloaded. */
|
||||
unload(to: RouteLocationRaw): number
|
||||
/** Unmount inactive cached views while retaining route/history descriptors. */
|
||||
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void
|
||||
registerPresentation(definition: NativePresentationDefinition): void
|
||||
|
||||
Reference in New Issue
Block a user