Fix routing during animation
This commit is contained in:
@@ -86,6 +86,7 @@ export const NativeRouterView = defineComponent({
|
||||
class: ['nvr-view', `nvr-view--${role}`],
|
||||
style: customStyle,
|
||||
'data-native-role': role,
|
||||
'data-native-route': entry.route.fullPath,
|
||||
'data-native-presentation': transaction?.presentation,
|
||||
'data-native-direction': transaction?.direction,
|
||||
inert: role === 'inactive' ? '' : undefined,
|
||||
@@ -106,6 +107,7 @@ export const NativeRouterView = defineComponent({
|
||||
style,
|
||||
'data-native-presentation': transaction?.presentation,
|
||||
'data-native-direction': transaction?.direction,
|
||||
'data-native-transaction': transaction?.id,
|
||||
}, children)
|
||||
}
|
||||
},
|
||||
@@ -336,18 +338,25 @@ export const NativeNavigator = defineComponent({
|
||||
return logicalDx < 0 ? 'forward' : 'back'
|
||||
},
|
||||
)
|
||||
const down = (event: PointerEvent) => {
|
||||
const gestureSettingAllowsNavigation = () => runtime.router.currentRoute.value.meta.native?.gesture !== false
|
||||
const atLeadingEdge = (event: PointerEvent) => {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl'
|
||||
const gestureSetting = runtime.router.currentRoute.value.meta.native?.gesture
|
||||
const atEdge = rect
|
||||
return rect
|
||||
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= props.edgeWidth
|
||||
: false
|
||||
candidate = atEdge && gestureSetting !== false && runtime.canGoBack.value
|
||||
? 'back'
|
||||
: props.siblings.length && gestureSetting !== false
|
||||
? 'sibling'
|
||||
: null
|
||||
}
|
||||
const captureDown = (event: PointerEvent) => {
|
||||
if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
|
||||
if (!atLeadingEdge(event) || !gestureSettingAllowsNavigation() || !runtime.canGoBack.value) return
|
||||
candidate = 'back'
|
||||
gesture.down(event)
|
||||
// The application shell owns the physical back edge. Component-owned
|
||||
// route gestures retain priority everywhere else.
|
||||
event.stopPropagation()
|
||||
}
|
||||
const down = (event: PointerEvent) => {
|
||||
candidate = props.siblings.length && gestureSettingAllowsNavigation() ? 'sibling' : null
|
||||
if (candidate) gesture.down(event)
|
||||
}
|
||||
return () => h('div', {
|
||||
@@ -356,6 +365,8 @@ export const NativeNavigator = defineComponent({
|
||||
if (root.value) runtimeByElement.set(root.value, runtime)
|
||||
},
|
||||
class: 'nvr-navigator',
|
||||
'data-native-can-go-back': String(runtime.canGoBack.value),
|
||||
onPointerdownCapture: captureDown,
|
||||
onPointerdown: down,
|
||||
onPointermove: gesture.move,
|
||||
onPointerup: gesture.up,
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('native router transactions', () => {
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('queues imperative navigation until the current transition settles', async () => {
|
||||
it('accepts imperative navigation as the previous transition finalizes', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.beginInteractive('push', '/b')
|
||||
const finishing = native.finishInteractive(true)
|
||||
|
||||
@@ -81,7 +81,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
private readonly platform?: NativePlatformAdapter
|
||||
private readonly presentations = new Map<NativePresentationName, NativePresentationDefinition>()
|
||||
private transactionSequence = 0
|
||||
private readonly idleWaiters = new Set<() => void>()
|
||||
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
|
||||
@@ -94,7 +95,15 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.activeKey = computed(() => this.mutableActiveKey.value)
|
||||
this.transaction = computed(() => this.mutableTransaction.value)
|
||||
this.canGoBack = computed(() => {
|
||||
return this.mutableHistoryKeys.value.length > 1 || Boolean(this.activeEntry()?.route.meta.native?.parent)
|
||||
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)
|
||||
@@ -131,42 +140,36 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
}
|
||||
|
||||
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('push', to, options)
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||
await this.waitForIdle()
|
||||
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 = {}) {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('sibling', to, options)
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async pop() {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('pop')
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('present', to, { presentation })
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('dismiss')
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
@@ -177,9 +180,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
to?: RouteLocationRaw,
|
||||
options: NativeNavigationOptions = {},
|
||||
) {
|
||||
// A settling/committing navigation is authoritative. Starting another
|
||||
// transaction here would allow two animations to mutate the same ledger.
|
||||
if (this.mutableTransaction.value) return null
|
||||
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
|
||||
|
||||
@@ -195,11 +202,14 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
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) {
|
||||
target.route = await this.preload(target.route.fullPath)
|
||||
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()
|
||||
@@ -209,6 +219,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
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
|
||||
@@ -250,6 +261,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
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
|
||||
@@ -276,45 +288,22 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
|
||||
this.mutableTransaction.value = { ...current, phase: 'committing' }
|
||||
void this.platform?.haptic?.('commit')
|
||||
let failed = false
|
||||
try {
|
||||
const navigation = this.commitRoute(current)
|
||||
await this.animateProgress(1, current.velocity)
|
||||
failed = Boolean(await navigation)
|
||||
} catch {
|
||||
await this.animateProgress(0, 0)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
return false
|
||||
}
|
||||
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)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
if (this.isCurrent(current.id)) this.finalizeCancelled(current)
|
||||
return false
|
||||
}
|
||||
|
||||
const target = this.entryByKey(current.toKey)
|
||||
if (target && this.router.currentRoute.value.fullPath !== target.route.fullPath) {
|
||||
// Vue Router accepted the navigation but redirected it. The redirected
|
||||
// route is already authoritative; unwind the stale visual preview.
|
||||
await this.animateProgress(0, 0)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
return true
|
||||
}
|
||||
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()
|
||||
this.finalizeCommitted(current)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -323,10 +312,9 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
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.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
this.finalizeCancelled(current)
|
||||
}
|
||||
|
||||
registerPresentation(definition: NativePresentationDefinition) {
|
||||
@@ -393,15 +381,61 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.mutableEntries.value = [...this.mutableEntries.value]
|
||||
}
|
||||
|
||||
private waitForIdle() {
|
||||
if (!this.mutableTransaction.value) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => this.idleWaiters.add(resolve))
|
||||
}
|
||||
|
||||
private clearTransaction() {
|
||||
this.mutableTransaction.value = null
|
||||
for (const resolve of this.idleWaiters) resolve()
|
||||
this.idleWaiters.clear()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user