From d2d82ae6aeeebf8eb571352dd341d6457170bbdc Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Tue, 21 Jul 2026 15:31:43 +1000 Subject: [PATCH] Fix routing during animation --- .gitignore | 1 + README.md | 3 +- apps/demo/e2e/navigation.spec.ts | 35 +++++++ docs/architecture.md | 2 + docs/platforms.md | 2 + package.json | 1 - packages/core/src/components.ts | 27 ++++-- packages/core/src/runtime.test.ts | 2 +- packages/core/src/runtime.ts | 148 ++++++++++++++++++------------ vite.config.ts | 5 + 10 files changed, 157 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index 8feeaef..c74b11c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr coverage playwright-report test-results +apps/demo/dev-dist .vite *.local diff --git a/README.md b/README.md index 893a6ec..2819872 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts index 119abb1..9eddd40 100644 --- a/apps/demo/e2e/navigation.spec.ts +++ b/apps/demo/e2e/navigation.spec.ts @@ -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') diff --git a/docs/architecture.md b/docs/architecture.md index d9f30c6..37e1cd7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/platforms.md b/docs/platforms.md index 0cb5e26..e35354a 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -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. diff --git a/package.json b/package.json index 14da7ce..e78dab8 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/core/src/components.ts b/packages/core/src/components.ts index a6112d3..a6b91a7 100644 --- a/packages/core/src/components.ts +++ b/packages/core/src/components.ts @@ -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, diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index 010bfee..0ac47cd 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -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) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 0a8a270..dcad0f4 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -81,7 +81,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { private readonly platform?: NativePlatformAdapter private readonly presentations = new Map() private transactionSequence = 0 - private readonly idleWaiters = new Set<() => void>() + private beginAttemptSequence = 0 + private readonly pendingNavigations = new Map>() 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((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) { diff --git a/vite.config.ts b/vite.config.ts index a5cec7f..78c67c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: '/',