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([]) private readonly mutableActiveKey = ref('') private readonly mutableTransaction = shallowRef(null) private readonly mutableHistoryKeys = shallowRef([]) private readonly maxInactive: number private readonly platform?: NativePlatformAdapter private readonly presentations = new Map() private transactionSequence = 0 private beginAttemptSequence = 0 private readonly pendingNavigations = new Map>() 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((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((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)) }