Improve caching

This commit is contained in:
2026-07-21 18:45:59 +10:00
parent 92c61abcfe
commit 7d134ff8c9
24 changed files with 519 additions and 105 deletions

View File

@@ -4,11 +4,14 @@ import {
h,
inject,
onBeforeUnmount,
onMounted,
onScopeDispose,
provide,
ref,
shallowReactive,
watch,
type PropType,
type InjectionKey,
type VNode,
} from 'vue'
import {
@@ -24,8 +27,12 @@ import type {
NativeRouterRuntime,
NativeSourceRect,
NativeViewEntry,
NativeViewLifecycle,
NativeViewRole,
} from './types'
const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol('native-view-lifecycle')
export function useNativeRouter() {
const runtime = inject<NativeRouterRuntime>(nativeRouterKey)
if (!runtime) throw new Error('Native Vue Router is not installed. Call app.use(nativeRouter).')
@@ -33,27 +40,103 @@ export function useNativeRouter() {
}
export function useNativeViewLifecycle() {
const runtime = useNativeRouter()
return {
activeKey: runtime.activeKey,
transaction: runtime.transaction,
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey)
if (!lifecycle) throw new Error('Native view lifecycle APIs must be used inside NativeRouterView.')
return lifecycle
}
type NativeViewHook = () => void
function onNativeViewState(source: Readonly<{ value: boolean }>, entering: boolean, hook: NativeViewHook) {
onMounted(() => {
if (entering && source.value) hook()
})
watch(() => source.value, (value, previous) => {
if (value === entering && previous !== entering) hook()
}, { flush: 'sync' })
}
export function onNativeViewActivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, true, hook)
}
export function onNativeViewDeactivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, false, hook)
}
export function onNativeViewShow(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook)
}
export function onNativeViewHide(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook)
}
export function onNativeViewEvict(hook: (reason: NativeViewLifecycle['evictionReason']['value']) => void) {
const lifecycle = useNativeViewLifecycle()
onBeforeUnmount(() => {
if (lifecycle.status.value === 'evicted') hook(lifecycle.evictionReason.value)
})
}
function useNativeViewEffect(
source: Readonly<{ value: boolean }>,
effect: () => void | (() => void),
) {
let cleanup: void | (() => void)
const stopEffect = () => {
cleanup?.()
cleanup = undefined
}
const stopWatch = watch(() => source.value, (enabled) => {
stopEffect()
if (enabled) cleanup = effect()
}, { immediate: true, flush: 'sync' })
onScopeDispose(() => {
stopWatch()
stopEffect()
})
}
/** Runs an effect only while this route is the semantically active route. */
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isActive, effect)
}
/** Runs an effect while this route is active or participating in a transition. */
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect)
}
const NativeRouteScope = defineComponent({
name: 'NativeRouteScope',
props: {
route: { type: Object as PropType<RouteLocationNormalizedLoaded>, required: true },
entryKey: { type: String, required: true },
},
setup(props, { slots }) {
const runtime = useNativeRouter()
const scopedRoute = shallowReactive({ ...props.route }) as RouteLocationNormalizedLoaded
watch(() => props.route, (route) => Object.assign(scopedRoute, route), { immediate: true })
provide(routeLocationKey, scopedRoute)
const entry = computed(() => runtime.entries.value.find((candidate) => candidate.key === props.entryKey))
const role = computed<NativeViewRole>(() => entry.value ? interactiveRole(entry.value, runtime) : 'inactive')
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
key: props.entryKey,
route: computed(() => entry.value?.route ?? props.route),
status: computed(() => entry.value?.status ?? 'evicted'),
role,
isActive: computed(() => runtime.activeKey.value === props.entryKey),
isVisible: computed(() => role.value !== 'inactive'),
isPreview: computed(() => runtime.transaction.value?.toKey === props.entryKey),
isCached: computed(() => Boolean(entry.value?.mounted && role.value === 'inactive')),
evictionReason: computed(() => entry.value?.evictionReason),
})
return () => slots.default?.()
},
})
function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime) {
function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime): NativeViewRole {
const transaction = runtime.transaction.value
if (!transaction) return entry.key === runtime.activeKey.value ? 'active' : 'inactive'
if (entry.key === transaction.fromKey) return 'from'
@@ -96,7 +179,7 @@ export const NativeRouterView = defineComponent({
default: ({ Component, route }: { Component: VNode | null; route: RouteLocationNormalizedLoaded }) => {
if (slots.default) return slots.default({ Component, route, entry })
return Component
? h(NativeRouteScope, { route }, { default: () => Component })
? h(NativeRouteScope, { route, entryKey: entry.key }, { default: () => Component })
: null
},
}),

View File

@@ -21,6 +21,8 @@ async function harness(blockB: boolean | 'redirect' = false) {
{ path: '/left', component: Page, meta: { native: { siblingOrder: 0, siblingHistory: 'replace' } } },
{ path: '/middle', component: Page, meta: { native: { siblingOrder: 1, siblingHistory: 'replace' } } },
{ path: '/right', component: Page, meta: { native: { siblingOrder: 2, siblingHistory: 'replace' } } },
{ path: '/no-cache', component: Page, meta: { native: { cache: false, parent: '/a' } } },
{ path: '/pinned', component: Page, meta: { native: { cache: 'pin', parent: '/a' } } },
],
})
if (blockB) router.beforeEach((to) => to.path === '/b' ? (blockB === 'redirect' ? '/modal' : false) : undefined)
@@ -145,6 +147,97 @@ describe('native router transactions', () => {
await native.cancelInteractive()
})
it('creates sibling views lazily and retains visited replace-style siblings', async () => {
const { native } = await harness()
expect(native.entries.value.map((entry) => entry.route.path)).toEqual(['/a'])
await native.replace('/left', { presentation: 'none' })
expect(native.entries.value.some((entry) => entry.route.path === '/middle')).toBe(false)
await native.sibling('/middle', { replace: true })
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({ mounted: true, status: 'inactive' })
expect(native.entries.value.find((entry) => entry.route.path === '/middle')).toMatchObject({ mounted: true, status: 'active' })
expect(native.entries.value.some((entry) => entry.route.path === '/right')).toBe(false)
})
it('evicts the least-recently-used inactive view when the cache limit is exceeded', async () => {
let clock = 0
vi.spyOn(performance, 'now').mockImplementation(() => ++clock)
const { native } = await harness()
await native.replace('/left', { presentation: 'none' })
await native.sibling('/middle', { replace: true })
await native.sibling('/right', { replace: true })
await native.sibling('/a', { replace: true })
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({
mounted: false,
evictionReason: 'cache-limit',
})
expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true)
expect(native.entries.value.find((entry) => entry.route.path === '/right')?.mounted).toBe(true)
expect(native.cacheStats.value.inactive).toBe(2)
})
it('evicts a pushed route after it is popped out of history', async () => {
const { native } = await harness()
await native.push('/b')
const pushedKey = native.activeKey.value
await native.pop()
expect(native.entries.value.find((entry) => entry.key === pushedKey)).toMatchObject({
mounted: false,
status: 'evicted',
evictionReason: 'popped',
})
})
it('honors cache opt-out even for a route that remains in back history', async () => {
const { native } = await harness()
await native.push('/no-cache')
const noCacheKey = native.activeKey.value
await native.push('/c')
expect(native.entries.value.find((entry) => entry.key === noCacheKey)).toMatchObject({
mounted: false,
evictionReason: 'cache-disabled',
})
expect(await native.beginInteractive('pop')).not.toBeNull()
expect(native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted).toBe(true)
await native.cancelInteractive()
})
it('keeps pinned views during normal trims and releases them when requested', async () => {
const { native } = await harness()
await native.replace('/pinned', { presentation: 'none' })
const pinnedKey = native.activeKey.value
await native.push('/c')
native.trimCache()
expect(native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted).toBe(true)
native.trimCache({ includePinned: true })
expect(native.entries.value.find((entry) => entry.key === pinnedKey)).toMatchObject({
mounted: false,
evictionReason: 'trimmed',
})
})
it('evicts a previously cached target when its guard rejects re-entry', async () => {
const { router, native } = await harness()
await native.replace('/left', { presentation: 'none' })
await native.sibling('/middle', { replace: true })
const cachedLeft = native.entries.value.find((entry) => entry.route.path === '/left')
expect(cachedLeft?.mounted).toBe(true)
const removeGuard = router.beforeEach((to) => to.path === '/left' ? false : undefined)
expect(await native.sibling('/left', { replace: true })).toBe(false)
expect(native.entries.value.find((entry) => entry.key === cachedLeft?.key)).toMatchObject({
mounted: false,
evictionReason: 'navigation-rejected',
})
expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0)
removeGuard()
})
it('does not preview a stale forward entry after pop then push', async () => {
const { native } = await harness()
await native.push('/b')

View File

@@ -15,6 +15,7 @@ import {
} from 'vue-router'
import type {
NativeDirection,
NativeEvictionReason,
NativeGestureKind,
NativeNavigationOptions,
NativePlatformAdapter,
@@ -81,6 +82,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
readonly activeKey
readonly transaction
readonly canGoBack
readonly cacheStats
private readonly mutableEntries = shallowRef<NativeViewEntry[]>([])
private readonly mutableActiveKey = ref('')
@@ -95,14 +97,32 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
private removeAfterEach?: () => void
private platformCleanup?: () => void
private pendingPop?: (failure?: NavigationFailure | void) => void
private totalEvictions = 0
private lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
private memoryPressureCleanup?: () => void
constructor(options: NativeRouterOptions) {
this.router = options.router
this.maxInactive = options.cache?.maxInactive ?? 8
this.maxInactive = Math.max(0, options.cache?.maxInactive ?? 4)
this.platform = options.platform
this.entries = computed(() => this.mutableEntries.value)
this.activeKey = computed(() => this.mutableActiveKey.value)
this.transaction = computed(() => this.mutableTransaction.value)
this.cacheStats = computed(() => {
const entries = this.mutableEntries.value
const mounted = entries.filter((entry) => entry.mounted)
const inactive = mounted.filter((entry) => entry.key !== this.mutableActiveKey.value)
return {
maxInactive: this.maxInactive,
descriptors: entries.length,
mounted: mounted.length,
inactive: inactive.length,
pinned: inactive.filter((entry) => entry.route.meta.native?.cache === 'pin').length,
evicted: entries.filter((entry) => !entry.mounted).length,
totalEvictions: this.totalEvictions,
lastEviction: this.lastEviction,
}
})
this.canGoBack = computed(() => {
const transaction = this.mutableTransaction.value
const committingForwardEntry = transaction
@@ -141,6 +161,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (cleanup) this.platformCleanup = cleanup
})
}
if (typeof window !== 'undefined') {
const trimForPressure = () => this.trimCache({ reason: 'memory-pressure' })
window.addEventListener('memorypressure', trimForPressure)
this.memoryPressureCleanup = () => window.removeEventListener('memorypressure', trimForPressure)
}
}
async preload(to: RouteLocationRaw) {
@@ -221,6 +246,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
target.route = route
target.mounted = true
target.status = 'inactive'
target.evictionReason = undefined
this.touchEntries()
}
} else {
@@ -236,6 +262,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
target.status = 'preview'
target.synthetic = false
target.lastUsed = now()
target.evictionReason = undefined
this.touchEntries()
} else {
target = entryFor(route, 'preview')
@@ -309,7 +336,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (!this.isCurrent(current.id)) return !failed
if (failed) {
await this.animateProgress(0, 0)
if (this.isCurrent(current.id)) this.finalizeCancelled(current)
if (this.isCurrent(current.id)) this.finalizeCancelled(current, true)
return false
}
this.finalizeCommitted(current)
@@ -326,6 +353,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
this.finalizeCancelled(current)
}
trimCache(options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}) {
const reason = options.reason ?? 'trimmed'
for (const entry of this.mutableEntries.value) {
if (entry.key === this.mutableActiveKey.value || !entry.mounted || entry.status !== 'inactive') continue
if (!options.includePinned && entry.route.meta.native?.cache === 'pin') continue
this.evictEntry(entry, reason)
}
this.touchEntries()
}
registerPresentation(definition: NativePresentationDefinition) {
this.presentations.set(definition.name, definition)
}
@@ -350,6 +387,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
dispose() {
this.removeAfterEach?.()
this.platformCleanup?.()
this.memoryPressureCleanup?.()
}
private activeEntry() {
@@ -414,15 +452,17 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
velocity: 0,
phase: 'settling',
}
if (failed) this.finalizeCancelled(transaction)
if (failed) this.finalizeCancelled(transaction, true)
else this.finalizeCommitted(transaction)
}
private finalizeCancelled(transaction: NativeTransaction) {
private finalizeCancelled(transaction: NativeTransaction, evictTarget = false) {
if (!this.isCurrent(transaction.id)) return
this.removePreview(transaction.toKey)
this.clearTransaction()
if (evictTarget) this.discardTarget(transaction.toKey, 'navigation-rejected')
else this.removePreview(transaction.toKey)
this.markStatuses()
this.enforceCache()
}
private finalizeCommitted(transaction: NativeTransaction) {
@@ -430,9 +470,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
const target = this.entryByKey(transaction.toKey)
if (target && this.router.currentRoute.value.fullPath !== target.route.fullPath) {
// A redirect is already authoritative; discard only the stale preview.
this.removePreview(transaction.toKey)
this.clearTransaction()
this.discardTarget(transaction.toKey, 'navigation-rejected')
this.markStatuses()
this.enforceCache()
return
}
if (target) {
@@ -486,11 +527,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
target.status = 'active'
target.committed = true
target.lastUsed = now()
target.evictionReason = undefined
this.touchEntries()
}
this.mutableActiveKey.value = target.key
this.acceptHistory(target, transaction)
this.markStatuses()
if (!transaction) this.enforceCache()
}
private acceptHistory(target: NativeViewEntry, transaction: NativeTransaction | null) {
@@ -542,16 +585,49 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
}
private discardTarget(key: string, reason: NativeEvictionReason) {
const entry = this.entryByKey(key)
if (!entry || entry.key === this.mutableActiveKey.value) return
if (!entry.committed || entry.synthetic) {
this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
return
}
this.evictEntry(entry, reason)
}
private evictEntry(entry: NativeViewEntry, reason: NativeEvictionReason) {
if (!entry.mounted || entry.key === this.mutableActiveKey.value) return
entry.mounted = false
entry.status = 'evicted'
entry.evictionReason = reason
this.totalEvictions += 1
this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason }
}
private shouldRetainInactive(entry: NativeViewEntry) {
const policy = entry.route.meta.native?.cache
if (policy === false) return false
if (policy === 'pin') return true
if (this.mutableHistoryKeys.value.includes(entry.key)) return true
return entry.route.meta.native?.siblingHistory === 'replace'
}
private enforceCache() {
const inactive = this.mutableEntries.value
.filter((entry) => entry.key !== this.mutableActiveKey.value && entry.mounted && entry.status === 'inactive')
.sort((a, b) => b.lastUsed - a.lastUsed)
for (const entry of inactive.slice(this.maxInactive)) {
if (entry.route.meta.native?.cache === false || inactive.length > this.maxInactive) {
entry.mounted = false
entry.status = 'evicted'
for (const entry of inactive) {
if (!this.shouldRetainInactive(entry)) {
const reason: NativeEvictionReason = entry.route.meta.native?.cache === false
? 'cache-disabled'
: 'popped'
this.evictEntry(entry, reason)
}
}
const retained = inactive.filter((entry) => entry.mounted && entry.route.meta.native?.cache !== 'pin')
for (const entry of retained.slice(this.maxInactive)) {
this.evictEntry(entry, 'cache-limit')
}
this.touchEntries()
}

View File

@@ -18,6 +18,15 @@ export type NativePresentationName =
export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss'
export type NativeDirection = 'forward' | 'back' | 'up' | 'down'
export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted'
export type NativeViewRole = 'active' | 'inactive' | 'from' | 'to'
export type NativeCachePolicy = boolean | 'pin'
export type NativeEvictionReason =
| 'cache-disabled'
| 'cache-limit'
| 'navigation-rejected'
| 'popped'
| 'trimmed'
| 'memory-pressure'
export interface NativeRouteOptions {
navigator?: string
@@ -27,7 +36,8 @@ export interface NativeRouteOptions {
siblingGroup?: string
siblingOrder?: number
siblingHistory?: 'push' | 'replace'
cache?: boolean
/** `false` disables retention; `pin` exempts the route from LRU trimming. */
cache?: NativeCachePolicy
gesture?: boolean | 'edge' | 'full'
}
@@ -48,6 +58,30 @@ export interface NativeViewEntry {
lastUsed: number
scrollX: number
scrollY: number
evictionReason?: NativeEvictionReason
}
export interface NativeCacheStats {
maxInactive: number
descriptors: number
mounted: number
inactive: number
pinned: number
evicted: number
totalEvictions: number
lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
}
export interface NativeViewLifecycle {
readonly key: string
readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>
readonly status: Readonly<{ value: NativeViewStatus }>
readonly role: Readonly<{ value: NativeViewRole }>
readonly isActive: Readonly<{ value: boolean }>
readonly isVisible: Readonly<{ value: boolean }>
readonly isPreview: Readonly<{ value: boolean }>
readonly isCached: Readonly<{ value: boolean }>
readonly evictionReason: Readonly<{ value: NativeEvictionReason | undefined }>
}
export interface NativeSourceRect {
@@ -114,6 +148,7 @@ export interface NativeRouterRuntime {
readonly activeKey: Readonly<{ value: string }>
readonly transaction: Readonly<{ value: NativeTransaction | null }>
readonly canGoBack: Readonly<{ value: boolean }>
readonly cacheStats: Readonly<{ value: NativeCacheStats }>
install(app: App): void
push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
@@ -126,6 +161,8 @@ export interface NativeRouterRuntime {
updateInteractive(progress: number, velocity?: number): void
finishInteractive(forceCommit?: boolean): Promise<boolean>
cancelInteractive(): Promise<void>
/** Unmount inactive cached views while retaining route/history descriptors. */
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void
registerPresentation(definition: NativePresentationDefinition): void
presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined
dispose(): void