817 lines
29 KiB
TypeScript
817 lines
29 KiB
TypeScript
import {
|
|
computed,
|
|
nextTick,
|
|
ref,
|
|
shallowRef,
|
|
type App,
|
|
type CSSProperties,
|
|
} from 'vue'
|
|
import {
|
|
isNavigationFailure,
|
|
loadRouteLocation,
|
|
START_LOCATION,
|
|
type NavigationFailure,
|
|
type RouteLocationNormalizedLoaded,
|
|
type RouteLocationRaw,
|
|
type RouteLocationResolved,
|
|
} from 'vue-router'
|
|
import type {
|
|
NativeDirection,
|
|
NativeDiagnosticEvent,
|
|
NativeDiagnosticEventType,
|
|
NativeEvictionReason,
|
|
NativeGestureKind,
|
|
NativeNavigationOptions,
|
|
NativePlatformAdapter,
|
|
NativePresentationDefinition,
|
|
NativePresentationName,
|
|
NativeRouterOptions,
|
|
NativeRouterRuntime,
|
|
NativeTransaction,
|
|
NativeViewEntry,
|
|
} from './types'
|
|
|
|
export const nativeRouterKey = Symbol('native-vue-router')
|
|
|
|
let entrySequence = 0
|
|
|
|
function now() {
|
|
return typeof performance === 'undefined' ? Date.now() : performance.now()
|
|
}
|
|
|
|
function diagnosticRoute(route: RouteLocationNormalizedLoaded | RouteLocationResolved) {
|
|
return route.name != null
|
|
? String(route.name)
|
|
: route.matched.at(-1)?.path ?? route.path
|
|
}
|
|
|
|
function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry['status'], synthetic = false): NativeViewEntry {
|
|
return {
|
|
key: `${route.fullPath}::${++entrySequence}`,
|
|
route,
|
|
status,
|
|
mounted: true,
|
|
synthetic,
|
|
committed: status !== 'preview',
|
|
lastUsed: now(),
|
|
scrollX: 0,
|
|
scrollY: 0,
|
|
}
|
|
}
|
|
|
|
export function shouldCommitGesture(progress: number, velocity: number, threshold = 0.36) {
|
|
return progress >= threshold || (progress >= 0.08 && velocity >= 1.1)
|
|
}
|
|
|
|
/**
|
|
* Converts release velocity (normalized route progress per second) into the
|
|
* rate at which the spring is simulated. A deliberate flick can settle up to
|
|
* three times faster while a stationary release keeps the baseline spring.
|
|
*/
|
|
export function springTimeScaleForVelocity(velocity: number) {
|
|
return 1 + Math.min(2, Math.abs(velocity) * 0.3)
|
|
}
|
|
|
|
export function definePresentation(definition: NativePresentationDefinition) {
|
|
return definition
|
|
}
|
|
|
|
const builtinPresentations: NativePresentationDefinition[] = [
|
|
{ name: 'push', axis: 'x' },
|
|
{ name: 'reveal', axis: 'x' },
|
|
{ name: 'slide', axis: 'x' },
|
|
{ name: 'fade', axis: 'x' },
|
|
{ name: 'modal', axis: 'y' },
|
|
{ name: 'sheet', axis: 'y' },
|
|
{ name: 'none', axis: 'x' },
|
|
]
|
|
|
|
class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|
readonly router
|
|
readonly entries
|
|
readonly activeKey
|
|
readonly transaction
|
|
readonly canGoBack
|
|
readonly cacheStats
|
|
|
|
private readonly mutableEntries = shallowRef<NativeViewEntry[]>([])
|
|
private readonly mutableActiveKey = ref('')
|
|
private readonly mutableTransaction = shallowRef<NativeTransaction | null>(null)
|
|
private readonly mutableHistoryKeys = shallowRef<string[]>([])
|
|
private readonly maxInactive: number
|
|
private readonly platform?: NativePlatformAdapter
|
|
private readonly presentations = new Map<NativePresentationName, NativePresentationDefinition>()
|
|
private transactionSequence = 0
|
|
private beginAttemptSequence = 0
|
|
private readonly pendingNavigations = new Map<number, Promise<NavigationFailure | void | true>>()
|
|
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
|
|
private readonly diagnosticListeners = new Set<(event: NativeDiagnosticEvent) => void>()
|
|
|
|
constructor(options: NativeRouterOptions) {
|
|
this.router = options.router
|
|
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
|
|
&& transaction.phase !== 'interactive'
|
|
&& transaction.kind !== 'pop'
|
|
&& transaction.kind !== 'dismiss'
|
|
&& !transaction.replace
|
|
return Boolean(committingForwardEntry)
|
|
|| this.mutableHistoryKeys.value.length > 1
|
|
|| Boolean(this.activeEntry()?.route.meta.native?.parent)
|
|
})
|
|
|
|
for (const definition of builtinPresentations) this.registerPresentation(definition)
|
|
for (const definition of options.presentations ?? []) this.registerPresentation(definition)
|
|
|
|
this.removeAfterEach = this.router.afterEach((to, from, failure) => {
|
|
if (!failure) this.acceptRoute(to, from)
|
|
this.pendingPop?.(failure)
|
|
this.pendingPop = undefined
|
|
})
|
|
}
|
|
|
|
install(app: App) {
|
|
app.provide(nativeRouterKey, this)
|
|
app.config.globalProperties.$nativeRouter = this
|
|
void this.router.isReady().then(() => {
|
|
if (this.mutableEntries.value.length === 0 && this.router.currentRoute.value !== START_LOCATION) {
|
|
const initial = entryFor(this.router.currentRoute.value, 'active')
|
|
this.mutableEntries.value = [initial]
|
|
this.mutableActiveKey.value = initial.key
|
|
this.mutableHistoryKeys.value = [initial.key]
|
|
}
|
|
})
|
|
if (this.platform?.install) {
|
|
void Promise.resolve(this.platform.install(this)).then((cleanup) => {
|
|
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) {
|
|
const resolved = this.router.resolve(to)
|
|
return await this.loadResolvedRoute(resolved)
|
|
}
|
|
|
|
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
|
const id = await this.beginInteractive('push', to, options)
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
|
const id = await this.beginInteractive('push', to, { ...options, replace: true })
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
|
const id = await this.beginInteractive('sibling', to, options)
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async pop() {
|
|
const id = await this.beginInteractive('pop')
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
|
|
const id = await this.beginInteractive('present', to, { presentation })
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async dismiss() {
|
|
const id = await this.beginInteractive('dismiss')
|
|
if (id === null) return false
|
|
return await this.finishInteractive(true)
|
|
}
|
|
|
|
async beginInteractive(
|
|
kind: NativeGestureKind,
|
|
to?: RouteLocationRaw,
|
|
options: NativeNavigationOptions = {},
|
|
) {
|
|
const attempt = ++this.beginAttemptSequence
|
|
const live = this.mutableTransaction.value
|
|
if (live) {
|
|
if (live.phase === 'interactive') return null
|
|
await this.interruptSettling(live)
|
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
|
}
|
|
const from = this.activeEntry()
|
|
if (!from) return null
|
|
|
|
let target: NativeViewEntry | undefined
|
|
let synthetic = false
|
|
let needsMount = false
|
|
const isBack = kind === 'pop' || kind === 'dismiss'
|
|
|
|
if (isBack) {
|
|
const history = this.mutableHistoryKeys.value
|
|
target = history.length > 1 ? this.entryByKey(history[history.length - 2]) : undefined
|
|
if (!target) {
|
|
const parent = from.route.meta.native?.parent
|
|
if (!parent) return null
|
|
const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
|
|
const route = await this.loadResolvedRoute(this.router.resolve(parentLocation), attempt)
|
|
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.loadResolvedRoute(this.router.resolve(target.route.fullPath), attempt)
|
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
|
target.route = route
|
|
target.mounted = true
|
|
target.status = 'inactive'
|
|
target.evictionReason = undefined
|
|
needsMount = true
|
|
this.touchEntries()
|
|
}
|
|
} else {
|
|
if (!to) return null
|
|
const resolved = this.router.resolve(to)
|
|
if (resolved.fullPath === from.route.fullPath) return null
|
|
const route = await this.loadResolvedRoute(resolved, attempt)
|
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
|
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'
|
|
target.synthetic = false
|
|
target.lastUsed = now()
|
|
target.evictionReason = undefined
|
|
this.touchEntries()
|
|
} else {
|
|
target = entryFor(route, 'preview')
|
|
needsMount = true
|
|
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
|
}
|
|
}
|
|
|
|
const presentationRoute = isBack ? from.route : target.route
|
|
const presentation = options.presentation
|
|
?? presentationRoute.meta.native?.presentation
|
|
?? presentationRoute.meta.native?.transition
|
|
?? (kind === 'present' || kind === 'dismiss' ? 'modal' : kind === 'sibling' ? 'slide' : 'push')
|
|
const direction: NativeDirection = options.direction
|
|
?? (kind === 'pop' || kind === 'dismiss'
|
|
? 'back'
|
|
: kind === 'present'
|
|
? 'up'
|
|
: kind === 'sibling'
|
|
? this.siblingDirection(from.route, target.route)
|
|
: 'forward')
|
|
|
|
const transaction: NativeTransaction = {
|
|
id: ++this.transactionSequence,
|
|
kind,
|
|
direction,
|
|
presentation,
|
|
fromKey: from.key,
|
|
toKey: target.key,
|
|
progress: 0,
|
|
velocity: 0,
|
|
phase: 'interactive',
|
|
replace: options.replace ?? (target.route.meta.native?.siblingHistory === 'replace'),
|
|
sourceRect: options.sourceRect,
|
|
}
|
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
|
this.mutableTransaction.value = transaction
|
|
this.emitDiagnostic('transaction-start', {
|
|
attempt,
|
|
transactionId: transaction.id,
|
|
route: diagnosticRoute(target.route),
|
|
details: {
|
|
kind: transaction.kind,
|
|
direction: transaction.direction,
|
|
presentation: transaction.presentation,
|
|
cold: needsMount,
|
|
},
|
|
})
|
|
if (synthetic) target.synthetic = true
|
|
if (needsMount) {
|
|
const prepareStarted = now()
|
|
this.emitDiagnostic('view-prepare-start', {
|
|
attempt,
|
|
transactionId: transaction.id,
|
|
route: diagnosticRoute(target.route),
|
|
})
|
|
await this.prepareMountedView()
|
|
this.emitDiagnostic('view-prepare-end', {
|
|
attempt,
|
|
transactionId: transaction.id,
|
|
route: diagnosticRoute(target.route),
|
|
duration: now() - prepareStarted,
|
|
})
|
|
if (!this.isCurrent(transaction.id)) return null
|
|
}
|
|
return transaction.id
|
|
}
|
|
|
|
updateInteractive(progress: number, velocity = 0) {
|
|
const current = this.mutableTransaction.value
|
|
if (!current || current.phase !== 'interactive') return
|
|
this.mutableTransaction.value = {
|
|
...current,
|
|
progress: Math.max(0, Math.min(1, progress)),
|
|
velocity,
|
|
}
|
|
}
|
|
|
|
async finishInteractive(forceCommit?: boolean) {
|
|
const current = this.mutableTransaction.value
|
|
if (!current) return false
|
|
const commit = forceCommit ?? shouldCommitGesture(current.progress, current.velocity)
|
|
if (!commit) {
|
|
await this.cancelInteractive()
|
|
return false
|
|
}
|
|
|
|
this.mutableTransaction.value = { ...current, phase: 'committing' }
|
|
this.emitDiagnostic('commit-start', {
|
|
transactionId: current.id,
|
|
route: this.entryByKey(current.toKey) ? diagnosticRoute(this.entryByKey(current.toKey)!.route) : undefined,
|
|
})
|
|
void this.platform?.haptic?.('commit')
|
|
const navigation = this.commitRoute(current)
|
|
this.pendingNavigations.set(current.id, navigation)
|
|
const result = navigation.then(
|
|
(failure) => ({ failed: Boolean(failure) }),
|
|
() => ({ failed: true }),
|
|
)
|
|
await this.animateProgress(1, current.velocity)
|
|
const { failed } = await result
|
|
this.pendingNavigations.delete(current.id)
|
|
if (!this.isCurrent(current.id)) return !failed
|
|
if (failed) {
|
|
await this.animateProgress(0, 0)
|
|
if (this.isCurrent(current.id)) this.finalizeCancelled(current, true)
|
|
return false
|
|
}
|
|
this.finalizeCommitted(current)
|
|
return true
|
|
}
|
|
|
|
async cancelInteractive() {
|
|
const current = this.mutableTransaction.value
|
|
if (!current || current.phase !== 'interactive') return
|
|
this.mutableTransaction.value = { ...current, phase: 'cancelled' }
|
|
await this.animateProgress(0, 0)
|
|
if (!this.isCurrent(current.id)) return
|
|
void this.platform?.haptic?.('cancel')
|
|
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) {
|
|
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()
|
|
}
|
|
|
|
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) {
|
|
this.diagnosticListeners.add(listener)
|
|
return () => this.diagnosticListeners.delete(listener)
|
|
}
|
|
|
|
registerPresentation(definition: NativePresentationDefinition) {
|
|
this.presentations.set(definition.name, definition)
|
|
}
|
|
|
|
presentationFor(name: NativePresentationName) {
|
|
return this.presentations.get(name)
|
|
}
|
|
|
|
layerStyle(entry: NativeViewEntry): CSSProperties | undefined {
|
|
const transaction = this.mutableTransaction.value
|
|
if (!transaction) return undefined
|
|
const role = entry.key === transaction.fromKey ? 'from' : entry.key === transaction.toKey ? 'to' : undefined
|
|
if (!role) return undefined
|
|
return this.presentationFor(transaction.presentation)?.layerStyle?.({
|
|
progress: transaction.progress,
|
|
role,
|
|
direction: transaction.direction,
|
|
sourceRect: transaction.sourceRect,
|
|
})
|
|
}
|
|
|
|
dispose() {
|
|
this.removeAfterEach?.()
|
|
this.platformCleanup?.()
|
|
this.memoryPressureCleanup?.()
|
|
}
|
|
|
|
private activeEntry() {
|
|
return this.entryByKey(this.mutableActiveKey.value)
|
|
}
|
|
|
|
private activeIndex() {
|
|
return this.mutableEntries.value.findIndex((entry) => entry.key === this.mutableActiveKey.value)
|
|
}
|
|
|
|
private entryByKey(key: string) {
|
|
return this.mutableEntries.value.find((entry) => entry.key === key)
|
|
}
|
|
|
|
private findReusable(fullPath: string) {
|
|
return [...this.mutableEntries.value].reverse().find((entry) =>
|
|
entry.route.fullPath === fullPath
|
|
&& entry.key !== this.mutableActiveKey.value
|
|
&& !this.mutableHistoryKeys.value.includes(entry.key),
|
|
)
|
|
}
|
|
|
|
private findHistoryEntry(fullPath: string) {
|
|
for (const key of [...this.mutableHistoryKeys.value].reverse()) {
|
|
const entry = this.entryByKey(key)
|
|
if (entry?.route.fullPath === fullPath) return entry
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
private siblingDirection(from: RouteLocationNormalizedLoaded, to: RouteLocationNormalizedLoaded): NativeDirection {
|
|
const fromOrder = from.meta.native?.siblingOrder
|
|
const toOrder = to.meta.native?.siblingOrder
|
|
return typeof fromOrder === 'number' && typeof toOrder === 'number' && toOrder < fromOrder ? 'back' : 'forward'
|
|
}
|
|
|
|
private touchEntries() {
|
|
this.mutableEntries.value = [...this.mutableEntries.value]
|
|
}
|
|
|
|
private clearTransaction() {
|
|
this.mutableTransaction.value = null
|
|
}
|
|
|
|
private isCurrent(id: number) {
|
|
return this.mutableTransaction.value?.id === id
|
|
}
|
|
|
|
private async interruptSettling(transaction: NativeTransaction) {
|
|
if (!this.isCurrent(transaction.id) || transaction.phase === 'interactive') return
|
|
const navigation = this.pendingNavigations.get(transaction.id)
|
|
if (!navigation) {
|
|
this.finalizeCancelled(transaction)
|
|
return
|
|
}
|
|
const failed = await navigation.then((failure) => Boolean(failure), () => true)
|
|
this.pendingNavigations.delete(transaction.id)
|
|
if (!this.isCurrent(transaction.id)) return
|
|
this.mutableTransaction.value = {
|
|
...this.mutableTransaction.value!,
|
|
progress: failed ? 0 : 1,
|
|
velocity: 0,
|
|
phase: 'settling',
|
|
}
|
|
if (failed) this.finalizeCancelled(transaction, true)
|
|
else this.finalizeCommitted(transaction)
|
|
}
|
|
|
|
private finalizeCancelled(transaction: NativeTransaction, evictTarget = false) {
|
|
if (!this.isCurrent(transaction.id)) return
|
|
const target = this.entryByKey(transaction.toKey)
|
|
this.emitDiagnostic('transaction-end', {
|
|
transactionId: transaction.id,
|
|
route: target ? diagnosticRoute(target.route) : undefined,
|
|
details: { outcome: evictTarget ? 'rejected' : 'cancelled' },
|
|
})
|
|
this.clearTransaction()
|
|
if (evictTarget) this.discardTarget(transaction.toKey, 'navigation-rejected')
|
|
else this.removePreview(transaction.toKey)
|
|
this.markStatuses()
|
|
this.enforceCache()
|
|
}
|
|
|
|
private finalizeCommitted(transaction: NativeTransaction) {
|
|
if (!this.isCurrent(transaction.id)) return
|
|
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.emitDiagnostic('transaction-end', {
|
|
transactionId: transaction.id,
|
|
route: diagnosticRoute(target.route),
|
|
details: { outcome: 'redirected' },
|
|
})
|
|
this.clearTransaction()
|
|
this.discardTarget(transaction.toKey, 'navigation-rejected')
|
|
this.markStatuses()
|
|
this.enforceCache()
|
|
return
|
|
}
|
|
if (target) {
|
|
target.status = 'active'
|
|
target.synthetic = false
|
|
target.committed = true
|
|
target.lastUsed = now()
|
|
this.mutableActiveKey.value = target.key
|
|
}
|
|
this.emitDiagnostic('transaction-end', {
|
|
transactionId: transaction.id,
|
|
route: target ? diagnosticRoute(target.route) : undefined,
|
|
details: { outcome: 'committed' },
|
|
})
|
|
this.clearTransaction()
|
|
this.markStatuses()
|
|
this.enforceCache()
|
|
}
|
|
|
|
private async commitRoute(transaction: NativeTransaction) {
|
|
const target = this.entryByKey(transaction.toKey)
|
|
if (!target) return true
|
|
if (transaction.kind === 'pop' || transaction.kind === 'dismiss') {
|
|
if (target.synthetic) return await this.router.replace(target.route.fullPath)
|
|
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
|
|
if (target && target.route.fullPath !== to.fullPath) target = undefined
|
|
if (!transaction) target ??= this.findHistoryEntry(to.fullPath)
|
|
target ??= this.findReusable(to.fullPath)
|
|
if (!target && this.activeEntry()?.route.fullPath === to.fullPath) target = this.activeEntry()
|
|
if (!target) {
|
|
target = entryFor(to, 'active')
|
|
const activeIndex = this.activeIndex()
|
|
const head = activeIndex >= 0 ? this.mutableEntries.value.slice(0, activeIndex + 1) : this.mutableEntries.value
|
|
this.mutableEntries.value = [...head, target]
|
|
} else {
|
|
target.route = to
|
|
target.mounted = true
|
|
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) {
|
|
const history = this.mutableHistoryKeys.value
|
|
if (!history.length) {
|
|
this.mutableHistoryKeys.value = [target.key]
|
|
return
|
|
}
|
|
if (transaction?.kind === 'pop' || transaction?.kind === 'dismiss') {
|
|
const targetIndex = history.lastIndexOf(target.key)
|
|
this.mutableHistoryKeys.value = targetIndex >= 0
|
|
? history.slice(0, targetIndex + 1)
|
|
: [...history.slice(0, -1), target.key]
|
|
return
|
|
}
|
|
if (transaction?.replace) {
|
|
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)
|
|
this.mutableHistoryKeys.value = transaction
|
|
? [...history, target.key]
|
|
: existingIndex >= 0
|
|
? history.slice(0, existingIndex + 1)
|
|
: [...history, target.key]
|
|
}
|
|
|
|
private markStatuses() {
|
|
const transaction = this.mutableTransaction.value
|
|
for (const entry of this.mutableEntries.value) {
|
|
if (entry.key === this.mutableActiveKey.value) entry.status = 'active'
|
|
else if (transaction?.toKey === entry.key && entry.status === 'preview') entry.status = 'preview'
|
|
else if (entry.mounted) entry.status = 'inactive'
|
|
else entry.status = 'evicted'
|
|
}
|
|
this.touchEntries()
|
|
}
|
|
|
|
private removePreview(key: string) {
|
|
const entry = this.entryByKey(key)
|
|
if (!entry || entry.key === this.mutableActiveKey.value) return
|
|
if (entry.status === 'preview' || entry.synthetic) {
|
|
if (entry.synthetic || !entry.committed) {
|
|
this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
|
|
} else {
|
|
entry.status = 'inactive'
|
|
this.touchEntries()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
this.emitDiagnostic('view-evicted', {
|
|
route: diagnosticRoute(entry.route),
|
|
details: { 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) {
|
|
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()
|
|
}
|
|
|
|
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 async loadResolvedRoute(route: RouteLocationResolved, attempt?: number) {
|
|
const started = now()
|
|
const label = diagnosticRoute(route)
|
|
this.emitDiagnostic('route-load-start', { attempt, route: label })
|
|
try {
|
|
return await loadRouteLocation(route)
|
|
} finally {
|
|
this.emitDiagnostic('route-load-end', {
|
|
attempt,
|
|
route: label,
|
|
duration: now() - started,
|
|
})
|
|
}
|
|
}
|
|
|
|
private emitDiagnostic(
|
|
type: NativeDiagnosticEventType,
|
|
event: Omit<NativeDiagnosticEvent, 'type' | 'timestamp'> = {},
|
|
) {
|
|
if (!this.diagnosticListeners.size) return
|
|
const diagnostic = { type, timestamp: now(), ...event }
|
|
for (const listener of this.diagnosticListeners) listener(diagnostic)
|
|
}
|
|
|
|
private animateProgress(target: number, initialVelocity: number) {
|
|
const transaction = this.mutableTransaction.value
|
|
if (!transaction) return Promise.resolve()
|
|
if (typeof window === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
this.mutableTransaction.value = { ...transaction, progress: target, velocity: 0, phase: 'settling' }
|
|
return Promise.resolve()
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
let position = transaction.progress
|
|
let velocity = Math.max(-12, Math.min(12, initialVelocity))
|
|
const timeScale = springTimeScaleForVelocity(initialVelocity)
|
|
let previous = now()
|
|
const step = (time: number) => {
|
|
const live = this.mutableTransaction.value
|
|
if (!live || live.id !== transaction.id) return resolve()
|
|
const elapsed = Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) * timeScale
|
|
previous = time
|
|
// Substeps keep the spring stable when a high-velocity flick advances
|
|
// several frames of simulated time in one display frame.
|
|
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)))
|
|
const dt = elapsed / iterations
|
|
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
|
const displacement = target - position
|
|
const acceleration = displacement * 280 - velocity * 30
|
|
velocity += acceleration * dt
|
|
position += velocity * dt
|
|
}
|
|
const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
|
|
this.mutableTransaction.value = {
|
|
...live,
|
|
progress: done ? target : Math.max(0, Math.min(1, position)),
|
|
velocity,
|
|
phase: 'settling',
|
|
}
|
|
if (done) resolve()
|
|
else requestAnimationFrame(step)
|
|
}
|
|
requestAnimationFrame(step)
|
|
})
|
|
}
|
|
}
|
|
|
|
export function createNativeRouter(options: NativeRouterOptions): NativeRouterRuntime {
|
|
return new NativeRouterRuntimeImpl(options)
|
|
}
|
|
|
|
export function isFailedNavigation(value: unknown) {
|
|
return Boolean(value && isNavigationFailure(value))
|
|
}
|