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() private transactionSequence = 0 private beginAttemptSequence = 0 private readonly pendingNavigations = new Map>() 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)) }