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

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist-ssr
coverage
playwright-report
test-results
apps/demo/dev-dist
.vite
*.local

View File

@@ -29,7 +29,6 @@ The default app is the installable messaging PWA. Other useful commands:
npm run build # packages, declarations, demo, and service worker
npm test # core transaction tests
npm run test:e2e # desktop and mobile Playwright projects
npm run pwa:preview # production PWA on every local network interface
npm run electron # build and launch the Electron host
npm run cap:sync # build and synchronize iOS and Android projects
```
@@ -38,7 +37,7 @@ Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Op
### Test the installed iOS PWA
Build and serve the production app with `npm run pwa:preview`, expose it through an HTTPS URL, and open that URL on the iPhone. Safari requires a secure context for the service worker; a plain LAN `http://` address is not sufficient. Choose **Share → Add to Home Screen**, then launch **NVR Messenger** from its Home Screen icon.
Run the normal `npm run dev` command, expose its printed network address through an HTTPS URL, and open that URL on the iPhone. The development server includes the PWA service worker and already listens on the local network. Safari still requires a secure context for the service worker; a plain LAN `http://` address is not sufficient. Choose **Share → Add to Home Screen**, then launch **NVR Messenger** from its Home Screen icon.
The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view.

View File

@@ -77,6 +77,41 @@ test('uses route order for tab direction and does not animate the active tab', a
await expect(page).toHaveURL(/\/inbox$/)
})
test('interrupts an active tab animation when another tab is tapped', async ({ page }) => {
await page.goto('/inbox')
const routerView = page.locator('.nvr-router-view')
await page.getByRole('link', { name: /Stories/ }).click()
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
const firstTransaction = await routerView.getAttribute('data-native-transaction')
expect(firstTransaction).not.toBeNull()
await page.getByRole('link', { name: /You/ }).click()
await expect(routerView).not.toHaveAttribute('data-native-transaction', firstTransaction!, { timeout: 250 })
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/stories')
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/profile')
await expect(page).toHaveURL(/\/profile$/)
})
test('interrupts a settling push animation with an edge-back gesture', async ({ page }) => {
await page.goto('/inbox')
await page.getByText('Maya Chen').last().click()
await expect(page).toHaveURL(/\/chat\/maya$/)
const routerView = page.locator('.nvr-router-view')
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
const pushTransaction = await routerView.getAttribute('data-native-transaction')
const frame = await page.locator('.app-frame').boundingBox()
if (!frame) throw new Error('App frame did not render')
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
await page.mouse.down()
await page.mouse.move(frame.x + frame.width * 0.34, frame.y + frame.height * 0.5, { steps: 16 })
await expect(routerView).not.toHaveAttribute('data-native-transaction', pushTransaction!, { timeout: 250 })
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/chat/maya')
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/inbox')
await page.mouse.up()
})
test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({ page }) => {
await page.goto('/stories')
const routerView = page.locator('.nvr-router-view')

View File

@@ -16,6 +16,8 @@ The runtime deliberately keeps two ledgers. The navigation stack mirrors committ
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Release uses distance/velocity intent and a damped spring. Reduced-motion mode settles immediately.
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
Component gesture owners stop propagation before a containing navigator can claim the same pointer. Navigator gestures wait for horizontal intent, use pointer capture, respect form controls and `data-native-gesture="ignore"`, and preserve normal vertical scrolling through `touch-action: pan-y`.
## Route metadata

View File

@@ -4,6 +4,8 @@
The demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. The Navigation Lab exposes the live display mode, service-worker state, edge-guard state, and intercepted-touch count.
The normal `npm run dev` server enables the development service worker and listens on local network interfaces; no PWA-specific command is required. iOS still requires the resulting address to be delivered through HTTPS before service-worker and Home Screen behavior is available.
In an installed iOS Home Screen app, the PWA adapter installs non-passive leading-edge touch listeners before the navigator gesture and applies `overscroll-behavior-x: none`. This gives the application the earliest web-content opportunity to claim the sequence. The guard is disabled in normal Safari tabs so the demo does not unexpectedly override browser navigation.
Mobile operating systems can reserve gestures before web content receives them. A PWA cannot set `WKWebView.allowsBackForwardNavigationGestures`, so absolute native-level suppression cannot be guaranteed from JavaScript. The full interaction system targets installed PWAs; use the Capacitor host when that native switch must be deterministic. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback.

View File

@@ -15,7 +15,6 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"preview": "vite preview",
"pwa:preview": "npm run build && vite preview --host 0.0.0.0 --port 4173",
"electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync"
},

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

View File

@@ -13,6 +13,11 @@ export default defineConfig({
tailwindcss(),
VitePWA({
registerType: 'prompt',
devOptions: {
enabled: true,
navigateFallback: 'index.html',
suppressWarnings: true,
},
includeAssets: ['favicon.svg', 'app-icon.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'],
manifest: {
id: '/',