Fix animation stale reference
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user