Fix animation stale reference

This commit is contained in:
2026-07-21 16:03:28 +10:00
parent d2d82ae6ae
commit 9af84760fa
12 changed files with 225 additions and 38 deletions

View File

@@ -39,7 +39,7 @@ Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Op
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.
The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active, and shows the exact build ID plus update-check count. Production builds check for updates whenever the app starts, returns to the foreground, regains connectivity, or has been open for a minute; activation reload waits for any live gesture to finish. 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.
An installed web app cannot access `WKWebView.allowsBackForwardNavigationGestures`. The demo therefore reserves leading-edge touch sequences at the web-content boundary as an iOS standalone-only safeguard. Capacitor remains the deterministic option when native-level gesture suppression is required.

View File

@@ -32,6 +32,17 @@ async function waitForTransition(page: Page) {
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
}
async function flickToNextTab(page: Page) {
// Start on the route header, outside conversation-owned drag targets.
const frame = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox()
if (!frame) throw new Error('Active route header did not render')
const y = frame.y + frame.height * 0.5
await page.mouse.move(frame.x + frame.width * 0.68, y)
await page.mouse.down()
await page.mouse.move(frame.x + frame.width * 0.48, y)
await page.mouse.up()
}
test('navigates a conversation and returns through the native runtime', async ({ page }) => {
await page.goto('/inbox')
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
@@ -131,6 +142,26 @@ test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({
await page.mouse.up()
})
test('accepts a second fast tab flick while the first spring is still settling', async ({ page }) => {
await page.goto('/inbox')
const routerView = page.locator('.nvr-router-view')
await flickToNextTab(page)
await expect(page).toHaveURL(/\/stories$/)
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
await flickToNextTab(page)
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
// A stale pointer-up cleanup used to leave an orphaned transaction here,
// permanently blocking both subsequent swipes and imperative tab links.
await page.getByRole('link', { name: /Inbox/ }).click()
await expect(page).toHaveURL(/\/inbox$/)
await waitForTransition(page)
await expect(routerView).not.toHaveAttribute('data-native-transaction')
})
test('opens and dismisses the compose sheet', async ({ page }) => {
await page.goto('/inbox')
await page.getByRole('button', { name: 'Compose' }).click()

View File

@@ -30,7 +30,12 @@ test('ships an installable standalone manifest and iOS metadata', async ({ page,
expect.objectContaining({ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' }),
]))
expect((await request.get('/apple-touch-icon.png')).headers()['content-type']).toContain('image/png')
expect((await request.get('/sw.js')).ok()).toBe(true)
const workerResponse = await request.get('/sw.js')
expect(workerResponse.ok()).toBe(true)
expect(workerResponse.headers()['cache-control']).toContain('no-cache')
const worker = await workerResponse.text()
expect(worker).toContain('self.skipWaiting()')
expect(worker).toContain('clientsClaim()')
})
test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => {
@@ -67,6 +72,7 @@ test('registers and activates the offline service worker', async ({ page }) => {
return registration.active?.scriptURL ?? ''
})
expect(workerUrl).toMatch(/\/sw\.js$/)
await expect(page.locator('html')).not.toHaveAttribute('data-pwa-update-checks', '0')
})
test('precaches lazily split routes for offline navigation', async ({ page }) => {

View File

@@ -1,18 +1,83 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { useNativeRouter } from '@native-vue-router/core'
import { recordPwaUpdateState } from '../pwa'
const native = useNativeRouter()
const { needRefresh, updateServiceWorker } = useRegisterSW()
const reloadPending = ref(false)
let registration: ServiceWorkerRegistration | undefined
let checkTimer: number | undefined
let reloading = false
let checking = false
function reloadWhenIdle() {
if (!reloadPending.value || native.transaction.value || reloading) return
reloading = true
window.location.reload()
}
async function checkForUpdate() {
if (!registration || checking || document.visibilityState === 'hidden' || !navigator.onLine) return
checking = true
recordPwaUpdateState('checking', true)
try {
await registration.update()
if (!reloadPending.value) recordPwaUpdateState('current')
} catch {
recordPwaUpdateState('error')
} finally {
checking = false
}
}
const { needRefresh, updateServiceWorker } = useRegisterSW({
immediate: true,
onRegisteredSW(_workerUrl, workerRegistration) {
registration = workerRegistration
void checkForUpdate()
if (checkTimer !== undefined) window.clearInterval(checkTimer)
checkTimer = window.setInterval(() => void checkForUpdate(), 60_000)
},
onNeedRefresh() {
recordPwaUpdateState('ready')
},
onNeedReload() {
recordPwaUpdateState('ready')
reloadPending.value = true
reloadWhenIdle()
},
onRegisterError() {
recordPwaUpdateState('error')
},
})
function checkWhenActive() {
if (document.visibilityState === 'visible') void checkForUpdate()
}
window.addEventListener('focus', checkWhenActive)
window.addEventListener('online', checkWhenActive)
document.addEventListener('visibilitychange', checkWhenActive)
watch(() => native.transaction.value, reloadWhenIdle, { flush: 'post' })
function update() {
reloadPending.value = true
if (!native.transaction.value) void updateServiceWorker(true)
reloadWhenIdle()
}
onBeforeUnmount(() => {
if (checkTimer !== undefined) window.clearInterval(checkTimer)
window.removeEventListener('focus', checkWhenActive)
window.removeEventListener('online', checkWhenActive)
document.removeEventListener('visibilitychange', checkWhenActive)
})
</script>
<template>
<aside v-if="needRefresh" class="update-toast" role="status">
<span>A fresh build is ready.</span>
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
<aside v-if="needRefresh || reloadPending" class="update-toast" role="status">
<span>{{ native.transaction.value ? 'A fresh build will open after this gesture.' : 'A fresh build is ready.' }}</span>
<button v-if="needRefresh" type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
</aside>
</template>

View File

@@ -6,6 +6,11 @@ interface StandaloneNavigator extends Navigator {
}
type ServiceWorkerState = 'unsupported' | 'installing' | 'ready'
export type PwaUpdateState = 'idle' | 'checking' | 'current' | 'ready' | 'error'
declare const __NVR_BUILD_ID__: string
export const pwaBuildId = __NVR_BUILD_ID__
const state = reactive({
ios: false,
@@ -13,10 +18,19 @@ const state = reactive({
edgeGuard: false,
edgeClaims: 0,
serviceWorker: 'installing' as ServiceWorkerState,
updateState: 'idle' as PwaUpdateState,
updateChecks: 0,
})
export const pwaEnvironment = readonly(state)
export function recordPwaUpdateState(updateState: PwaUpdateState, checked = false) {
state.updateState = updateState
if (checked) state.updateChecks += 1
document.documentElement.dataset.pwaUpdateState = state.updateState
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
}
export function isIOSWebKit() {
const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number }
return /iPad|iPhone|iPod/.test(navigator.userAgent)
@@ -37,6 +51,8 @@ function updateEnvironment() {
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
document.documentElement.dataset.pwaUpdateState = state.updateState
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
}
export interface PwaAdapterOptions {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
import { pwaEnvironment } from '../pwa'
import { pwaBuildId, pwaEnvironment } from '../pwa'
const store = useDemoStore()
</script>
@@ -17,6 +17,7 @@ const store = useDemoStore()
<h2>PWA environment</h2>
<div><span><strong>Display mode</strong><small>Home Screen installation state</small></span><b :class="{ offline: !pwaEnvironment.standalone }">{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}</b></div>
<div><span><strong>Offline worker</strong><small>Cached application shell</small></span><b :class="{ offline: pwaEnvironment.serviceWorker !== 'ready' }">{{ pwaEnvironment.serviceWorker }}</b></div>
<div><span><strong>App build</strong><small>{{ pwaBuildId }}</small></span><b :class="{ offline: pwaEnvironment.updateState === 'error' }">{{ pwaEnvironment.updateState }} · {{ pwaEnvironment.updateChecks }} checks</b></div>
<div><span><strong>iOS edge ownership</strong><small>Leading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}</small></span><b :class="{ offline: !pwaEnvironment.edgeGuard }">{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}</b></div>
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
<p v-else-if="pwaEnvironment.ios">The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.</p>

View File

@@ -14,7 +14,7 @@ Transactions move through `interactive`, `committing`, `settling`, and cancellat
The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.
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.
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. 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.

View File

@@ -2,10 +2,12 @@
## PWA and browser
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 demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Production builds check for updates on startup, focus, foreground resume, network reconnection, and once per minute. A new worker activates automatically; its page reload is deferred until no gesture transaction is active. The Navigation Lab exposes the build ID, update checks, 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.
For a production-style update test, deploy successive `npm run build` outputs at the same HTTPS origin. The service-worker entry file must be served without long-lived HTTP caching; fingerprinted files under `assets/` can remain immutable. An already-installed build that predates the automatic updater may require one final manual refresh or reinstall before it can receive the new update policy.
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

@@ -108,6 +108,7 @@ export const NativeRouterView = defineComponent({
'data-native-presentation': transaction?.presentation,
'data-native-direction': transaction?.direction,
'data-native-transaction': transaction?.id,
'data-native-velocity': transaction ? String(transaction.velocity) : undefined,
}, children)
}
},
@@ -166,6 +167,7 @@ function createPointerGesture(
let ending = false
let bufferedProgress = 0
let bufferedVelocity = 0
let gestureSign = 0
const down = (event: PointerEvent) => {
if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
@@ -176,6 +178,9 @@ function createPointerGesture(
captured = false
beginPromise = null
ending = false
bufferedProgress = 0
bufferedVelocity = 0
gestureSign = 0
}
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return
@@ -187,20 +192,22 @@ function createPointerGesture(
if (!direction) return reset()
captured = true
ending = false
gestureSign = Math.sign(dx)
element()?.setPointerCapture(pointerId)
beginPromise = begin(direction)
}
event.preventDefault()
const elapsed = Math.max(8, event.timeStamp - lastTime)
bufferedVelocity = Math.abs(event.clientX - lastX) / elapsed
bufferedProgress = Math.min(1, Math.abs(dx) / Math.max(1, element()?.clientWidth ?? window.innerWidth))
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth)
bufferedVelocity = ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width)
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width))
lastX = event.clientX
lastTime = event.timeStamp
const pending = beginPromise
if (pending) {
const id = await pending
if (id === null) return reset()
if (pending !== beginPromise || ending) return
if (id === null) return reset()
const runtime = injectRuntimeFromElement(element())
runtime?.updateInteractive(bufferedProgress, bufferedVelocity)
}
@@ -209,19 +216,27 @@ function createPointerGesture(
if (event.pointerId !== pointerId) return
ending = true
const runtime = injectRuntimeFromElement(element())
const id = beginPromise ? await beginPromise : null
if (captured && id !== null && runtime) {
runtime.updateInteractive(bufferedProgress, bufferedVelocity)
const pending = beginPromise
const shouldFinish = captured
const progress = bufferedProgress
const velocity = bufferedVelocity
// Detach this pointer before awaiting preload/navigation/animation work. A
// new gesture may now start without this release callback erasing it.
reset()
const id = pending ? await pending : null
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
runtime.updateInteractive(progress, velocity)
await runtime.finishInteractive()
}
reset()
}
const cancel = async () => {
ending = true
const runtime = injectRuntimeFromElement(element())
const id = beginPromise ? await beginPromise : null
if (captured && id !== null && runtime) await runtime.cancelInteractive()
const pending = beginPromise
const shouldCancel = captured
reset()
const id = pending ? await pending : null
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id) await runtime.cancelInteractive()
}
const reset = () => {
pointerId = -1
@@ -230,6 +245,7 @@ function createPointerGesture(
ending = false
bufferedProgress = 0
bufferedVelocity = 0
gestureSign = 0
}
return { down, move, up, cancel }
}
@@ -429,32 +445,39 @@ export const NativeDismissGesture = defineComponent({
}
event.preventDefault()
progress = Math.max(0, Math.min(1, dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight)))
velocity = Math.max(0, event.clientY - lastY) / Math.max(8, event.timeStamp - lastTime)
const height = Math.max(1, root.value?.clientHeight ?? window.innerHeight)
velocity = ((event.clientY - lastY) * 1000) / (Math.max(8, event.timeStamp - lastTime) * height)
lastY = event.clientY
lastTime = event.timeStamp
const pending = beginPromise
if (pending) {
const id = await pending
if (id === null) return reset()
if (pending !== beginPromise || ending) return
if (id === null) return reset()
runtime.updateInteractive(progress, velocity)
}
}
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return
ending = true
const id = beginPromise ? await beginPromise : null
if (captured && id !== null) {
runtime.updateInteractive(progress, velocity)
const pending = beginPromise
const shouldFinish = captured
const finalProgress = progress
const finalVelocity = velocity
reset()
const id = pending ? await pending : null
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
runtime.updateInteractive(finalProgress, finalVelocity)
await runtime.finishInteractive()
}
reset()
}
const cancel = async () => {
ending = true
const id = beginPromise ? await beginPromise : null
if (captured && id !== null) await runtime.cancelInteractive()
const pending = beginPromise
const shouldCancel = captured
reset()
const id = pending ? await pending : null
if (shouldCancel && id !== null && runtime.transaction.value?.id === id) await runtime.cancelInteractive()
}
onBeforeUnmount(() => void cancel())
return () => h(props.as, {

View File

@@ -1,7 +1,12 @@
import { createApp, defineComponent, nextTick } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNativeRouter, definePresentation, shouldCommitGesture } from './runtime'
import {
createNativeRouter,
definePresentation,
shouldCommitGesture,
springTimeScaleForVelocity,
} from './runtime'
const Page = defineComponent({ template: '<div>page</div>' })
@@ -36,9 +41,16 @@ beforeEach(() => {
describe('gesture decisions', () => {
it('uses progress or a deliberate velocity to commit', () => {
expect(shouldCommitGesture(0.4, 0)).toBe(true)
expect(shouldCommitGesture(0.12, 0.7)).toBe(true)
expect(shouldCommitGesture(0.04, 1.4)).toBe(false)
expect(shouldCommitGesture(0.2, 0.1)).toBe(false)
expect(shouldCommitGesture(0.12, 1.4)).toBe(true)
expect(shouldCommitGesture(0.04, 4)).toBe(false)
expect(shouldCommitGesture(0.2, 0.4)).toBe(false)
})
it('settles a fast flick more quickly without unbounded spring steps', () => {
expect(springTimeScaleForVelocity(0)).toBe(1)
expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6)
expect(springTimeScaleForVelocity(8)).toBe(3)
expect(springTimeScaleForVelocity(-20)).toBe(3)
})
})

View File

@@ -49,7 +49,16 @@ function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry[
}
export function shouldCommitGesture(progress: number, velocity: number, threshold = 0.36) {
return progress >= threshold || (progress >= 0.08 && velocity >= 0.52)
return progress >= threshold || (progress >= 0.08 && velocity >= 1.1)
}
/**
* Converts release velocity (normalized route progress per second) into the
* rate at which the spring is simulated. A deliberate flick can settle up to
* three times faster while a stationary release keeps the baseline spring.
*/
export function springTimeScaleForVelocity(velocity: number) {
return 1 + Math.min(2, Math.abs(velocity) * 0.3)
}
export function definePresentation(definition: NativePresentationDefinition) {
@@ -555,17 +564,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
return new Promise<void>((resolve) => {
let position = transaction.progress
let velocity = Math.max(-2, Math.min(2, initialVelocity))
let velocity = Math.max(-12, Math.min(12, initialVelocity))
const timeScale = springTimeScaleForVelocity(initialVelocity)
let previous = now()
const step = (time: number) => {
const live = this.mutableTransaction.value
if (!live || live.id !== transaction.id) return resolve()
const dt = Math.min(0.032, Math.max(0.001, (time - previous) / 1000))
const elapsed = Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) * timeScale
previous = time
const displacement = target - position
const acceleration = displacement * 280 - velocity * 30
velocity += acceleration * dt
position += velocity * dt
// Substeps keep the spring stable when a high-velocity flick advances
// several frames of simulated time in one display frame.
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)))
const dt = elapsed / iterations
for (let iteration = 0; iteration < iterations; iteration += 1) {
const displacement = target - position
const acceleration = displacement * 280 - velocity * 30
velocity += acceleration * dt
position += velocity * dt
}
const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
this.mutableTransaction.value = {
...live,

View File

@@ -8,11 +8,24 @@ export default defineConfig({
base: './',
root: path.resolve(__dirname, 'apps/demo'),
publicDir: path.resolve(__dirname, 'public'),
define: {
__NVR_BUILD_ID__: JSON.stringify(new Date().toISOString()),
},
server: {
// iOS Safari can otherwise retain a tunnelled development response after
// the dev server has restarted with a new build.
headers: { 'Cache-Control': 'no-store' },
},
preview: {
// Production hosts should apply the same policy at least to index.html and
// sw.js. Fingerprinted assets can use immutable caching when deployed.
headers: { 'Cache-Control': 'no-cache' },
},
plugins: [
vue(),
tailwindcss(),
VitePWA({
registerType: 'prompt',
registerType: 'autoUpdate',
devOptions: {
enabled: true,
navigateFallback: 'index.html',
@@ -39,6 +52,8 @@ export default defineConfig({
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
},