Compare commits
2 Commits
49cecad06d
...
d2d82ae6ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
d2d82ae6ae
|
|||
|
ee25b9a94d
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist-ssr
|
|||||||
coverage
|
coverage
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
apps/demo/dev-dist
|
||||||
.vite
|
.vite
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ npm run cap:sync # build and synchronize iOS and Android projects
|
|||||||
|
|
||||||
Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Open or run them from `apps/capacitor` with `npx cap open ios`, `npx cap open android`, or `npx cap run <platform>`.
|
Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Open or run them from `apps/capacitor` with `npx cap open ios`, `npx cap open android`, or `npx cap run <platform>`.
|
||||||
|
|
||||||
|
### Test the installed iOS PWA
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Minimal integration
|
## Minimal integration
|
||||||
|
|
||||||
```ts
|
```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$/)
|
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 }) => {
|
test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({ page }) => {
|
||||||
await page.goto('/stories')
|
await page.goto('/stories')
|
||||||
const routerView = page.locator('.nvr-router-view')
|
const routerView = page.locator('.nvr-router-view')
|
||||||
|
|||||||
85
apps/demo/e2e/pwa.spec.ts
Normal file
85
apps/demo/e2e/pwa.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
const iphoneUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1'
|
||||||
|
|
||||||
|
async function dispatchTouchStart(page: import('@playwright/test').Page, clientX: number) {
|
||||||
|
return await page.locator('.nvr-navigator').evaluate((element, x) => {
|
||||||
|
const event = new Event('touchstart', { bubbles: true, cancelable: true })
|
||||||
|
Object.defineProperty(event, 'touches', {
|
||||||
|
value: [{ identifier: 7, clientX: x, clientY: 240 }],
|
||||||
|
})
|
||||||
|
return { dispatched: element.dispatchEvent(event), prevented: event.defaultPrevented }
|
||||||
|
}, clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ships an installable standalone manifest and iOS metadata', async ({ page, request }) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes')
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-status-bar-style"]')).toHaveAttribute('content', 'black-translucent')
|
||||||
|
const touchIconPath = await page.locator('link[rel="apple-touch-icon"]').evaluate((link) =>
|
||||||
|
new URL(link.getAttribute('href') ?? '', document.baseURI).pathname,
|
||||||
|
)
|
||||||
|
expect(touchIconPath).toBe('/apple-touch-icon.png')
|
||||||
|
|
||||||
|
const manifestResponse = await request.get('/manifest.webmanifest')
|
||||||
|
expect(manifestResponse.ok()).toBe(true)
|
||||||
|
const manifest = await manifestResponse.json()
|
||||||
|
expect(manifest).toMatchObject({ id: '/', scope: '/', start_url: '/', display: 'standalone' })
|
||||||
|
expect(manifest.icons).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ src: '/pwa-192.png', sizes: '192x192', 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('/sw.js')).ok()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => {
|
||||||
|
await page.addInitScript((userAgent) => {
|
||||||
|
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
|
||||||
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||||
|
}, iphoneUserAgent)
|
||||||
|
await page.goto('/chat/maya')
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-pwa-display-mode', 'browser')
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-pwa-edge-guard', 'inactive')
|
||||||
|
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reserves only the leading edge in an installed iOS PWA', async ({ page }) => {
|
||||||
|
await page.addInitScript((userAgent) => {
|
||||||
|
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
|
||||||
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||||
|
}, iphoneUserAgent)
|
||||||
|
await page.goto('/chat/maya')
|
||||||
|
const root = page.locator('html')
|
||||||
|
await expect(root).toHaveAttribute('data-pwa-platform', 'ios')
|
||||||
|
await expect(root).toHaveAttribute('data-pwa-display-mode', 'standalone')
|
||||||
|
await expect(root).toHaveAttribute('data-pwa-edge-guard', 'active')
|
||||||
|
|
||||||
|
expect((await dispatchTouchStart(page, 80)).prevented).toBe(false)
|
||||||
|
expect((await dispatchTouchStart(page, 1)).prevented).toBe(true)
|
||||||
|
await expect(root).toHaveAttribute('data-pwa-edge-claims', '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registers and activates the offline service worker', async ({ page }) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
const workerUrl = await page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
return registration.active?.scriptURL ?? ''
|
||||||
|
})
|
||||||
|
expect(workerUrl).toMatch(/\/sw\.js$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('precaches lazily split routes for offline navigation', async ({ page }) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.evaluate(async () => { await navigator.serviceWorker.ready })
|
||||||
|
const cachedUrls = await page.evaluate(async () => {
|
||||||
|
const urls: string[] = []
|
||||||
|
for (const name of await caches.keys()) {
|
||||||
|
const cache = await caches.open(name)
|
||||||
|
urls.push(...(await cache.keys()).map((request) => request.url))
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
})
|
||||||
|
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true)
|
||||||
|
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true)
|
||||||
|
})
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="NVR Messenger" />
|
<meta name="apple-mobile-web-app-title" content="NVR Messenger" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<script>
|
||||||
|
// Vite emits relative assets for the packaged Electron file:// build.
|
||||||
|
// Web/PWA deep links need those same URLs rooted at the HTTPS origin.
|
||||||
|
if (location.protocol !== 'file:') {
|
||||||
|
const base = document.createElement('base')
|
||||||
|
base.href = '/'
|
||||||
|
document.head.append(base)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" href="/favicon.svg" />
|
<link rel="icon" href="/favicon.svg" />
|
||||||
<title>Native Vue Messenger</title>
|
<title>Native Vue Messenger</title>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function updateEnvironment() {
|
|||||||
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
|
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PwaAdapterOptions {
|
export interface PwaAdapterOptions {
|
||||||
@@ -81,6 +82,7 @@ export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatfor
|
|||||||
if (!touch) return
|
if (!touch) return
|
||||||
reservedTouch = touch.identifier
|
reservedTouch = touch.identifier
|
||||||
state.edgeClaims += 1
|
state.edgeClaims += 1
|
||||||
|
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
const holdEdge = (event: TouchEvent) => {
|
const holdEdge = (event: TouchEvent) => {
|
||||||
|
|||||||
@@ -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.
|
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`.
|
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
|
## Route metadata
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
## PWA and browser
|
## PWA and browser
|
||||||
|
|
||||||
The demo uses a standalone manifest, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. `overscroll-behavior` suppresses pull-to-refresh and history overscroll where supported; `touch-action` reserves horizontal manipulation only on navigator-owned surfaces.
|
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.
|
||||||
|
|
||||||
Mobile operating systems can reserve gestures that web content cannot suppress in every browser mode. The full interaction system targets installed PWAs. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback.
|
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.
|
||||||
|
|
||||||
## Electron
|
## Electron
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"preview": "vite preview",
|
"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",
|
"electron": "npm run build && electron apps/electron/main.mjs",
|
||||||
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync"
|
"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}`],
|
class: ['nvr-view', `nvr-view--${role}`],
|
||||||
style: customStyle,
|
style: customStyle,
|
||||||
'data-native-role': role,
|
'data-native-role': role,
|
||||||
|
'data-native-route': entry.route.fullPath,
|
||||||
'data-native-presentation': transaction?.presentation,
|
'data-native-presentation': transaction?.presentation,
|
||||||
'data-native-direction': transaction?.direction,
|
'data-native-direction': transaction?.direction,
|
||||||
inert: role === 'inactive' ? '' : undefined,
|
inert: role === 'inactive' ? '' : undefined,
|
||||||
@@ -106,6 +107,7 @@ export const NativeRouterView = defineComponent({
|
|||||||
style,
|
style,
|
||||||
'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,
|
||||||
}, children)
|
}, children)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -336,18 +338,25 @@ export const NativeNavigator = defineComponent({
|
|||||||
return logicalDx < 0 ? 'forward' : 'back'
|
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 rect = root.value?.getBoundingClientRect()
|
||||||
const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl'
|
const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl'
|
||||||
const gestureSetting = runtime.router.currentRoute.value.meta.native?.gesture
|
return rect
|
||||||
const atEdge = rect
|
|
||||||
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= props.edgeWidth
|
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= props.edgeWidth
|
||||||
: false
|
: false
|
||||||
candidate = atEdge && gestureSetting !== false && runtime.canGoBack.value
|
}
|
||||||
? 'back'
|
const captureDown = (event: PointerEvent) => {
|
||||||
: props.siblings.length && gestureSetting !== false
|
if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
|
||||||
? 'sibling'
|
if (!atLeadingEdge(event) || !gestureSettingAllowsNavigation() || !runtime.canGoBack.value) return
|
||||||
: null
|
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)
|
if (candidate) gesture.down(event)
|
||||||
}
|
}
|
||||||
return () => h('div', {
|
return () => h('div', {
|
||||||
@@ -356,6 +365,8 @@ export const NativeNavigator = defineComponent({
|
|||||||
if (root.value) runtimeByElement.set(root.value, runtime)
|
if (root.value) runtimeByElement.set(root.value, runtime)
|
||||||
},
|
},
|
||||||
class: 'nvr-navigator',
|
class: 'nvr-navigator',
|
||||||
|
'data-native-can-go-back': String(runtime.canGoBack.value),
|
||||||
|
onPointerdownCapture: captureDown,
|
||||||
onPointerdown: down,
|
onPointerdown: down,
|
||||||
onPointermove: gesture.move,
|
onPointermove: gesture.move,
|
||||||
onPointerup: gesture.up,
|
onPointerup: gesture.up,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ describe('native router transactions', () => {
|
|||||||
await native.cancelInteractive()
|
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()
|
const { router, native } = await harness()
|
||||||
await native.beginInteractive('push', '/b')
|
await native.beginInteractive('push', '/b')
|
||||||
const finishing = native.finishInteractive(true)
|
const finishing = native.finishInteractive(true)
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
private readonly platform?: NativePlatformAdapter
|
private readonly platform?: NativePlatformAdapter
|
||||||
private readonly presentations = new Map<NativePresentationName, NativePresentationDefinition>()
|
private readonly presentations = new Map<NativePresentationName, NativePresentationDefinition>()
|
||||||
private transactionSequence = 0
|
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 removeAfterEach?: () => void
|
||||||
private platformCleanup?: () => void
|
private platformCleanup?: () => void
|
||||||
private pendingPop?: (failure?: NavigationFailure | void) => void
|
private pendingPop?: (failure?: NavigationFailure | void) => void
|
||||||
@@ -94,7 +95,15 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
this.activeKey = computed(() => this.mutableActiveKey.value)
|
this.activeKey = computed(() => this.mutableActiveKey.value)
|
||||||
this.transaction = computed(() => this.mutableTransaction.value)
|
this.transaction = computed(() => this.mutableTransaction.value)
|
||||||
this.canGoBack = computed(() => {
|
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)
|
for (const definition of builtinPresentations) this.registerPresentation(definition)
|
||||||
@@ -131,42 +140,36 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('push', to, options)
|
const id = await this.beginInteractive('push', to, options)
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('push', to, { ...options, replace: true })
|
const id = await this.beginInteractive('push', to, { ...options, replace: true })
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('sibling', to, options)
|
const id = await this.beginInteractive('sibling', to, options)
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async pop() {
|
async pop() {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('pop')
|
const id = await this.beginInteractive('pop')
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
|
async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('present', to, { presentation })
|
const id = await this.beginInteractive('present', to, { presentation })
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async dismiss() {
|
async dismiss() {
|
||||||
await this.waitForIdle()
|
|
||||||
const id = await this.beginInteractive('dismiss')
|
const id = await this.beginInteractive('dismiss')
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
return await this.finishInteractive(true)
|
return await this.finishInteractive(true)
|
||||||
@@ -177,9 +180,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
to?: RouteLocationRaw,
|
to?: RouteLocationRaw,
|
||||||
options: NativeNavigationOptions = {},
|
options: NativeNavigationOptions = {},
|
||||||
) {
|
) {
|
||||||
// A settling/committing navigation is authoritative. Starting another
|
const attempt = ++this.beginAttemptSequence
|
||||||
// transaction here would allow two animations to mutate the same ledger.
|
const live = this.mutableTransaction.value
|
||||||
if (this.mutableTransaction.value) return null
|
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()
|
const from = this.activeEntry()
|
||||||
if (!from) return null
|
if (!from) return null
|
||||||
|
|
||||||
@@ -195,11 +202,14 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
if (!parent) return null
|
if (!parent) return null
|
||||||
const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
|
const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
|
||||||
const route = await this.preload(parentLocation)
|
const route = await this.preload(parentLocation)
|
||||||
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||||
target = entryFor(route, 'preview', true)
|
target = entryFor(route, 'preview', true)
|
||||||
synthetic = true
|
synthetic = true
|
||||||
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
||||||
} else if (!target.mounted) {
|
} 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.mounted = true
|
||||||
target.status = 'inactive'
|
target.status = 'inactive'
|
||||||
this.touchEntries()
|
this.touchEntries()
|
||||||
@@ -209,6 +219,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
const resolved = this.router.resolve(to)
|
const resolved = this.router.resolve(to)
|
||||||
if (resolved.fullPath === from.route.fullPath) return null
|
if (resolved.fullPath === from.route.fullPath) return null
|
||||||
const route = await loadRouteLocation(resolved)
|
const route = await loadRouteLocation(resolved)
|
||||||
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||||
target = this.findReusable(route.fullPath)
|
target = this.findReusable(route.fullPath)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.route = route
|
target.route = route
|
||||||
@@ -250,6 +261,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
replace: options.replace ?? (target.route.meta.native?.siblingHistory === 'replace'),
|
replace: options.replace ?? (target.route.meta.native?.siblingHistory === 'replace'),
|
||||||
sourceRect: options.sourceRect,
|
sourceRect: options.sourceRect,
|
||||||
}
|
}
|
||||||
|
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
|
||||||
this.mutableTransaction.value = transaction
|
this.mutableTransaction.value = transaction
|
||||||
if (synthetic) target.synthetic = true
|
if (synthetic) target.synthetic = true
|
||||||
return transaction.id
|
return transaction.id
|
||||||
@@ -276,45 +288,22 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
|
|
||||||
this.mutableTransaction.value = { ...current, phase: 'committing' }
|
this.mutableTransaction.value = { ...current, phase: 'committing' }
|
||||||
void this.platform?.haptic?.('commit')
|
void this.platform?.haptic?.('commit')
|
||||||
let failed = false
|
|
||||||
try {
|
|
||||||
const navigation = this.commitRoute(current)
|
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)
|
await this.animateProgress(1, current.velocity)
|
||||||
failed = Boolean(await navigation)
|
const { failed } = await result
|
||||||
} catch {
|
this.pendingNavigations.delete(current.id)
|
||||||
await this.animateProgress(0, 0)
|
if (!this.isCurrent(current.id)) return !failed
|
||||||
this.removePreview(current.toKey)
|
|
||||||
this.clearTransaction()
|
|
||||||
this.markStatuses()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
await this.animateProgress(0, 0)
|
await this.animateProgress(0, 0)
|
||||||
this.removePreview(current.toKey)
|
if (this.isCurrent(current.id)) this.finalizeCancelled(current)
|
||||||
this.clearTransaction()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
this.finalizeCommitted(current)
|
||||||
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()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +312,9 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
if (!current || current.phase !== 'interactive') return
|
if (!current || current.phase !== 'interactive') return
|
||||||
this.mutableTransaction.value = { ...current, phase: 'cancelled' }
|
this.mutableTransaction.value = { ...current, phase: 'cancelled' }
|
||||||
await this.animateProgress(0, 0)
|
await this.animateProgress(0, 0)
|
||||||
|
if (!this.isCurrent(current.id)) return
|
||||||
void this.platform?.haptic?.('cancel')
|
void this.platform?.haptic?.('cancel')
|
||||||
this.removePreview(current.toKey)
|
this.finalizeCancelled(current)
|
||||||
this.clearTransaction()
|
|
||||||
this.markStatuses()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPresentation(definition: NativePresentationDefinition) {
|
registerPresentation(definition: NativePresentationDefinition) {
|
||||||
@@ -393,15 +381,61 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
this.mutableEntries.value = [...this.mutableEntries.value]
|
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() {
|
private clearTransaction() {
|
||||||
this.mutableTransaction.value = null
|
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) {
|
private async commitRoute(transaction: NativeTransaction) {
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/pwa-192.png
Normal file
BIN
public/pwa-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/pwa-512.png
Normal file
BIN
public/pwa-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -13,6 +13,11 @@ export default defineConfig({
|
|||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'prompt',
|
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'],
|
includeAssets: ['favicon.svg', 'app-icon.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
id: '/',
|
id: '/',
|
||||||
|
|||||||
Reference in New Issue
Block a user