Files
Native-Router-Vue/packages/core/src/runtime.ts

591 lines
20 KiB
TypeScript

import {
computed,
ref,
shallowRef,
type App,
type CSSProperties,
} from 'vue'
import {
isNavigationFailure,
loadRouteLocation,
START_LOCATION,
type NavigationFailure,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
} from 'vue-router'
import type {
NativeDirection,
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 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 >= 0.52)
}
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
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
constructor(options: NativeRouterOptions) {
this.router = options.router
this.maxInactive = options.cache?.maxInactive ?? 8
this.platform = options.platform
this.entries = computed(() => this.mutableEntries.value)
this.activeKey = computed(() => this.mutableActiveKey.value)
this.transaction = computed(() => this.mutableTransaction.value)
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
})
}
}
async preload(to: RouteLocationRaw) {
const resolved = this.router.resolve(to)
return await loadRouteLocation(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
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.preload(parentLocation)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
target = entryFor(route, 'preview', true)
synthetic = true
this.mutableEntries.value = [...this.mutableEntries.value, target]
} else if (!target.mounted) {
const route = await this.preload(target.route.fullPath)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
target.route = route
target.mounted = true
target.status = 'inactive'
this.touchEntries()
}
} else {
if (!to) return null
const resolved = this.router.resolve(to)
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)
if (target) {
target.route = route
target.mounted = true
target.status = 'preview'
target.synthetic = false
target.lastUsed = now()
this.touchEntries()
} else {
target = entryFor(route, 'preview')
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
if (synthetic) target.synthetic = true
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' }
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)
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)
}
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?.()
}
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)
else this.finalizeCommitted(transaction)
}
private finalizeCancelled(transaction: NativeTransaction) {
if (!this.isCurrent(transaction.id)) return
this.removePreview(transaction.toKey)
this.clearTransaction()
this.markStatuses()
}
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.removePreview(transaction.toKey)
this.clearTransaction()
this.markStatuses()
return
}
if (target) {
target.status = 'active'
target.synthetic = false
target.committed = true
target.lastUsed = now()
this.mutableActiveKey.value = target.key
}
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 new Promise<NavigationFailure | void>((resolve) => {
this.pendingPop = resolve
this.router.back()
window.setTimeout(() => {
if (this.pendingPop === resolve) {
this.pendingPop = undefined
resolve()
}
}, 1200)
})
}
return transaction.replace
? await this.router.replace(target.route.fullPath)
: await this.router.push(target.route.fullPath)
}
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()
this.touchEntries()
}
this.mutableActiveKey.value = target.key
this.acceptHistory(target, transaction)
this.markStatuses()
}
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) {
this.mutableHistoryKeys.value = [...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 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'
}
}
this.touchEntries()
}
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(-2, Math.min(2, initialVelocity))
let previous = now()
const step = (time: number) => {
const live = this.mutableTransaction.value
if (!live || live.id !== transaction.id) return resolve()
const dt = Math.min(0.032, Math.max(0.001, (time - previous) / 1000))
previous = time
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))
}