Fix routing during animation

This commit is contained in:
2026-07-21 15:31:43 +10:00
parent ee25b9a94d
commit d2d82ae6ae
10 changed files with 157 additions and 69 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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) {