import { computed, shallowRef, type App, type CSSProperties } from "vue"; import type { RouteLocationRaw, RouteLocationResolved } from "vue-router"; import { animateProgress, prepareMountedView, shouldCommitGesture, springTimeScaleForVelocity, } from "./runtime/animation"; import { DiagnosticChannel } from "./runtime/diagnostics"; import { NativeHistoryLedger } from "./runtime/history-ledger"; import { definePresentation, PresentationRegistry, } from "./runtime/presentations"; import { diagnosticRoute, monotonicNow, siblingDirection, } from "./runtime/route-entry"; import { ViewStore } from "./runtime/view-store"; import { isFailedNavigation, VueRouterBridge, type NavigationResult, } from "./runtime/vue-router-bridge"; import type { NativeDiagnosticEvent, NativeEvictionReason, NativeGestureKind, NativeNavigationOptions, NativePlatformAdapter, NativePresentationDefinition, NativePresentationName, NativeRouterOptions, NativeRouterRuntime, NativeTransaction, NativeViewEntry, } from "./types"; export { definePresentation, isFailedNavigation, shouldCommitGesture, springTimeScaleForVelocity, }; export const nativeRouterKey = Symbol("native-vue-router"); class NativeRouterRuntimeImpl implements NativeRouterRuntime { readonly router; readonly entries; readonly activeKey; readonly transaction; readonly canGoBack; readonly cacheStats; private readonly transactionState = shallowRef( null, ); private readonly history: NativeHistoryLedger; private readonly diagnostics: DiagnosticChannel; private readonly views: ViewStore; private readonly bridge: VueRouterBridge; private readonly presentations: PresentationRegistry; private readonly platform?: NativePlatformAdapter; private transactionSequence = 0; private beginAttemptSequence = 0; private readonly pendingNavigations = new Map< number, Promise >(); private platformCleanup?: () => void; private memoryPressureCleanup?: () => void; constructor(options: NativeRouterOptions) { this.router = options.router; this.platform = options.platform; this.history = new NativeHistoryLedger(); this.diagnostics = new DiagnosticChannel(); this.views = new ViewStore( Math.max(0, options.cache?.maxInactive ?? 4), this.history, this.diagnostics, ); this.presentations = new PresentationRegistry(options.presentations); this.bridge = new VueRouterBridge(this.router, (route) => this.views.acceptRoute(route, this.transactionState.value), ); this.entries = computed(() => this.views.entries.value); this.activeKey = computed(() => this.views.activeKey.value); this.transaction = computed(() => this.transactionState.value); this.cacheStats = this.views.cacheStats; this.canGoBack = computed(() => { const transaction = this.transactionState.value; const committingForwardEntry = transaction && transaction.phase !== "interactive" && transaction.kind !== "pop" && transaction.kind !== "dismiss" && !transaction.replace; return ( Boolean(committingForwardEntry) || this.history.length > 1 || Boolean(this.views.active()?.route.meta.native?.parent) ); }); } install(app: App) { app.provide(nativeRouterKey, this); app.config.globalProperties.$nativeRouter = this; this.bridge.installRouteScope(app); void this.bridge.initialRoute().then((route) => { if (route) this.views.initialize(route); }); 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) { return await this.loadResolvedRoute(this.bridge.resolve(to)); } 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.transactionState.value; if (live) { if (live.phase === "interactive") return null; await this.interruptSettling(live); if (attempt !== this.beginAttemptSequence || this.transactionState.value) return null; } const from = this.views.active(); if (!from) return null; let target: NativeViewEntry; let needsMount = false; const isBack = kind === "pop" || kind === "dismiss"; if (isBack) { const previousKey = this.history.previousKey; const priorEntry = previousKey ? this.views.byKey(previousKey) : undefined; if (!priorEntry) { 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.bridge.resolve(parentLocation), attempt, ); if (this.isStaleAttempt(attempt)) return null; target = this.views.appendPreview(route, true); needsMount = true; } else { target = priorEntry; if (!target.mounted) { const route = await this.loadResolvedRoute( this.bridge.resolve(target.route.fullPath), attempt, ); if (this.isStaleAttempt(attempt)) return null; this.views.revive(target, route); needsMount = true; } } } else { if (!to) return null; const resolved = this.bridge.resolve(to); if (resolved.fullPath === from.route.fullPath) return null; const route = await this.loadResolvedRoute(resolved, attempt); if (this.isStaleAttempt(attempt)) return null; const replace = options.replace ?? route.meta.native?.siblingHistory === "replace"; const prepared = this.views.prepareForward(route, replace); target = prepared.entry; needsMount = prepared.needsMount; } 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 = options.direction ?? (kind === "pop" || kind === "dismiss" ? "back" : kind === "present" ? "up" : kind === "sibling" ? siblingDirection(from.route, target.route) : "forward"); if (isBack) { from.presentation ??= presentation; } else { target.presentation = presentation; target.underlayKey = presentation === "sheet" ? from.key : undefined; } 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 (this.isStaleAttempt(attempt)) return null; this.transactionState.value = transaction; this.diagnostics.emit("transaction-start", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), details: { kind: transaction.kind, direction: transaction.direction, presentation: transaction.presentation, cold: needsMount, }, }); if (needsMount) { const prepareStarted = monotonicNow(); this.diagnostics.emit("view-prepare-start", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), }); await prepareMountedView(); this.diagnostics.emit("view-prepare-end", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), duration: monotonicNow() - prepareStarted, }); if (!this.isCurrent(transaction.id)) return null; } return transaction.id; } updateInteractive(progress: number, velocity = 0) { const current = this.transactionState.value; if (!current || current.phase !== "interactive") return; this.transactionState.value = { ...current, progress: Math.max(0, Math.min(1, progress)), velocity, }; } async finishInteractive(forceCommit?: boolean) { const current = this.transactionState.value; if (!current) return false; const commit = forceCommit ?? shouldCommitGesture(current.progress, current.velocity); if (!commit) { await this.cancelInteractive(); return false; } this.transactionState.value = { ...current, phase: "committing" }; const target = this.views.byKey(current.toKey); this.diagnostics.emit("commit-start", { transactionId: current.id, route: target ? diagnosticRoute(target.route) : undefined, }); void this.platform?.haptic?.("commit"); const navigation = target ? this.bridge.commit(current, target, this.history) : Promise.resolve(true as const); this.pendingNavigations.set(current.id, navigation); const result = navigation.then( (failure) => ({ failed: Boolean(failure) }), () => ({ failed: true }), ); await animateProgress(this.transactionState, 1, current.velocity); const { failed } = await result; this.pendingNavigations.delete(current.id); if (!this.isCurrent(current.id)) return !failed; if (failed) { await animateProgress(this.transactionState, 0, 0); if (this.isCurrent(current.id)) this.finalizeCancelled(current, true); return false; } this.finalizeCommitted(current); return true; } async cancelInteractive() { const current = this.transactionState.value; if (!current || current.phase !== "interactive") return; this.transactionState.value = { ...current, phase: "cancelled" }; await animateProgress(this.transactionState, 0, 0); if (!this.isCurrent(current.id)) return; void this.platform?.haptic?.("cancel"); this.finalizeCancelled(current); } unload(to: RouteLocationRaw) { return this.views.unload( this.bridge.resolve(to).fullPath, this.transactionState.value, ); } trimCache( options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}, ) { this.views.trim(options); } onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) { return this.diagnostics.subscribe(listener); } registerPresentation(definition: NativePresentationDefinition) { this.presentations.register(definition); } presentationFor(name: NativePresentationName) { return this.presentations.get(name); } layerStyle(entry: NativeViewEntry): CSSProperties | undefined { const transaction = this.transactionState.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.bridge.dispose(); this.platformCleanup?.(); this.memoryPressureCleanup?.(); } private isStaleAttempt(attempt: number) { return ( attempt !== this.beginAttemptSequence || Boolean(this.transactionState.value) ); } private clearTransaction() { this.transactionState.value = null; } private isCurrent(id: number) { return this.transactionState.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.transactionState.value = { ...this.transactionState.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.views.byKey(transaction.toKey); this.diagnostics.emit("transaction-end", { transactionId: transaction.id, route: target ? diagnosticRoute(target.route) : undefined, details: { outcome: evictTarget ? "rejected" : "cancelled" }, }); this.clearTransaction(); if (evictTarget) this.views.discardTarget(transaction.toKey, "navigation-rejected"); else this.views.removePreview(transaction.toKey); this.views.markStatuses(null); this.views.enforceCache(); } private finalizeCommitted(transaction: NativeTransaction) { if (!this.isCurrent(transaction.id)) return; const target = this.views.byKey(transaction.toKey); if (target && this.bridge.currentFullPath() !== target.route.fullPath) { this.diagnostics.emit("transaction-end", { transactionId: transaction.id, route: diagnosticRoute(target.route), details: { outcome: "redirected" }, }); this.clearTransaction(); this.views.discardTarget(transaction.toKey, "navigation-rejected"); this.views.markStatuses(null); this.views.enforceCache(); return; } if (target) this.views.commitTarget(target); this.diagnostics.emit("transaction-end", { transactionId: transaction.id, route: target ? diagnosticRoute(target.route) : undefined, details: { outcome: "committed" }, }); this.clearTransaction(); this.views.markStatuses(null); this.views.enforceCache(); } private async loadResolvedRoute( route: RouteLocationResolved, attempt?: number, ) { const started = monotonicNow(); const label = diagnosticRoute(route); this.diagnostics.emit("route-load-start", { attempt, route: label }); try { return await this.bridge.load(route); } finally { this.diagnostics.emit("route-load-end", { attempt, route: label, duration: monotonicNow() - started, }); } } } export function createNativeRouter( options: NativeRouterOptions, ): NativeRouterRuntime { return new NativeRouterRuntimeImpl(options); }