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. 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. 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/) 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 }) => { test('navigates a conversation and returns through the native runtime', async ({ page }) => {
await page.goto('/inbox') await page.goto('/inbox')
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() 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() 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 }) => { test('opens and dismisses the compose sheet', async ({ page }) => {
await page.goto('/inbox') await page.goto('/inbox')
await page.getByRole('button', { name: 'Compose' }).click() 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.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('/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 }) => { 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 ?? '' return registration.active?.scriptURL ?? ''
}) })
expect(workerUrl).toMatch(/\/sw\.js$/) 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 }) => { test('precaches lazily split routes for offline navigation', async ({ page }) => {

View File

@@ -1,18 +1,83 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRegisterSW } from 'virtual:pwa-register/vue' import { useRegisterSW } from 'virtual:pwa-register/vue'
import { useNativeRouter } from '@native-vue-router/core' import { useNativeRouter } from '@native-vue-router/core'
import { recordPwaUpdateState } from '../pwa'
const native = useNativeRouter() 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() { function update() {
reloadPending.value = true
if (!native.transaction.value) void updateServiceWorker(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> </script>
<template> <template>
<aside v-if="needRefresh" class="update-toast" role="status"> <aside v-if="needRefresh || reloadPending" class="update-toast" role="status">
<span>A fresh build is ready.</span> <span>{{ native.transaction.value ? 'A fresh build will open after this gesture.' : 'A fresh build is ready.' }}</span>
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button> <button v-if="needRefresh" type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
</aside> </aside>
</template> </template>

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data' import { useDemoStore } from '../data'
import { pwaEnvironment } from '../pwa' import { pwaBuildId, pwaEnvironment } from '../pwa'
const store = useDemoStore() const store = useDemoStore()
</script> </script>
@@ -17,6 +17,7 @@ const store = useDemoStore()
<h2>PWA environment</h2> <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>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>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> <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-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> <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. 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. 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 ## 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. 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. 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. 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-presentation': transaction?.presentation,
'data-native-direction': transaction?.direction, 'data-native-direction': transaction?.direction,
'data-native-transaction': transaction?.id, 'data-native-transaction': transaction?.id,
'data-native-velocity': transaction ? String(transaction.velocity) : undefined,
}, children) }, children)
} }
}, },
@@ -166,6 +167,7 @@ function createPointerGesture(
let ending = false let ending = false
let bufferedProgress = 0 let bufferedProgress = 0
let bufferedVelocity = 0 let bufferedVelocity = 0
let gestureSign = 0
const down = (event: PointerEvent) => { const down = (event: PointerEvent) => {
if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
@@ -176,6 +178,9 @@ function createPointerGesture(
captured = false captured = false
beginPromise = null beginPromise = null
ending = false ending = false
bufferedProgress = 0
bufferedVelocity = 0
gestureSign = 0
} }
const move = async (event: PointerEvent) => { const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return if (event.pointerId !== pointerId) return
@@ -187,20 +192,22 @@ function createPointerGesture(
if (!direction) return reset() if (!direction) return reset()
captured = true captured = true
ending = false ending = false
gestureSign = Math.sign(dx)
element()?.setPointerCapture(pointerId) element()?.setPointerCapture(pointerId)
beginPromise = begin(direction) beginPromise = begin(direction)
} }
event.preventDefault() event.preventDefault()
const elapsed = Math.max(8, event.timeStamp - lastTime) const elapsed = Math.max(8, event.timeStamp - lastTime)
bufferedVelocity = Math.abs(event.clientX - lastX) / elapsed const width = Math.max(1, element()?.clientWidth ?? window.innerWidth)
bufferedProgress = Math.min(1, Math.abs(dx) / 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 lastX = event.clientX
lastTime = event.timeStamp lastTime = event.timeStamp
const pending = beginPromise const pending = beginPromise
if (pending) { if (pending) {
const id = await pending const id = await pending
if (id === null) return reset()
if (pending !== beginPromise || ending) return if (pending !== beginPromise || ending) return
if (id === null) return reset()
const runtime = injectRuntimeFromElement(element()) const runtime = injectRuntimeFromElement(element())
runtime?.updateInteractive(bufferedProgress, bufferedVelocity) runtime?.updateInteractive(bufferedProgress, bufferedVelocity)
} }
@@ -209,19 +216,27 @@ function createPointerGesture(
if (event.pointerId !== pointerId) return if (event.pointerId !== pointerId) return
ending = true ending = true
const runtime = injectRuntimeFromElement(element()) const runtime = injectRuntimeFromElement(element())
const id = beginPromise ? await beginPromise : null const pending = beginPromise
if (captured && id !== null && runtime) { const shouldFinish = captured
runtime.updateInteractive(bufferedProgress, bufferedVelocity) 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() await runtime.finishInteractive()
} }
reset()
} }
const cancel = async () => { const cancel = async () => {
ending = true ending = true
const runtime = injectRuntimeFromElement(element()) const runtime = injectRuntimeFromElement(element())
const id = beginPromise ? await beginPromise : null const pending = beginPromise
if (captured && id !== null && runtime) await runtime.cancelInteractive() const shouldCancel = captured
reset() reset()
const id = pending ? await pending : null
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id) await runtime.cancelInteractive()
} }
const reset = () => { const reset = () => {
pointerId = -1 pointerId = -1
@@ -230,6 +245,7 @@ function createPointerGesture(
ending = false ending = false
bufferedProgress = 0 bufferedProgress = 0
bufferedVelocity = 0 bufferedVelocity = 0
gestureSign = 0
} }
return { down, move, up, cancel } return { down, move, up, cancel }
} }
@@ -429,32 +445,39 @@ export const NativeDismissGesture = defineComponent({
} }
event.preventDefault() event.preventDefault()
progress = Math.max(0, Math.min(1, dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight))) 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 lastY = event.clientY
lastTime = event.timeStamp lastTime = event.timeStamp
const pending = beginPromise const pending = beginPromise
if (pending) { if (pending) {
const id = await pending const id = await pending
if (id === null) return reset()
if (pending !== beginPromise || ending) return if (pending !== beginPromise || ending) return
if (id === null) return reset()
runtime.updateInteractive(progress, velocity) runtime.updateInteractive(progress, velocity)
} }
} }
const up = async (event: PointerEvent) => { const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return if (event.pointerId !== pointerId) return
ending = true ending = true
const id = beginPromise ? await beginPromise : null const pending = beginPromise
if (captured && id !== null) { const shouldFinish = captured
runtime.updateInteractive(progress, velocity) 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() await runtime.finishInteractive()
} }
reset()
} }
const cancel = async () => { const cancel = async () => {
ending = true ending = true
const id = beginPromise ? await beginPromise : null const pending = beginPromise
if (captured && id !== null) await runtime.cancelInteractive() const shouldCancel = captured
reset() reset()
const id = pending ? await pending : null
if (shouldCancel && id !== null && runtime.transaction.value?.id === id) await runtime.cancelInteractive()
} }
onBeforeUnmount(() => void cancel()) onBeforeUnmount(() => void cancel())
return () => h(props.as, { return () => h(props.as, {

View File

@@ -1,7 +1,12 @@
import { createApp, defineComponent, nextTick } from 'vue' import { createApp, defineComponent, nextTick } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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>' }) const Page = defineComponent({ template: '<div>page</div>' })
@@ -36,9 +41,16 @@ beforeEach(() => {
describe('gesture decisions', () => { describe('gesture decisions', () => {
it('uses progress or a deliberate velocity to commit', () => { it('uses progress or a deliberate velocity to commit', () => {
expect(shouldCommitGesture(0.4, 0)).toBe(true) expect(shouldCommitGesture(0.4, 0)).toBe(true)
expect(shouldCommitGesture(0.12, 0.7)).toBe(true) expect(shouldCommitGesture(0.12, 1.4)).toBe(true)
expect(shouldCommitGesture(0.04, 1.4)).toBe(false) expect(shouldCommitGesture(0.04, 4)).toBe(false)
expect(shouldCommitGesture(0.2, 0.1)).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) { 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) { export function definePresentation(definition: NativePresentationDefinition) {
@@ -555,17 +564,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
} }
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
let position = transaction.progress 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() let previous = now()
const step = (time: number) => { const step = (time: number) => {
const live = this.mutableTransaction.value const live = this.mutableTransaction.value
if (!live || live.id !== transaction.id) return resolve() 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 previous = time
// 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 displacement = target - position
const acceleration = displacement * 280 - velocity * 30 const acceleration = displacement * 280 - velocity * 30
velocity += acceleration * dt velocity += acceleration * dt
position += velocity * dt position += velocity * dt
}
const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02 const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
this.mutableTransaction.value = { this.mutableTransaction.value = {
...live, ...live,

View File

@@ -8,11 +8,24 @@ export default defineConfig({
base: './', base: './',
root: path.resolve(__dirname, 'apps/demo'), root: path.resolve(__dirname, 'apps/demo'),
publicDir: path.resolve(__dirname, 'public'), 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: [ plugins: [
vue(), vue(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: 'prompt', registerType: 'autoUpdate',
devOptions: { devOptions: {
enabled: true, enabled: true,
navigateFallback: 'index.html', navigateFallback: 'index.html',
@@ -39,6 +52,8 @@ export default defineConfig({
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: '/index.html', navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
}, },