Fix routing during animation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist-ssr
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
apps/demo/dev-dist
|
||||
.vite
|
||||
*.local
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
Reference in New Issue
Block a user