Add docs and further stress tests to the demo

This commit is contained in:
2026-07-21 17:56:58 +10:00
parent 9af84760fa
commit 92c61abcfe
15 changed files with 752 additions and 14 deletions

View File

@@ -41,6 +41,8 @@ Run the normal `npm run dev` command, expose its printed network address through
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.
For more aggressive lifecycle testing, open **You → Runtime stress lab**. It is a deeper route that keeps the primary tab bar, opts into push-style sibling history, exposes how long its route component has remained mounted, and renders a one-second async child through `<Suspense>`. Return to You, enable **Block cached lab re-entry**, and try opening it again to exercise an asynchronous route guard against an already-mounted cached destination.
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
@@ -94,7 +96,13 @@ Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is de
- `@native-vue-router/capacitor` — hardware back, deep links, pause cancellation, root exit, and haptics.
- `@native-vue-router/electron` — Chromium history-gesture suppression and renderer back/forward bridging.
See [architecture](docs/architecture.md) and [platform integration](docs/platforms.md) for the transaction lifecycle and host-specific behavior.
Design and engineering documentation:
- [How it works and why the pattern is uncommon](docs/how-it-works.md)
- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
- [Architecture reference](docs/architecture.md)
- [Platform integration reference](docs/platforms.md)
## Support contract

View File

@@ -32,14 +32,19 @@ async function waitForTransition(page: Page) {
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
}
async function flickToNextTab(page: Page) {
async function flickToNextTab(page: Page, leaveSlowSpring = false) {
// 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)
const surface = await page.locator('.nvr-router-view').boundingBox()
const header = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox()
if (!surface || !header) throw new Error('Active route header did not render')
const y = header.y + header.height * 0.5
await page.mouse.move(surface.x + surface.width * 0.72, y)
await page.mouse.down()
await page.mouse.move(frame.x + frame.width * 0.48, y)
if (leaveSlowSpring) {
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 })
await page.waitForTimeout(90)
}
await page.mouse.move(surface.x + surface.width * 0.27, y)
await page.mouse.up()
}
@@ -146,7 +151,9 @@ test('accepts a second fast tab flick while the first spring is still settling',
await page.goto('/inbox')
const routerView = page.locator('.nvr-router-view')
await flickToNextTab(page)
// Commit by distance with a deliberately slow final sample, leaving enough
// baseline spring for the second fast gesture to interrupt deterministically.
await flickToNextTab(page, true)
await expect(page).toHaveURL(/\/stories$/)
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
@@ -162,6 +169,61 @@ test('accepts a second fast tab flick while the first spring is still settling',
await expect(routerView).not.toHaveAttribute('data-native-transaction')
})
test('renders a suspended deep sibling and keeps its pushed history entry', async ({ page }) => {
await page.goto('/profile')
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page.getByTestId('async-data-loading')).toBeVisible()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible()
await expect(page.getByRole('link', { name: /You/ })).toHaveAttribute('aria-current', 'page')
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
const counter = page.locator('[data-native-route="/profile/runtime-lab"] [data-testid="mounted-seconds"]')
const before = Number(await counter.getAttribute('data-seconds'))
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
// The route is out of view but still mounted, so its local interval advances.
await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(before)
// browser forward exists only because this sibling opted into push history.
await page.evaluate(() => history.forward())
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByTestId('async-data-ready')).toBeVisible()
})
test('a dynamic guard rejects an already-cached route without destroying it', async ({ page }) => {
await page.goto('/profile')
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
const cachedLab = page.locator('[data-native-route="/profile/runtime-lab"]')
const counter = cachedLab.getByTestId('mounted-seconds')
const beforeBlockedAttempt = Number(await counter.getAttribute('data-seconds'))
await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click()
await expect(page.getByRole('button', { name: 'Block Runtime Lab re-entry' })).toHaveAttribute('aria-pressed', 'true')
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(cachedLab.getByTestId('lab-guard-status')).toHaveText('blocked')
await waitForTransition(page)
await expect(page).toHaveURL(/\/profile$/)
await expect(page.getByRole('heading', { name: 'You' })).toBeVisible()
await expect(cachedLab).toHaveCount(1)
await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(beforeBlockedAttempt)
await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click()
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByTestId('async-data-ready')).toBeVisible()
await expect(page.getByTestId('mounted-seconds')).toHaveAttribute('data-seconds', /[2-9]|[1-9]\d+/)
})
test('opens and dismisses the compose sheet', async ({ page }) => {
await page.goto('/inbox')
await page.getByRole('button', { name: 'Compose' }).click()

View File

@@ -88,4 +88,5 @@ test('precaches lazily split routes for offline navigation', async ({ page }) =>
})
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true)
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true)
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(true)
})

View File

@@ -6,12 +6,12 @@ import { useRoute } from 'vue-router'
import PwaUpdate from './components/PwaUpdate.vue'
const route = useRoute()
const siblingRoutes = ['/inbox', '/stories', '/profile']
const siblingRoutes = ['/inbox', '/stories', '/profile', '/profile/runtime-lab']
const showTabs = computed(() => Boolean(route.meta.tab))
const tabs: NativeTabItem[] = [
{ label: 'Inbox', to: '/inbox', icon: '◉' },
{ label: 'Stories', to: '/stories', icon: '◎' },
{ label: 'You', to: '/profile', icon: '◇' },
{ label: 'You', to: '/profile', icon: '◇', activeWhen: (current) => current.path.startsWith('/profile') },
]
</script>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const requestedAt = Date.now()
await new Promise((resolve) => window.setTimeout(resolve, 1_000))
const resolutionTime = Date.now() - requestedAt
</script>
<template>
<article class="lab-probe lab-probe--ready" data-testid="async-data-ready">
<span class="lab-probe__icon" aria-hidden="true"></span>
<div>
<strong>Async payload available</strong>
<p>Resolved {{ resolutionTime }} ms after this component mounted.</p>
</div>
</article>
</template>

View File

@@ -0,0 +1,25 @@
import { readonly, reactive } from 'vue'
const state = reactive({
blockEntry: false,
checks: 0,
status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked',
})
export const runtimeLabGuard = readonly(state)
export function setRuntimeLabBlocked(blocked: boolean) {
state.blockEntry = blocked
if (state.status !== 'checking') state.status = 'idle'
}
/** An intentionally asynchronous, stateful guard used by the stress demo. */
export async function evaluateRuntimeLabEntry() {
state.checks += 1
state.status = 'checking'
await new Promise((resolve) => window.setTimeout(resolve, 320))
const allowed = !state.blockEntry
state.status = allowed ? 'allowed' : 'blocked'
return allowed
}

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { evaluateRuntimeLabEntry } from './lab-state'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/inbox' },
@@ -14,6 +15,11 @@ const routes: RouteRecordRaw[] = [
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
},
{
path: '/profile/runtime-lab', name: 'runtime-lab', component: () => import('./views/RuntimeLabView.vue'),
beforeEnter: evaluateRuntimeLabEntry,
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 3, siblingHistory: 'push', presentation: 'slide', parent: '/profile', gesture: 'full' } },
},
{
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },

View File

@@ -241,6 +241,15 @@ html[data-pwa-edge-guard="active"] body {
.lab-intro { display: flex; align-items: center; gap: 16px; margin: 20px 16px; padding: 18px; border: 1px solid rgba(124,92,255,.2); border-radius: 20px; background: rgba(124,92,255,.1); }
.lab-intro > span { display: grid; width: 54px; height: 54px; place-items: center; border-radius: 17px; background: #7c5cff; font-size: 20px; font-weight: 800; }
.lab-intro strong { font-size: 14px; }.lab-intro p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
.lab-intro--timer > span { font-variant-numeric: tabular-nums; }
.runtime-lab-screen .settings-group { padding-bottom: 0; }
.lab-probe { display: flex; min-height: 86px; align-items: center; gap: 14px; padding: 15px; border-top: 1px solid var(--line); }
.lab-probe__icon, .lab-spinner { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border-radius: 14px; }
.lab-probe__icon { color: #07130f; background: #3dd9aa; font-size: 20px; font-weight: 900; }
.lab-probe strong { font-size: 13px; }
.lab-probe p { margin: 4px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
.lab-spinner { border: 3px solid rgba(155,136,255,.2); border-top-color: #9b88ff; animation: lab-spin .7s linear infinite; }
@keyframes lab-spin { to { transform: rotate(360deg); } }
.settings-group { padding: 8px 0; }
.settings-group h2 { margin: 8px 15px; color: #737986; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
.settings-group > label, .settings-group > div { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 15px; padding: 10px 15px; border-top: 1px solid var(--line); }

View File

@@ -1,6 +1,13 @@
<script setup lang="ts">
import { NativeLink } from '@native-vue-router/core'
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
import { runtimeLabGuard, setRuntimeLabBlocked } from '../lab-state'
const native = useNativeRouter()
function toggleLabGuard() {
setRuntimeLabBlocked(!runtimeLabGuard.blockEntry)
}
</script>
<template>
@@ -17,6 +24,10 @@ import AppHeader from '../components/AppHeader.vue'
</div>
</section>
<section class="settings-list">
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span></span><strong>Runtime stress lab</strong><i></i></a>
<button type="button" aria-label="Block Runtime Lab re-entry" :aria-pressed="runtimeLabGuard.blockEntry" @click="toggleLabGuard">
<span></span><strong>Block cached lab re-entry</strong><i>{{ runtimeLabGuard.blockEntry ? 'On' : 'Off' }}</i>
</button>
<NativeLink to="/settings"><span></span><strong>Navigation lab</strong><i></i></NativeLink>
<a href="https://github.com" target="_blank" rel="noreferrer"><span></span><strong>Project source</strong><i></i></a>
<button type="button"><span></span><strong>Appearance</strong><i>System</i></button>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import AsyncLabData from '../components/AsyncLabData.vue'
import { runtimeLabGuard } from '../lab-state'
const mountedSeconds = ref(0)
let mountedAt = 0
let timer: number | undefined
onMounted(() => {
mountedAt = Date.now()
timer = window.setInterval(() => {
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
}, 200)
})
onBeforeUnmount(() => {
if (timer !== undefined) window.clearInterval(timer)
})
</script>
<template>
<main class="screen screen--tabs runtime-lab-screen">
<AppHeader title="Runtime stress lab" subtitle="A cached push-history sibling" back />
<section class="lab-intro lab-intro--timer">
<span>{{ mountedSeconds }}</span>
<div>
<strong>Seconds mounted</strong>
<p
data-testid="mounted-seconds"
:data-seconds="mountedSeconds"
>This timer continues while the route is cached and out of view.</p>
</div>
</section>
<section class="settings-group">
<h2>Suspense boundary</h2>
<Suspense :timeout="0">
<AsyncLabData />
<template #fallback>
<article class="lab-probe lab-probe--loading" data-testid="async-data-loading" aria-live="polite">
<span class="lab-spinner" aria-hidden="true" />
<div>
<strong>Waiting for async payload</strong>
<p>Data becomes available one second after the child mounts.</p>
</div>
</article>
</template>
</Suspense>
</section>
<section class="settings-group">
<h2>Guard and history state</h2>
<div>
<span><strong>Entry guard</strong><small>Asynchronous check #{{ runtimeLabGuard.checks }}</small></span>
<b data-testid="lab-guard-status" :class="{ offline: runtimeLabGuard.status === 'blocked' }">{{ runtimeLabGuard.status }}</b>
</div>
<div>
<span><strong>Sibling history</strong><small>This route pushes instead of replacing Profile</small></span>
<b>push</b>
</div>
<p>Use Back to return to Profile. The route remains mounted in the native view cache, so its timer and resolved async component keep their state.</p>
</section>
</main>
</template>

View File

@@ -8,6 +8,8 @@ A forward drag calls `router.resolve()` and Vue Router's public `loadRouteLocati
Each preview subtree receives a scoped `routeLocationKey`, so `useRoute()` returns preview params even though the global route is not committed. A normal `router.push()` or `replace()` runs only after the gesture chooses to commit. A guard failure cancels the transaction and removes the preview.
Preview routes can contain ordinary Vue `<Suspense>` boundaries. A route may therefore become a live navigation surface immediately, show its fallback while async child setup continues, and preserve the resolved child when the route later moves into the mounted cache. Route guards remain commit-time authority and can still reject that cached destination on a later entry attempt.
## Transaction lifecycle
Transactions move through `interactive`, `committing`, `settling`, and cancellation states. They expose normalized progress and velocity plus `fromKey`, `toKey`, direction, presentation, and optional source geometry.

View File

@@ -0,0 +1,202 @@
# Engineering Challenges, Vue Router Limitations, and Trade-offs
## The fundamental mismatch
Vue Router is designed around an authoritative current route. A navigation resolves a location, runs guards, updates history, and makes that location current; `<RouterView>` then renders the matching component. That model is correct for normal web navigation.
Interactive native navigation needs a second, provisional route before any of those semantic effects are committed. The destination must be fully rendered beside the source while the user is still free to cancel. Much of the work in Native Vue Router exists to bridge that mismatch without forking Vue Router or relying on its internals.
## Challenge 1: showing two route locations at once
### Limitation
The usual `<RouterView>` follows the global current route. A CSS transition around a normal router view can animate old and new DOM after navigation, but it cannot naturally provide a live, reversible destination before navigation.
### Approach
The runtime resolves and loads the destination, creates a preview entry, and renders explicit router views for both entries using the `route` prop. A scoped `routeLocationKey` is provided inside each entry so descendants using `useRoute()` read the correct route for that surface.
### Trade-off
Two component trees may be live simultaneously. Preview components can mount before commit, so their setup and data loading must tolerate cancellation. Irreversible side effects should be tied to committed application state or view activation, not blindly to component mount.
## Challenge 2: preserving Vue Router authority
### Limitation
It would be simpler to maintain a completely separate navigation stack and update the URL afterward, but that would bypass guards, redirects, route encoding, and existing Vue Router integrations.
### Approach
The preview phase never mutates Vue Router history. Commit uses public Vue Router navigation operations. The runtime then accepts the actual resulting route, including redirects, as authoritative and discards any stale preview.
### Trade-off
The visual runtime must maintain and reconcile a second ledger. This is deliberate duplication of visual/navigation bookkeeping, with explicit invariants to keep the two systems aligned.
## Challenge 3: predictive back and opaque browser history
### Limitation
The browser does not expose a portable array of prior route locations. Calling `router.back()` also does not synchronously reveal the destination. A cold-start deep link may have an external page, another application, or no useful same-app route behind it.
### Approach
The runtime records committed native view keys and uses that ledger for warm-session predictive back. Routes can declare a logical `parent` for cold-start prediction. The actual pop still goes through Vue Router/browser history when a real entry exists.
### Trade-off
Applications must describe route topology where history alone is insufficient. A declared parent is a product-level relationship, not proof that the corresponding browser history entry exists. Synthetic parents use replace semantics when committed.
## Challenge 4: guards, redirects, and asynchronous loading
### Limitation
Lazy modules, route guards, redirects, and browser pops resolve asynchronously. Meanwhile, gesture input and animation frames continue. A response from an old navigation can arrive after a newer interaction has started.
### Approach
Every transaction and begin attempt receives a monotonically increasing identity. Async continuations verify that they still own the current attempt and transaction before mutating state. Guard rejection springs back; redirect results become authoritative; stale previews are removed.
### Trade-off
Only one visual transaction is authoritative at a time. The runtime supports rapid sequential interruption, not multiple independent transitions mutating one navigator concurrently.
## Challenge 5: interruption without a navigation cooldown
### Limitation
The easiest animation model locks input until a transition completes. That creates a visible cooldown and feels unlike a native application. Simply cancelling animation promises is unsafe because Vue Router may already be committing history.
### Approach
Settling motion and semantic navigation are treated separately. A new interaction can interrupt the spring immediately, but it waits for any already-started Vue Router navigation to resolve. The prior transaction is finalized at the route Vue Router accepted, and the new transaction begins from that authoritative state.
Pointer recognizers also detach their local state before awaiting anything. Captured progress, velocity, and transaction ID travel with the old release callback, while a new pointer can start cleanly. Both move and release continuations check ownership before cleanup.
### Trade-off
A route guard or browser-history operation can still impose real latency because semantic navigation cannot safely be cancelled after the platform has begun it. The library removes animation cooldown; it cannot remove application guard or network latency.
## Challenge 6: direction and topology for sibling routes
### Limitation
Vue Router knows route hierarchy and matching, but not that `/inbox`, `/stories`, and `/profile` are ordered pages on a horizontal strip. Browser history order also does not necessarily match visual tab order.
### Approach
The navigator receives its peer route list, while routes declare `siblingOrder` and optional sibling history semantics. Direction is derived from route order. The sibling presentation moves both surfaces one-to-one, rather than placing a new foreground layer over the old one.
### Trade-off
Visual topology must be explicit. Automatic inference from route declaration order would be fragile in modular or dynamically registered route sets.
## Challenge 7: velocity that behaves consistently
### Limitation
Raw pointer velocity in pixels per millisecond changes meaning with viewport size. A fixed-duration animation also ignores whether the user released slowly or flicked decisively.
### Approach
Velocity is normalized by the gesture surface width or height and expressed as route progress per second. It influences both the commit decision and the rate of the damped settling spring. High-speed springs are advanced in small simulation substeps to avoid numerical instability.
### Trade-off
The current spring constants and velocity scaling are shared defaults rather than a fully configurable physics system. Extremely high input is clamped, preserving stability over perfectly reproducing every raw pointer sample.
## Challenge 8: gesture arbitration
### Limitation
A horizontal movement may mean browser back, application back, tab paging, a component action, text selection, or ordinary scrolling. Bubbling alone is insufficient because a partially revealed child layer can steal the physical edge from its navigator.
### Approach
Leading-edge back recognition runs in the navigator's capture phase. Component gestures own non-edge drags by stopping propagation. Recognition waits for a directional threshold, then uses pointer capture. Form controls and explicit ignore regions are excluded, while CSS `touch-action` leaves the perpendicular scroll axis available.
### Trade-off
Applications must design gesture regions intentionally. Highly interactive canvases, maps, carousels, editors, and nested horizontal scrollers should opt out or provide their own arbitration.
## Challenge 9: native edge gestures on the web
### Limitation
Web content cannot set `WKWebView.allowsBackForwardNavigationGestures`. In a normal iOS Safari tab, the browser may reserve an edge sequence before page JavaScript receives enough input to implement its own predictive back.
### Approach
The installed iOS PWA uses a non-passive leading-edge touch guard at capture time and disables horizontal overscroll as far as web content permits. The guard is intentionally inactive in a normal browser tab. Electron disables Chromium overscroll history navigation at the host level. Capacitor supplies the deterministic native container option.
### Trade-off
An installed PWA can provide a strong approximation, not an absolute WebKit-level guarantee. Products that require complete ownership of the native back gesture should use the Capacitor host.
## Challenge 10: view caching without corrupting history
### Limitation
Keeping a tab mounted is a rendering concern; deciding whether Back should visit it is a history concern. Treating one list as both caused stale conversations and replaced tabs to appear as incorrect back targets.
### Approach
The runtime separates the history-key ledger from the mounted-view cache. A replaced sibling can remain reusable without entering the back path. Statuses distinguish active, inactive, preview, and evicted entries.
### Trade-off
Mounted routes consume memory. The inactive cache is bounded, and evicted component-local state is not guaranteed to survive. Durable state belongs in Pinia, another store, IndexedDB, or the backend.
## Challenge 11: accessibility with concurrent routes
### Limitation
Two visible DOM subtrees can create duplicate landmarks, focus targets, and screen-reader content. A hidden cached route can also accidentally receive pointer or keyboard input.
### Approach
Inactive entries are hidden, `inert`, and `aria-hidden`. Only the active route or interactive pair is presented visually. Back and tab controls remain semantic buttons and links. Reduced-motion preference settles transactions immediately.
### Trade-off
Custom presentations and application overlays must preserve these invariants. Focus transfer at commit may still require application-specific handling for complex screens.
## Challenge 12: reliable PWA updates
### Limitation
Service-worker lifecycle, HTTP caching, Safari foreground behavior, and installed Home Screen state can leave an old application shell active even after a deployment. Reloading immediately is also dangerous during an interactive transaction.
### Approach
Production builds use automatic worker activation and check on registration, focus, foreground resume, reconnection, and a periodic timer. Reload is deferred until the native transaction is idle. The demo exposes a build ID and update-check count, and local development/preview uses conservative cache headers.
### Trade-off
Deployment infrastructure must still avoid long-lived caching for `sw.js` and the HTML shell. A client running code from before the automatic update policy may need one final manual refresh or reinstall; new code cannot retroactively change an old worker's behavior before it is loaded.
## Current limitations
- Client-side DOM navigation is the first-class target; SSR and hydration of a live visual stack are not currently a complete feature.
- One runtime coordinates one authoritative visual transaction at a time. Independent nested navigation controllers need an explicit ownership design.
- `navigator` and `siblingGroup` metadata are reserved topology fields; the current `NativeNavigator` still receives its sibling route list explicitly. Likewise, `gesture: false` is the enforced opt-out, while finer `edge`/`full` policy is primarily expressed by navigator and component structure today.
- Route previews may mount application code that later gets cancelled.
- Cold-start predictive back requires declared parent topology.
- Cache eviction does not preserve arbitrary component-local state.
- The browser cannot guarantee native gesture suppression at the same level as Capacitor or a custom `WKWebView`.
- Presentation physics are currently library defaults rather than route-by-route configurable tokens.
- Long sessions keep lightweight route descriptors in the ledger even when their component trees are evicted. Mounted DOM is bounded, but applications with exceptionally large histories may eventually benefit from indexed lookup and descriptor compaction.
- Native appearance is broader than navigation: slow screen rendering, non-native controls, layout shifts, or inappropriate typography can still break the illusion.
## Summary of deliberate trade-offs
| Decision | Benefit | Cost |
| --- | --- | --- |
| Keep Vue Router authoritative | Guards, URLs, redirects, and ecosystem compatibility | Reconciliation complexity |
| Render a preview before commit | Truly interactive and cancellable navigation | Two live component trees and preview side effects |
| Maintain separate history and view ledgers | Correct back semantics plus tab caching | More state and invariants |
| Interrupt springs but finish semantic commits | No animation cooldown without corrupting history | Guard/history latency can remain |
| Require explicit route topology | Deterministic parent and sibling behavior | More route metadata |
| Bound mounted views | Predictable DOM and memory use | Component-local state can be evicted |
| Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts |

125
docs/how-it-works.md Normal file
View File

@@ -0,0 +1,125 @@
# How Native Vue Router Works
## Purpose
Native Vue Router adds an interactive visual navigation layer to Vue Router. Its goal is not merely to make route changes slide instead of fade. It is designed so navigation itself can be manipulated: a user can reveal a destination, stop halfway, reverse direction, release with velocity, or begin another navigation before the previous animation has settled.
The library supports four related navigation styles through one transaction engine:
- Edge-driven predictive back, including drag-and-hold.
- One-to-one paging between ordered sibling routes such as primary tabs.
- Component-originated navigation, where dragging a row or card reveals its destination.
- Presented routes such as modals and sheets, including interactive dismissal.
Buttons and links use the same engine as gestures. A tab click, back button, programmatic push, and interactive swipe differ only in how progress is supplied and whether the final commit decision is forced.
## The central idea: semantic state and visual state are different
Vue Router remains the authority for semantic navigation: route matching, URLs, parameters, redirects, guards, lazy route modules, and browser history. Native Vue Router owns temporary visual state: mounted route surfaces, interactive progress, motion, and the relationship between the surface being left and the surface being revealed.
That separation produces two ledgers:
| Ledger | Owns | Source of truth for |
| --- | --- | --- |
| Vue Router | Current committed route and browser history | What URL the application is actually on |
| Native view runtime | Active, inactive, preview, and evicted view entries | What route surfaces can be rendered during motion |
The ledgers agree at rest. During a gesture they intentionally diverge: Vue Router still reports the committed `from` route while the native runtime also renders an uncommitted `to` route.
## Preview before commit
A forward interaction follows a two-phase process:
1. Resolve the target with Vue Router.
2. Load its lazy route component without pushing a history entry.
3. Add a preview entry to the native view ledger.
4. Render both the committed route and preview route through explicit `<RouterView :route>` instances.
5. Drive their transforms from normalized gesture progress between `0` and `1`.
6. On release, decide whether to commit from distance and velocity.
7. Only then call Vue Router's `push()`, `replace()`, or browser-backed `back()`.
8. Reconcile the visual ledger with the route Vue Router actually accepted.
If the user reverses the gesture or a navigation guard rejects the target, the preview springs away and is removed. The URL never briefly changes to a route that the user did not commit.
Each preview subtree receives its own scoped Vue Router route injection. As a result, components rendered in the preview see the preview's params and metadata through `useRoute()`, even though the application's global committed route has not changed yet.
## Predictive back
Back navigation is harder than forward navigation because browser history does not expose a reliable, portable list of previous route objects. The runtime therefore maintains its own committed history-key ledger alongside Vue Router.
When a back gesture starts, the runtime locates the preceding native view entry and renders it beneath the current one. The browser URL remains unchanged while the user drags. On commit, the runtime calls Vue Router/browser back and reconciles with the route that actually wins.
A cold-start deep link has no in-memory predecessor. Route metadata can declare a logical `parent`, either as a route location or a function of the current route. That gives the runtime a synthetic predictive-back destination without pretending that a browser history entry exists.
## Transactions
All navigation motion is represented by a `NativeTransaction`. A transaction identifies:
- Its kind: push, pop, sibling, present, or dismiss.
- The `from` and `to` view keys.
- Direction and presentation.
- Normalized progress and velocity.
- Whether commit uses push or replace semantics.
- Its lifecycle phase and optional component source geometry.
The important phases are interactive, committing, and settling. During the interactive phase, pointer movement directly controls progress. A release changes the phase and starts a damped spring toward either `0` or `1`.
Velocity is measured as route progress per second rather than pixels per millisecond. A flick therefore behaves consistently on a small phone and a wide desktop window. High release velocity also advances the settling spring faster, so fast intent produces fast completion.
Transactions are interruptible. Starting a new navigation while a spring is settling does not wait for the old visual animation. The runtime waits only for an in-flight Vue Router history or guard decision, finalizes the old transaction at its authoritative endpoint, and begins the next transaction from that route. Transaction IDs prevent delayed pointer, preload, animation, and navigation callbacks from mutating a newer interaction.
## Rendering and presentation
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is visible and interactive. During a transaction, exactly the `from` and `to` entries receive active roles.
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry.
## Gesture ownership
Gesture recognition uses Pointer Events and waits for clear directional intent before claiming a pointer. Vertical scrolling remains available through `touch-action`, while form controls, editable content, and elements marked with `data-native-gesture="ignore"` are excluded.
Ownership is explicit:
- The application navigator owns the physical leading edge for back navigation.
- A component gesture link owns drags that begin on that component away from the back edge.
- A sheet dismissal surface owns downward vertical drags.
Pointer capture keeps delivery stable after recognition. Recognizer state is detached synchronously at pointer release, before route loading or animation promises are awaited. This is essential: a delayed callback from one gesture must never erase the state of a newer gesture.
## History and cache are intentionally separate
Primary sibling routes normally replace one another in history. Their component trees can still remain mounted in the view cache. This means returning to a tab can preserve local UI state without making every tab selection a browser-back destination.
The cache has a configurable inactive-view limit. Older entries retain route descriptors but their component trees are unmounted and restored lazily. Durable application data should live in an application store rather than depending on a route component remaining cached forever.
## Platform behavior
The core runtime is host-neutral. Platform adapters add capabilities that a browser-only router should not own:
- The PWA adapter reserves the leading edge in installed iOS standalone mode as early as web content allows.
- The Electron adapter disables Chromium overscroll history navigation and bridges host back/forward commands.
- The Capacitor adapter integrates hardware back, deep links, app lifecycle cancellation, root exit, and haptics.
This is progressive capability, not user-agent imitation. Normal browser tabs still work, but a browser may reserve gestures before JavaScript can claim them. Electron and Capacitor can disable or coordinate host behavior more deterministically.
## Why this pattern is uncommon
There is prior work in mobile web navigation and animated router outlets, so the claim is not that interactive routing has never existed. What is unusual is combining Vue Router compatibility, live destination previews, reversible gestures, native-style history semantics, interruption, and multiple hosts in one reusable runtime.
Several factors make that combination rare:
1. **Web routers are commit-first.** Their normal unit of work is “change the current location, then render it.” Native interaction needs “render the possible destination, let the user manipulate it, then decide whether location changes.”
2. **The platform does not expose a native navigation controller.** Browser history, DOM rendering, pointer recognition, safe areas, service workers, and host gestures are separate systems with separate lifecycles.
3. **Two live routes complicate assumptions.** Route injection, focus, accessibility, component side effects, caching, redirects, and scroll ownership all become more difficult when a route is visible but not current.
4. **Correct interruption is harder than animation.** A polished demo can lock input while a transition runs. A native-feeling library must accept new intent during route loading, guard resolution, history mutation, and spring settling without stale asynchronous work winning.
5. **Host guarantees differ.** A browser tab cannot promise the same edge ownership as a native `WKWebView`, while Electron and Capacitor can change host settings.
6. **The implementation cost is disproportionate.** Most web products can accept non-interactive transitions. The additional state machine, testing matrix, memory use, and platform work are justified only when navigation feel is a core product requirement.
Native Vue Router addresses this by treating interactive navigation as its own stateful system while leaving Vue Router authoritative wherever Vue Router is strongest.
## Scope
The library is a client-side navigation runtime, not a replacement for Vue Router and not a native rendering engine. It can closely reproduce native navigation composition and input behavior, but final fidelity still depends on application design, frame performance, platform embedding, typography, safe-area handling, and avoiding expensive work in route components during a gesture.

View File

@@ -0,0 +1,201 @@
# Core Principles, Scalability, and Flexibility
## Core principles
### 1. Vue Router remains authoritative
Native Vue Router is a visual transaction system around Vue Router, not a competing URL router. Matching, encoding, guards, redirects, and committed history stay with Vue Router. If the two systems disagree after navigation, Vue Router's accepted route wins.
This principle preserves compatibility and gives the runtime a clear recovery rule.
### 2. Preview is not commitment
A destination may be loaded, mounted, and visible without being current. Gesture progress must never imply semantic commitment. The URL, browser history, analytics, and irreversible business actions should change only when the transaction commits.
### 3. Gestures are first-class navigation input
A gesture is not a decorative transition attached after `router.push()`. It begins a navigation candidate, controls its progress, and chooses commit or cancellation. Buttons, links, hardware back, and gestures feed the same runtime so they cannot develop contradictory behavior.
### 4. Navigation must remain interruptible
Users should not wait for visual settling before expressing the next intent. Animations are disposable; committed route decisions are not. New input may interrupt presentation immediately while respecting any semantic operation already in flight.
### 5. Route topology should be explicit
History order, visual sibling order, and logical parentage are different concepts. Applications declare parent and sibling relationships rather than relying on incidental route registration or click history.
### 6. History and rendering are separate concerns
A route can be cached without belonging in the back stack, and a back destination can be reconstructed without remaining mounted. This separation is essential for tabs, sheets, deep links, and bounded memory.
### 7. Async work never owns state forever
Every preload, pointer continuation, route commit, and animation frame is conditional on a current attempt or transaction identity. Stale work becomes a no-op. Cleanup is ownership-aware and cannot erase a newer gesture.
### 8. The active frame should be cheap
Pointer movement updates normalized progress. Rendering derives from that value. Built-in sibling motion uses only adjacent transforms, and JavaScript work per frame does not grow with route count. Route components should avoid layout churn and heavyweight synchronous work during previews.
### 9. Host capabilities are adapters, not conditionals scattered through core
Hardware back, haptics, deep links, WebKit edge behavior, and Electron command-line switches belong at the platform boundary. The transaction model remains the same across hosts.
### 10. Accessibility is a state invariant
Cached and preview DOM must not create duplicate interactive applications. Inactive layers are isolated from focus and assistive technology, controls keep native semantics, and reduced-motion behavior is deterministic.
## Scalability model
The runtime is intended to scale in four different ways: number of routes, session length, application complexity, and number of host platforms.
### Route and DOM scale
Only the active route, recent inactive routes, and a transaction preview need mounted component trees. `maxInactive` bounds the inactive mounted cache; the default is eight. Older views are marked evicted and lazily remounted when needed.
During an interaction, animation work concerns two surfaces regardless of total route count:
| Resource | Growth behavior |
| --- | --- |
| Animated surfaces | Constant: `from` and `to` |
| Mounted inactive component trees | Bounded by `maxInactive` |
| Route descriptors/history keys | Grows with navigation history |
| Per-frame transaction state | Constant |
| Lazy route code | Loaded on first preview or navigation |
The current implementation uses linear searches through view entries for some reconciliation operations. This is appropriate for ordinary application histories and a small mounted cache. If the runtime is used for sessions with thousands of unique committed entries, a key/path index and descriptor compaction would be a sensible evolution without changing the public transaction model.
### Data scale
The view cache is not an application data cache. Large collections, message history, drafts, and durable form state should live outside route component instances. This allows view eviction to remain cheap and makes state available whether a route is reached through a gesture, deep link, background notification, or restored session.
Preview loading should fetch only what the destination needs to render its initial surface. Applications can use route-level lazy imports, shared stores, request deduplication, and cancellation to avoid duplicating expensive work during a cancelled preview.
### Team and feature scale
Navigation policy is carried by route metadata and small primitives rather than screen-specific animation code. Feature teams can define:
- Presentation and gesture policy on their routes.
- Logical parents for deep-linked screens.
- Sibling order and history behavior for peer lists owned by a navigator.
- Component-owned gesture entry points.
Cross-cutting behavior remains in the runtime. This reduces the risk that every feature implements a slightly different back threshold, history mutation, or animation lock.
For very large applications, route topology should be assembled from typed feature modules and validated in tests. Parent cycles, duplicate sibling ordering, and incompatible nested gesture regions are application configuration errors and should be caught before runtime.
### Platform scale
The `NativePlatformAdapter` interface keeps platform growth additive. A new host can install listeners, provide haptics, or coordinate root exit without changing route matching or presentation code. The existing PWA, Electron, and Capacitor adapters demonstrate three capability levels:
1. Best-effort control from web content.
2. Desktop host control around a web renderer.
3. Native mobile container integration.
Platform-specific policy should not leak into route components unless the product experience genuinely differs.
## Flexibility and extension points
### Route metadata
Metadata expresses topology and defaults close to route definitions:
```ts
{
path: '/chat/:id',
component: () => import('./ChatView.vue'),
meta: {
native: {
parent: '/inbox',
presentation: 'push',
gesture: 'edge',
},
},
}
```
Ordered peers are passed to `NativeNavigator`. Their routes use `siblingOrder` to derive direction, while `siblingHistory` decides whether selection replaces or grows history. Metadata supplies defaults, while individual runtime calls can override presentation, direction, replacement, and source geometry. `gesture: false` disables navigator gesture handling for a route; finer ownership remains structural in the current implementation.
### Custom presentations
Presentation definitions receive only the data needed to derive layer styles:
```ts
nativeRouter.registerPresentation(definePresentation({
name: 'scale-fade',
axis: 'x',
layerStyle({ role, progress }) {
return role === 'to'
? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
: { opacity: 1 - progress * 0.25 }
},
}))
```
A presentation does not decide history or commit. Keeping motion separate from navigation semantics makes new visual styles safer to add.
### Custom recognizers
Applications with a bespoke interaction can call:
- `beginInteractive()` to create and preload a candidate.
- `updateInteractive()` with normalized progress and velocity.
- `finishInteractive()` to apply the normal commit decision.
- `cancelInteractive()` to settle back.
This permits interactions such as a card expansion, trackpad scrub, keyboard-driven preview, or canvas gesture without duplicating the route transaction machinery.
Custom recognizers must follow the same ownership rules: one current transaction, normalized input, stale-callback protection, and explicit cancellation on teardown.
### Visual presets
The core owns behavior and minimum presentation CSS. Higher-level packages can supply tab bars, back controls, motion tokens, typography, and platform-adaptive appearance. Product teams can replace the preset without replacing the transaction runtime.
### Platform adapters
Adapters can install host listeners and optionally provide haptic feedback or root-exit behavior. They should translate host events into runtime operations instead of editing runtime ledgers directly.
## Adoption patterns
The architecture supports incremental use:
1. **Imperative transitions only:** use `push`, `pop`, `sibling`, `present`, and `dismiss` with buttons and links.
2. **Predictive back:** wrap the route surface in `NativeNavigator` and declare cold-start parents.
3. **Horizontal route paging:** add ordered sibling routes.
4. **Component-originated navigation:** wrap selected rows or cards with `NativeGestureLink`.
5. **Custom product motion:** register presentations or drive transactions from a custom recognizer.
6. **Host integration:** add the PWA, Electron, or Capacitor adapter according to the guarantees required.
Teams do not need to make every route gesture-driven at once. Route metadata can disable gestures while retaining native runtime navigation.
## Reliability and testing principles
Interactive routing failures are temporal, so tests must assert state during transitions rather than only final URLs. The test suite should preserve these invariants:
- The `from` and `to` routes are both live during a held gesture.
- The URL does not change before commit.
- Sibling direction follows declared order.
- Adjacent siblings remain edge-to-edge at intermediate progress.
- Navigating to the active route is a strict no-op.
- A guard rejection removes the preview and restores the source.
- Back never selects an unrelated cached view.
- A new click or gesture can interrupt settling.
- Rapid pointer releases cannot leave an orphaned transaction.
- Fast, short flicks commit through velocity and settle faster.
- Modal and sheet presentation is independent of previous history shape.
- PWA worker activation and update checks remain observable.
Final-state tests alone would miss most of the bugs that make a router feel non-native.
## Evolution rules
Future work should preserve the following boundaries:
- Do not make preview routes authoritative early to simplify animation.
- Do not infer back targets from the mounted cache.
- Do not make presentation definitions mutate history.
- Do not solve platform behavior with host checks scattered through core.
- Do not introduce input locks as a substitute for correct interruption.
- Do not let stale async cleanup run without verifying ownership.
- Do not rely on component mount as proof of committed navigation.
Likely extensions include configurable spring profiles, indexed ledgers for unusually long sessions, stronger focus restoration, explicit multi-navigator ownership, SSR-safe initial stack hydration, and more platform-specific motion presets. Each can be added while retaining the same central model: preview visually, commit semantically, and reconcile authoritatively.

View File

@@ -1,5 +1,5 @@
import { defineComponent, h, type PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router'
import { useNativeRouter } from '@native-vue-router/core'
import './style.css'
@@ -30,6 +30,7 @@ export interface NativeTabItem {
label: string
to: RouteLocationRaw
icon?: string
activeWhen?: (current: RouteLocationNormalizedLoaded) => boolean
}
export const NativeTabBar = defineComponent({
@@ -41,7 +42,9 @@ export const NativeTabBar = defineComponent({
const native = useNativeRouter()
return () => h('nav', { class: 'nvr-native-tabs', 'aria-label': 'Primary navigation' },
props.items.map((item) => {
const active = native.router.currentRoute.value.path === native.router.resolve(item.to).path
const current = native.router.currentRoute.value
const exact = current.path === native.router.resolve(item.to).path
const active = exact || Boolean(item.activeWhen?.(current))
const href = native.router.resolve(item.to).href
return h('a', {
href,
@@ -50,7 +53,7 @@ export const NativeTabBar = defineComponent({
onClick: (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
if (active) return
if (exact) return
void native.sibling(item.to, { replace: true })
},
}, [