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([]); 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< NativePresentationName, NativePresentationDefinition >(); private transactionSequence = 0; private beginAttemptSequence = 0; private readonly pendingNavigations = new Map< number, Promise >(); 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((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((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 = {}, ) { 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((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)); }