Improve caching
This commit is contained in:
@@ -41,7 +41,7 @@ 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.
|
||||
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 its mount lifetime, and renders a one-second async child through `<Suspense>`. Backing out evicts this pushed screen after its exit; browser Forward reconstructs it and shows the fallback again. To exercise a guard against an already-mounted destination, visit **Stories**, switch to **You**, enable **Block cached Stories re-entry**, and try returning to Stories. The guard rejects and evicts the cached 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.
|
||||
|
||||
@@ -89,6 +89,8 @@ Import `@native-vue-router/core/style.css` for the built-in presentation layers.
|
||||
|
||||
Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is derived from `siblingOrder`, repeated navigation to the active route is a no-op, and `siblingHistory: 'replace'` keeps cached tab views out of the back stack.
|
||||
|
||||
Sibling views are lazy rather than pre-mounted: only the initial route exists on startup, and a sibling joins the bounded cache on its first visit or interactive preview. Route metadata accepts `cache: false` to opt out or `cache: 'pin'` for views that must survive ordinary trimming. `useNativeViewLifecycle()`, the `onNativeView*` hooks, and `useNativeViewActiveEffect()` let cached screens pause polling, media, or subscriptions while retaining their local UI state.
|
||||
|
||||
## Packages
|
||||
|
||||
- `@native-vue-router/core` — transactions, route ledger, concurrent views, gestures, caching, and public components/composables.
|
||||
|
||||
@@ -169,7 +169,7 @@ 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 }) => {
|
||||
test('renders a suspended pushed sibling and evicts it after backing out', async ({ page }) => {
|
||||
await page.goto('/profile')
|
||||
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
|
||||
|
||||
@@ -179,49 +179,71 @@ test('renders a suspended deep sibling and keeps its pushed history entry', asyn
|
||||
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'))
|
||||
const lab = page.getByTestId('runtime-lab-view')
|
||||
const firstMountId = await lab.getAttribute('data-mount-id')
|
||||
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)
|
||||
// Popping a pushed route removes it after the exit animation. Keeping the
|
||||
// descriptor allows browser-forward navigation without retaining its DOM.
|
||||
await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0)
|
||||
|
||||
// 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()
|
||||
await expect(page.getByTestId('async-data-loading')).toBeVisible()
|
||||
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
|
||||
await expect(page.getByTestId('runtime-lab-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
|
||||
})
|
||||
|
||||
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()
|
||||
test('lazily caches a visited sibling and pauses its active work while hidden', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
const stories = page.getByTestId('stories-view')
|
||||
const mountId = await stories.getAttribute('data-mount-id')
|
||||
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(1)
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(stories).toHaveCount(1)
|
||||
|
||||
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')
|
||||
const hiddenTicks = Number(await stories.getAttribute('data-active-ticks'))
|
||||
await page.waitForTimeout(600)
|
||||
await expect(stories).toHaveAttribute('data-active-ticks', String(hiddenTicks))
|
||||
|
||||
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
|
||||
await expect(cachedLab.getByTestId('lab-guard-status')).toHaveText('blocked')
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
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 expect(stories).toHaveAttribute('data-mount-id', mountId!)
|
||||
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(hiddenTicks)
|
||||
})
|
||||
|
||||
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('evicts a cached sibling when its dynamic entry guard rejects it', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
const stories = page.getByTestId('stories-view')
|
||||
const firstMountId = await stories.getAttribute('data-mount-id')
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(stories).toHaveCount(1)
|
||||
|
||||
const guardToggle = page.getByRole('button', { name: 'Block Stories re-entry' })
|
||||
await guardToggle.click()
|
||||
await expect(guardToggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await expect(page.getByTestId('story-guard-status')).toHaveText('blocked')
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByTestId('stories-view')).toHaveCount(0)
|
||||
|
||||
await guardToggle.click()
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByTestId('stories-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
|
||||
})
|
||||
|
||||
test('opens and dismisses the compose sheet', async ({ page }) => {
|
||||
|
||||
50
apps/demo/src/guard-state.ts
Normal file
50
apps/demo/src/guard-state.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { readonly, reactive } from 'vue'
|
||||
|
||||
function createGuardState() {
|
||||
return reactive({
|
||||
blockEntry: false,
|
||||
checks: 0,
|
||||
status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked',
|
||||
})
|
||||
}
|
||||
|
||||
const storyState = createGuardState()
|
||||
const labState = createGuardState()
|
||||
|
||||
export const storyEntryGuard = readonly(storyState)
|
||||
export const runtimeLabGuard = readonly(labState)
|
||||
|
||||
export function setStoryEntryBlocked(blocked: boolean) {
|
||||
storyState.blockEntry = blocked
|
||||
if (storyState.status !== 'checking') storyState.status = 'idle'
|
||||
}
|
||||
|
||||
async function evaluate(state: ReturnType<typeof createGuardState>) {
|
||||
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
|
||||
}
|
||||
|
||||
/** Dynamic guard used to reject a sibling that may already be cached. */
|
||||
export function evaluateStoryEntry() {
|
||||
storyState.checks += 1
|
||||
if (!storyState.blockEntry) {
|
||||
storyState.status = 'allowed'
|
||||
return true
|
||||
}
|
||||
storyState.status = 'checking'
|
||||
return new Promise<boolean>((resolve) => {
|
||||
window.setTimeout(() => {
|
||||
storyState.status = 'blocked'
|
||||
resolve(false)
|
||||
}, 320)
|
||||
})
|
||||
}
|
||||
|
||||
/** Always-allowing asynchronous guard for the deeper stress-lab route. */
|
||||
export function evaluateRuntimeLabEntry() {
|
||||
return evaluate(labState)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const platform = isElectron
|
||||
|
||||
const nativeRouter = createNativeRouter({
|
||||
router,
|
||||
cache: { maxInactive: 8 },
|
||||
cache: { maxInactive: 4 },
|
||||
platform,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { evaluateRuntimeLabEntry } from './lab-state'
|
||||
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from './guard-state'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/inbox' },
|
||||
@@ -9,6 +9,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'),
|
||||
beforeEnter: evaluateStoryEntry,
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -260,6 +260,7 @@ html[data-pwa-edge-guard="active"] body {
|
||||
.settings-group input[type="checkbox"] { width: 42px; height: 24px; accent-color: var(--nvr-accent); }
|
||||
.settings-group b { color: #3dd9aa; font-size: 12px; }.settings-group b.offline { color: #ff7a84; }
|
||||
.reset-button { display: block; width: calc(100% - 32px); min-height: 48px; margin: 0 16px 30px; border: 1px solid rgba(255,108,118,.2); border-radius: 15px; color: #ff7a84; background: rgba(255,108,118,.07); }
|
||||
.reset-button--neutral { margin-bottom: 18px; border-color: rgba(124,92,255,.22); color: #ad9fff; background: rgba(124,92,255,.08); }
|
||||
.empty-state { display: grid; place-items: center; }
|
||||
.update-toast { position: absolute; z-index: 50; right: 14px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 14px; display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 15px; background: rgba(28,31,40,.96); box-shadow: 0 18px 50px rgba(0,0,0,.4); font-size: 12px; }
|
||||
.update-toast button { border: 0; color: #a998ff; background: transparent; font-weight: 700; }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { runtimeLabGuard, setRuntimeLabBlocked } from '../lab-state'
|
||||
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
|
||||
|
||||
const native = useNativeRouter()
|
||||
|
||||
function toggleLabGuard() {
|
||||
setRuntimeLabBlocked(!runtimeLabGuard.blockEntry)
|
||||
function toggleStoryGuard() {
|
||||
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,8 +25,8 @@ function toggleLabGuard() {
|
||||
</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 type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
|
||||
<span>⌽</span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : '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>
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useNativeViewActiveEffect } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import AsyncLabData from '../components/AsyncLabData.vue'
|
||||
import { runtimeLabGuard } from '../lab-state'
|
||||
import { runtimeLabGuard } from '../guard-state'
|
||||
|
||||
const mountedSeconds = ref(0)
|
||||
let mountedAt = 0
|
||||
let timer: number | undefined
|
||||
const mountedAt = Date.now()
|
||||
const mountId = crypto.randomUUID()
|
||||
|
||||
onMounted(() => {
|
||||
mountedAt = Date.now()
|
||||
timer = window.setInterval(() => {
|
||||
useNativeViewActiveEffect(() => {
|
||||
const update = () => {
|
||||
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
|
||||
}, 200)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== undefined) window.clearInterval(timer)
|
||||
}
|
||||
update()
|
||||
const timer = window.setInterval(update, 200)
|
||||
return () => window.clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs runtime-lab-screen">
|
||||
<main class="screen screen--tabs runtime-lab-screen" data-testid="runtime-lab-view" :data-mount-id="mountId">
|
||||
<AppHeader title="Runtime stress lab" subtitle="A cached push-history sibling" back />
|
||||
|
||||
<section class="lab-intro lab-intro--timer">
|
||||
@@ -31,7 +30,7 @@ onBeforeUnmount(() => {
|
||||
<p
|
||||
data-testid="mounted-seconds"
|
||||
:data-seconds="mountedSeconds"
|
||||
>This timer continues while the route is cached and out of view.</p>
|
||||
>Elapsed mount time is preserved, while interval work pauses whenever this view is inactive.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -61,7 +60,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<p>Use Back to return to Profile. Because this is a pushed sibling, it is unmounted after its exit animation; replaced primary siblings remain lazily cached instead.</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useNativeRouter } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
import { pwaBuildId, pwaEnvironment } from '../pwa'
|
||||
|
||||
const store = useDemoStore()
|
||||
const native = useNativeRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,6 +24,14 @@ const store = useDemoStore()
|
||||
<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>
|
||||
</section>
|
||||
<section class="settings-group">
|
||||
<h2>Native view cache</h2>
|
||||
<div><span><strong>Mounted views</strong><small>{{ native.cacheStats.value.inactive }} inactive of {{ native.cacheStats.value.maxInactive }} allowed</small></span><b data-testid="cache-mounted">{{ native.cacheStats.value.mounted }}</b></div>
|
||||
<div><span><strong>Route descriptors</strong><small>{{ native.cacheStats.value.evicted }} currently evicted</small></span><b>{{ native.cacheStats.value.descriptors }}</b></div>
|
||||
<div><span><strong>Total evictions</strong><small>{{ native.cacheStats.value.lastEviction?.reason ?? 'No eviction yet' }}</small></span><b data-testid="cache-evictions">{{ native.cacheStats.value.totalEvictions }}</b></div>
|
||||
<p>Sibling tabs are created on first visit, then retained. Back-stack screens stay warm only while they remain useful as a predictive-back target.</p>
|
||||
</section>
|
||||
<button class="reset-button reset-button--neutral" type="button" @click="native.trimCache()">Trim inactive view cache</button>
|
||||
<section class="settings-group">
|
||||
<h2>Simulation</h2>
|
||||
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useNativeViewActiveEffect } from '@native-vue-router/core'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
|
||||
const store = useDemoStore()
|
||||
const mountId = crypto.randomUUID()
|
||||
const mountedAt = Date.now()
|
||||
const mountedSeconds = ref(0)
|
||||
const activeTicks = ref(0)
|
||||
|
||||
useNativeViewActiveEffect(() => {
|
||||
const update = () => {
|
||||
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
|
||||
activeTicks.value += 1
|
||||
}
|
||||
update()
|
||||
const timer = window.setInterval(update, 200)
|
||||
return () => window.clearInterval(timer)
|
||||
})
|
||||
const gradients = [
|
||||
'linear-gradient(155deg, #f4a261, #d64e7d 48%, #5427a8)',
|
||||
'linear-gradient(155deg, #44c6e9, #4378db 48%, #1b245d)',
|
||||
@@ -13,7 +29,13 @@ const gradients = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs">
|
||||
<main
|
||||
class="screen screen--tabs"
|
||||
data-testid="stories-view"
|
||||
:data-mount-id="mountId"
|
||||
:data-mounted-seconds="mountedSeconds"
|
||||
:data-active-ticks="activeTicks"
|
||||
>
|
||||
<AppHeader title="Stories" subtitle="Moments from your circle" large />
|
||||
<section class="story-grid">
|
||||
<article v-for="(person, index) in store.people.value.slice(0, 4)" :key="person.id" class="story-card" :style="{ background: gradients[index] }">
|
||||
@@ -26,6 +48,6 @@ const gradients = [
|
||||
<span class="story-card__mark">{{ ['✦', '◌', '△', '◇'][index] }}</span>
|
||||
</article>
|
||||
</section>
|
||||
<p class="gesture-tip">Swipe horizontally anywhere to move between primary routes.</p>
|
||||
<p class="gesture-tip">Mounted {{ mountedSeconds }}s · active work {{ activeTicks }} ticks. The tick loop pauses while this sibling is cached.</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -61,6 +61,8 @@ function createWindow() {
|
||||
{ label: 'Back', accelerator: 'Alt+Left', click: () => window.webContents.send('native-vue:back') },
|
||||
{ label: 'Forward', accelerator: 'Alt+Right', click: () => window.webContents.send('native-vue:forward') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Trim Navigation Cache', click: () => window.webContents.send('native-vue:memory-pressure') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'reload' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,4 +9,5 @@ function listener(channel, callback) {
|
||||
contextBridge.exposeInMainWorld('nativeVueHost', {
|
||||
onBack: (callback) => listener('native-vue:back', callback),
|
||||
onForward: (callback) => listener('native-vue:forward', callback),
|
||||
onMemoryPressure: (callback) => listener('native-vue:memory-pressure', callback),
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ interface NativeRouteOptions {
|
||||
siblingGroup?: string
|
||||
siblingOrder?: number
|
||||
siblingHistory?: 'push' | 'replace'
|
||||
cache?: boolean
|
||||
cache?: boolean | 'pin'
|
||||
gesture?: boolean | 'edge' | 'full'
|
||||
}
|
||||
```
|
||||
@@ -57,4 +57,29 @@ Applications can call `beginInteractive()`, `updateInteractive()`, and `finishIn
|
||||
|
||||
## Cache semantics
|
||||
|
||||
The active route and recent inactive routes remain mounted. The default limit is eight inactive views per runtime. Older entries keep their route descriptor but are unmounted and lazily restored when revisited. Application data that must survive eviction belongs in an application store.
|
||||
The cache is lazy: application startup mounts the current route, not every sibling. A replace-style sibling is created when it is first visited or previewed and can then remain mounted without becoming a browser-back entry. Recent history targets can also stay warm so predictive Back restores component-local state such as a scrolled list immediately.
|
||||
|
||||
The default limit is four inactive views per runtime. `cache: false` always unmounts an inactive route, while `cache: 'pin'` exempts it from ordinary LRU and manual trimming. A pushed detail route that is popped or dismissed is unmounted after its exit animation unless it is explicitly pinned. If a guard rejects a cached destination, that component tree is evicted because it is no longer a valid navigation target. Older entries keep lightweight route descriptors and are lazily reconstructed if history reaches them again.
|
||||
|
||||
This is deliberately not implemented with a single Vue `<KeepAlive>`. An interactive transition must render the current and destination route instances concurrently, while one `<KeepAlive>` outlet normally activates one selected child. Separate temporary wrappers would themselves be removed and lose their caches. The runtime therefore owns the small multi-view cache and exposes equivalent route-aware lifecycle signals:
|
||||
|
||||
```ts
|
||||
import {
|
||||
onNativeViewActivate,
|
||||
onNativeViewDeactivate,
|
||||
onNativeViewEvict,
|
||||
useNativeViewActiveEffect,
|
||||
useNativeViewLifecycle,
|
||||
} from '@native-vue-router/core'
|
||||
|
||||
const view = useNativeViewLifecycle()
|
||||
|
||||
useNativeViewActiveEffect(() => {
|
||||
const timer = startPolling()
|
||||
return () => stopPolling(timer)
|
||||
})
|
||||
|
||||
onNativeViewEvict((reason) => saveDraft(view.route.value, reason))
|
||||
```
|
||||
|
||||
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store.
|
||||
|
||||
@@ -142,11 +142,13 @@ Keeping a tab mounted is a rendering concern; deciding whether Back should visit
|
||||
|
||||
### 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.
|
||||
The runtime separates the history-key ledger from the mounted-view cache. A replaced sibling is created lazily and can remain reusable without entering the back path. Popped pushed routes and guard-rejected destinations are evicted; history descriptors remain available for reconstruction. Statuses distinguish active, inactive, preview, and evicted entries. Route-aware active/visible effects give cached components a way to suspend work.
|
||||
|
||||
### 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.
|
||||
Mounted routes consume memory. The inactive cache is bounded and can be trimmed by the host, while `cache: false` and `cache: 'pin'` make exceptional route policy explicit. Evicted component-local state is not guaranteed to survive. Durable state belongs in Pinia, another store, IndexedDB, or the backend.
|
||||
|
||||
Vue's `<KeepAlive>` was not used as the cache owner. One shared wrapper is designed to select a current child, but a predictive gesture renders two route instances concurrently. Creating independent wrappers per temporary route layer would make wrapper lifetime control cache lifetime and complicate deterministic LRU eviction. The trade-off is a small router-owned cache with lifecycle APIs instead of Vue's built-in activated/deactivated hooks.
|
||||
|
||||
## Challenge 11: accessibility with concurrent routes
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ Pointer capture keeps delivery stable after recognition. Recognizer state is det
|
||||
|
||||
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.
|
||||
Siblings are mounted lazily on their first visit or gesture preview, not all at application startup. The cache has a configurable inactive-view limit and an explicit opt-out/pin policy. Popping a pushed route releases its component tree after the exit animation; a rejected cached guard target is evicted immediately. 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.
|
||||
|
||||
A normal Vue `<KeepAlive>` is excellent when one outlet selects one child. It is not the cache primitive here because an interactive transition needs two independently addressed route instances to be active at once. Instead, the router owns those sibling view instances and exposes active, visible, cached, and eviction lifecycle signals. This preserves the useful KeepAlive distinction—mounted versus currently active—without coupling navigation history to Vue's single-child activation model.
|
||||
|
||||
## Platform behavior
|
||||
|
||||
@@ -122,4 +124,3 @@ Native Vue Router addresses this by treating interactive navigation as its own s
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ Mobile operating systems can reserve gestures before web content receives them.
|
||||
|
||||
## Electron
|
||||
|
||||
Call `disableElectronHistoryGestures(app.commandLine)` before `app.whenReady()`. It disables Chromium's `OverscrollHistoryNavigation`, preventing the host from racing the renderer's interactive stack. The included preload bridge maps app commands and Alt+Arrow shortcuts into the renderer adapter without enabling Node integration.
|
||||
Call `disableElectronHistoryGestures(app.commandLine)` before `app.whenReady()`. It disables Chromium's `OverscrollHistoryNavigation`, preventing the host from racing the renderer's interactive stack. The included preload bridge maps app commands, memory-pressure notifications, and Alt+Arrow shortcuts into the renderer adapter without enabling Node integration.
|
||||
|
||||
The demo switches to hash history under `file:` so packaged deep navigation never asks the filesystem for route paths.
|
||||
|
||||
## Capacitor
|
||||
|
||||
`createCapacitorAdapter()` handles Android hardware back, Universal/App Links, launch URLs, pause cancellation, root exit, and native haptic feedback.
|
||||
`createCapacitorAdapter()` handles Android hardware back, Universal/App Links, launch URLs, pause cancellation, root exit, and native haptic feedback. It trims inactive views when the native app pauses by default; set `trimCacheOnPause: false` only when the application deliberately prefers warm views over background memory release.
|
||||
|
||||
The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics, Splash Screen, and Status Bar plugins. Rebuild the web bundle before `npx cap sync`.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The runtime is intended to scale in four different ways: number of routes, sessi
|
||||
|
||||
### 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.
|
||||
Only the active route, previously visited inactive routes, and a transaction preview need mounted component trees. Siblings are not instantiated eagerly at application startup. `maxInactive` bounds the inactive mounted cache; the default is four. Pinned entries are deliberately outside this ordinary budget. Older views are marked evicted and lazily remounted when needed.
|
||||
|
||||
During an interaction, animation work concerns two surfaces regardless of total route count:
|
||||
|
||||
@@ -68,6 +68,8 @@ The current implementation uses linear searches through view entries for some re
|
||||
|
||||
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.
|
||||
|
||||
Cached components should also avoid doing active-screen work indefinitely. Route-aware lifecycle effects let polling, animation loops, media, and subscriptions stop while a component is inactive and resume without losing its local render state. Hosts can call `trimCache()` under memory pressure; the Capacitor adapter does so when the app pauses by default.
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { NativePlatformAdapter, NativeRouterRuntime } from '@native-vue-rou
|
||||
export interface CapacitorAdapterOptions {
|
||||
exitAtRoot?: boolean
|
||||
haptics?: boolean
|
||||
/** Release inactive component trees when the native app backgrounds. Defaults to true. */
|
||||
trimCacheOnPause?: boolean
|
||||
deepLinkPath?: (url: URL) => string
|
||||
}
|
||||
|
||||
@@ -31,7 +33,10 @@ export function createCapacitorAdapter(options: CapacitorAdapterOptions = {}): N
|
||||
const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
void runtime.push(path || '/')
|
||||
}),
|
||||
App.addListener('pause', () => void runtime.cancelInteractive()),
|
||||
App.addListener('pause', () => {
|
||||
void runtime.cancelInteractive()
|
||||
if (options.trimCacheOnPause !== false) runtime.trimCache({ reason: 'memory-pressure' })
|
||||
}),
|
||||
])
|
||||
const launch = await App.getLaunchUrl()
|
||||
if (launch?.url) {
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
h,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
provide,
|
||||
ref,
|
||||
shallowReactive,
|
||||
watch,
|
||||
type PropType,
|
||||
type InjectionKey,
|
||||
type VNode,
|
||||
} from 'vue'
|
||||
import {
|
||||
@@ -24,8 +27,12 @@ import type {
|
||||
NativeRouterRuntime,
|
||||
NativeSourceRect,
|
||||
NativeViewEntry,
|
||||
NativeViewLifecycle,
|
||||
NativeViewRole,
|
||||
} from './types'
|
||||
|
||||
const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol('native-view-lifecycle')
|
||||
|
||||
export function useNativeRouter() {
|
||||
const runtime = inject<NativeRouterRuntime>(nativeRouterKey)
|
||||
if (!runtime) throw new Error('Native Vue Router is not installed. Call app.use(nativeRouter).')
|
||||
@@ -33,27 +40,103 @@ export function useNativeRouter() {
|
||||
}
|
||||
|
||||
export function useNativeViewLifecycle() {
|
||||
const runtime = useNativeRouter()
|
||||
return {
|
||||
activeKey: runtime.activeKey,
|
||||
transaction: runtime.transaction,
|
||||
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey)
|
||||
if (!lifecycle) throw new Error('Native view lifecycle APIs must be used inside NativeRouterView.')
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
type NativeViewHook = () => void
|
||||
|
||||
function onNativeViewState(source: Readonly<{ value: boolean }>, entering: boolean, hook: NativeViewHook) {
|
||||
onMounted(() => {
|
||||
if (entering && source.value) hook()
|
||||
})
|
||||
watch(() => source.value, (value, previous) => {
|
||||
if (value === entering && previous !== entering) hook()
|
||||
}, { flush: 'sync' })
|
||||
}
|
||||
|
||||
export function onNativeViewActivate(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isActive, true, hook)
|
||||
}
|
||||
|
||||
export function onNativeViewDeactivate(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isActive, false, hook)
|
||||
}
|
||||
|
||||
export function onNativeViewShow(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook)
|
||||
}
|
||||
|
||||
export function onNativeViewHide(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook)
|
||||
}
|
||||
|
||||
export function onNativeViewEvict(hook: (reason: NativeViewLifecycle['evictionReason']['value']) => void) {
|
||||
const lifecycle = useNativeViewLifecycle()
|
||||
onBeforeUnmount(() => {
|
||||
if (lifecycle.status.value === 'evicted') hook(lifecycle.evictionReason.value)
|
||||
})
|
||||
}
|
||||
|
||||
function useNativeViewEffect(
|
||||
source: Readonly<{ value: boolean }>,
|
||||
effect: () => void | (() => void),
|
||||
) {
|
||||
let cleanup: void | (() => void)
|
||||
const stopEffect = () => {
|
||||
cleanup?.()
|
||||
cleanup = undefined
|
||||
}
|
||||
const stopWatch = watch(() => source.value, (enabled) => {
|
||||
stopEffect()
|
||||
if (enabled) cleanup = effect()
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
onScopeDispose(() => {
|
||||
stopWatch()
|
||||
stopEffect()
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs an effect only while this route is the semantically active route. */
|
||||
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
|
||||
useNativeViewEffect(useNativeViewLifecycle().isActive, effect)
|
||||
}
|
||||
|
||||
/** Runs an effect while this route is active or participating in a transition. */
|
||||
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
|
||||
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect)
|
||||
}
|
||||
|
||||
const NativeRouteScope = defineComponent({
|
||||
name: 'NativeRouteScope',
|
||||
props: {
|
||||
route: { type: Object as PropType<RouteLocationNormalizedLoaded>, required: true },
|
||||
entryKey: { type: String, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const runtime = useNativeRouter()
|
||||
const scopedRoute = shallowReactive({ ...props.route }) as RouteLocationNormalizedLoaded
|
||||
watch(() => props.route, (route) => Object.assign(scopedRoute, route), { immediate: true })
|
||||
provide(routeLocationKey, scopedRoute)
|
||||
const entry = computed(() => runtime.entries.value.find((candidate) => candidate.key === props.entryKey))
|
||||
const role = computed<NativeViewRole>(() => entry.value ? interactiveRole(entry.value, runtime) : 'inactive')
|
||||
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
|
||||
key: props.entryKey,
|
||||
route: computed(() => entry.value?.route ?? props.route),
|
||||
status: computed(() => entry.value?.status ?? 'evicted'),
|
||||
role,
|
||||
isActive: computed(() => runtime.activeKey.value === props.entryKey),
|
||||
isVisible: computed(() => role.value !== 'inactive'),
|
||||
isPreview: computed(() => runtime.transaction.value?.toKey === props.entryKey),
|
||||
isCached: computed(() => Boolean(entry.value?.mounted && role.value === 'inactive')),
|
||||
evictionReason: computed(() => entry.value?.evictionReason),
|
||||
})
|
||||
return () => slots.default?.()
|
||||
},
|
||||
})
|
||||
|
||||
function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime) {
|
||||
function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime): NativeViewRole {
|
||||
const transaction = runtime.transaction.value
|
||||
if (!transaction) return entry.key === runtime.activeKey.value ? 'active' : 'inactive'
|
||||
if (entry.key === transaction.fromKey) return 'from'
|
||||
@@ -96,7 +179,7 @@ export const NativeRouterView = defineComponent({
|
||||
default: ({ Component, route }: { Component: VNode | null; route: RouteLocationNormalizedLoaded }) => {
|
||||
if (slots.default) return slots.default({ Component, route, entry })
|
||||
return Component
|
||||
? h(NativeRouteScope, { route }, { default: () => Component })
|
||||
? h(NativeRouteScope, { route, entryKey: entry.key }, { default: () => Component })
|
||||
: null
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -21,6 +21,8 @@ async function harness(blockB: boolean | 'redirect' = false) {
|
||||
{ path: '/left', component: Page, meta: { native: { siblingOrder: 0, siblingHistory: 'replace' } } },
|
||||
{ path: '/middle', component: Page, meta: { native: { siblingOrder: 1, siblingHistory: 'replace' } } },
|
||||
{ path: '/right', component: Page, meta: { native: { siblingOrder: 2, siblingHistory: 'replace' } } },
|
||||
{ path: '/no-cache', component: Page, meta: { native: { cache: false, parent: '/a' } } },
|
||||
{ path: '/pinned', component: Page, meta: { native: { cache: 'pin', parent: '/a' } } },
|
||||
],
|
||||
})
|
||||
if (blockB) router.beforeEach((to) => to.path === '/b' ? (blockB === 'redirect' ? '/modal' : false) : undefined)
|
||||
@@ -145,6 +147,97 @@ describe('native router transactions', () => {
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('creates sibling views lazily and retains visited replace-style siblings', async () => {
|
||||
const { native } = await harness()
|
||||
expect(native.entries.value.map((entry) => entry.route.path)).toEqual(['/a'])
|
||||
|
||||
await native.replace('/left', { presentation: 'none' })
|
||||
expect(native.entries.value.some((entry) => entry.route.path === '/middle')).toBe(false)
|
||||
await native.sibling('/middle', { replace: true })
|
||||
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({ mounted: true, status: 'inactive' })
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/middle')).toMatchObject({ mounted: true, status: 'active' })
|
||||
expect(native.entries.value.some((entry) => entry.route.path === '/right')).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts the least-recently-used inactive view when the cache limit is exceeded', async () => {
|
||||
let clock = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => ++clock)
|
||||
const { native } = await harness()
|
||||
await native.replace('/left', { presentation: 'none' })
|
||||
await native.sibling('/middle', { replace: true })
|
||||
await native.sibling('/right', { replace: true })
|
||||
await native.sibling('/a', { replace: true })
|
||||
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({
|
||||
mounted: false,
|
||||
evictionReason: 'cache-limit',
|
||||
})
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true)
|
||||
expect(native.entries.value.find((entry) => entry.route.path === '/right')?.mounted).toBe(true)
|
||||
expect(native.cacheStats.value.inactive).toBe(2)
|
||||
})
|
||||
|
||||
it('evicts a pushed route after it is popped out of history', async () => {
|
||||
const { native } = await harness()
|
||||
await native.push('/b')
|
||||
const pushedKey = native.activeKey.value
|
||||
await native.pop()
|
||||
|
||||
expect(native.entries.value.find((entry) => entry.key === pushedKey)).toMatchObject({
|
||||
mounted: false,
|
||||
status: 'evicted',
|
||||
evictionReason: 'popped',
|
||||
})
|
||||
})
|
||||
|
||||
it('honors cache opt-out even for a route that remains in back history', async () => {
|
||||
const { native } = await harness()
|
||||
await native.push('/no-cache')
|
||||
const noCacheKey = native.activeKey.value
|
||||
await native.push('/c')
|
||||
|
||||
expect(native.entries.value.find((entry) => entry.key === noCacheKey)).toMatchObject({
|
||||
mounted: false,
|
||||
evictionReason: 'cache-disabled',
|
||||
})
|
||||
expect(await native.beginInteractive('pop')).not.toBeNull()
|
||||
expect(native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted).toBe(true)
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('keeps pinned views during normal trims and releases them when requested', async () => {
|
||||
const { native } = await harness()
|
||||
await native.replace('/pinned', { presentation: 'none' })
|
||||
const pinnedKey = native.activeKey.value
|
||||
await native.push('/c')
|
||||
|
||||
native.trimCache()
|
||||
expect(native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted).toBe(true)
|
||||
native.trimCache({ includePinned: true })
|
||||
expect(native.entries.value.find((entry) => entry.key === pinnedKey)).toMatchObject({
|
||||
mounted: false,
|
||||
evictionReason: 'trimmed',
|
||||
})
|
||||
})
|
||||
|
||||
it('evicts a previously cached target when its guard rejects re-entry', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.replace('/left', { presentation: 'none' })
|
||||
await native.sibling('/middle', { replace: true })
|
||||
const cachedLeft = native.entries.value.find((entry) => entry.route.path === '/left')
|
||||
expect(cachedLeft?.mounted).toBe(true)
|
||||
const removeGuard = router.beforeEach((to) => to.path === '/left' ? false : undefined)
|
||||
|
||||
expect(await native.sibling('/left', { replace: true })).toBe(false)
|
||||
expect(native.entries.value.find((entry) => entry.key === cachedLeft?.key)).toMatchObject({
|
||||
mounted: false,
|
||||
evictionReason: 'navigation-rejected',
|
||||
})
|
||||
expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0)
|
||||
removeGuard()
|
||||
})
|
||||
|
||||
it('does not preview a stale forward entry after pop then push', async () => {
|
||||
const { native } = await harness()
|
||||
await native.push('/b')
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'vue-router'
|
||||
import type {
|
||||
NativeDirection,
|
||||
NativeEvictionReason,
|
||||
NativeGestureKind,
|
||||
NativeNavigationOptions,
|
||||
NativePlatformAdapter,
|
||||
@@ -81,6 +82,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
readonly activeKey
|
||||
readonly transaction
|
||||
readonly canGoBack
|
||||
readonly cacheStats
|
||||
|
||||
private readonly mutableEntries = shallowRef<NativeViewEntry[]>([])
|
||||
private readonly mutableActiveKey = ref('')
|
||||
@@ -95,14 +97,32 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
private removeAfterEach?: () => void
|
||||
private platformCleanup?: () => void
|
||||
private pendingPop?: (failure?: NavigationFailure | void) => void
|
||||
private totalEvictions = 0
|
||||
private lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
|
||||
private memoryPressureCleanup?: () => void
|
||||
|
||||
constructor(options: NativeRouterOptions) {
|
||||
this.router = options.router
|
||||
this.maxInactive = options.cache?.maxInactive ?? 8
|
||||
this.maxInactive = Math.max(0, options.cache?.maxInactive ?? 4)
|
||||
this.platform = options.platform
|
||||
this.entries = computed(() => this.mutableEntries.value)
|
||||
this.activeKey = computed(() => this.mutableActiveKey.value)
|
||||
this.transaction = computed(() => this.mutableTransaction.value)
|
||||
this.cacheStats = computed(() => {
|
||||
const entries = this.mutableEntries.value
|
||||
const mounted = entries.filter((entry) => entry.mounted)
|
||||
const inactive = mounted.filter((entry) => entry.key !== this.mutableActiveKey.value)
|
||||
return {
|
||||
maxInactive: this.maxInactive,
|
||||
descriptors: entries.length,
|
||||
mounted: mounted.length,
|
||||
inactive: inactive.length,
|
||||
pinned: inactive.filter((entry) => entry.route.meta.native?.cache === 'pin').length,
|
||||
evicted: entries.filter((entry) => !entry.mounted).length,
|
||||
totalEvictions: this.totalEvictions,
|
||||
lastEviction: this.lastEviction,
|
||||
}
|
||||
})
|
||||
this.canGoBack = computed(() => {
|
||||
const transaction = this.mutableTransaction.value
|
||||
const committingForwardEntry = transaction
|
||||
@@ -141,6 +161,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (cleanup) this.platformCleanup = cleanup
|
||||
})
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const trimForPressure = () => this.trimCache({ reason: 'memory-pressure' })
|
||||
window.addEventListener('memorypressure', trimForPressure)
|
||||
this.memoryPressureCleanup = () => window.removeEventListener('memorypressure', trimForPressure)
|
||||
}
|
||||
}
|
||||
|
||||
async preload(to: RouteLocationRaw) {
|
||||
@@ -221,6 +246,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
target.route = route
|
||||
target.mounted = true
|
||||
target.status = 'inactive'
|
||||
target.evictionReason = undefined
|
||||
this.touchEntries()
|
||||
}
|
||||
} else {
|
||||
@@ -236,6 +262,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
target.status = 'preview'
|
||||
target.synthetic = false
|
||||
target.lastUsed = now()
|
||||
target.evictionReason = undefined
|
||||
this.touchEntries()
|
||||
} else {
|
||||
target = entryFor(route, 'preview')
|
||||
@@ -309,7 +336,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
if (!this.isCurrent(current.id)) return !failed
|
||||
if (failed) {
|
||||
await this.animateProgress(0, 0)
|
||||
if (this.isCurrent(current.id)) this.finalizeCancelled(current)
|
||||
if (this.isCurrent(current.id)) this.finalizeCancelled(current, true)
|
||||
return false
|
||||
}
|
||||
this.finalizeCommitted(current)
|
||||
@@ -326,6 +353,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
this.finalizeCancelled(current)
|
||||
}
|
||||
|
||||
trimCache(options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}) {
|
||||
const reason = options.reason ?? 'trimmed'
|
||||
for (const entry of this.mutableEntries.value) {
|
||||
if (entry.key === this.mutableActiveKey.value || !entry.mounted || entry.status !== 'inactive') continue
|
||||
if (!options.includePinned && entry.route.meta.native?.cache === 'pin') continue
|
||||
this.evictEntry(entry, reason)
|
||||
}
|
||||
this.touchEntries()
|
||||
}
|
||||
|
||||
registerPresentation(definition: NativePresentationDefinition) {
|
||||
this.presentations.set(definition.name, definition)
|
||||
}
|
||||
@@ -350,6 +387,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
dispose() {
|
||||
this.removeAfterEach?.()
|
||||
this.platformCleanup?.()
|
||||
this.memoryPressureCleanup?.()
|
||||
}
|
||||
|
||||
private activeEntry() {
|
||||
@@ -414,15 +452,17 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
velocity: 0,
|
||||
phase: 'settling',
|
||||
}
|
||||
if (failed) this.finalizeCancelled(transaction)
|
||||
if (failed) this.finalizeCancelled(transaction, true)
|
||||
else this.finalizeCommitted(transaction)
|
||||
}
|
||||
|
||||
private finalizeCancelled(transaction: NativeTransaction) {
|
||||
private finalizeCancelled(transaction: NativeTransaction, evictTarget = false) {
|
||||
if (!this.isCurrent(transaction.id)) return
|
||||
this.removePreview(transaction.toKey)
|
||||
this.clearTransaction()
|
||||
if (evictTarget) this.discardTarget(transaction.toKey, 'navigation-rejected')
|
||||
else this.removePreview(transaction.toKey)
|
||||
this.markStatuses()
|
||||
this.enforceCache()
|
||||
}
|
||||
|
||||
private finalizeCommitted(transaction: NativeTransaction) {
|
||||
@@ -430,9 +470,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
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.discardTarget(transaction.toKey, 'navigation-rejected')
|
||||
this.markStatuses()
|
||||
this.enforceCache()
|
||||
return
|
||||
}
|
||||
if (target) {
|
||||
@@ -486,11 +527,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
target.status = 'active'
|
||||
target.committed = true
|
||||
target.lastUsed = now()
|
||||
target.evictionReason = undefined
|
||||
this.touchEntries()
|
||||
}
|
||||
this.mutableActiveKey.value = target.key
|
||||
this.acceptHistory(target, transaction)
|
||||
this.markStatuses()
|
||||
if (!transaction) this.enforceCache()
|
||||
}
|
||||
|
||||
private acceptHistory(target: NativeViewEntry, transaction: NativeTransaction | null) {
|
||||
@@ -542,16 +585,49 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private discardTarget(key: string, reason: NativeEvictionReason) {
|
||||
const entry = this.entryByKey(key)
|
||||
if (!entry || entry.key === this.mutableActiveKey.value) return
|
||||
if (!entry.committed || entry.synthetic) {
|
||||
this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
|
||||
return
|
||||
}
|
||||
this.evictEntry(entry, reason)
|
||||
}
|
||||
|
||||
private evictEntry(entry: NativeViewEntry, reason: NativeEvictionReason) {
|
||||
if (!entry.mounted || entry.key === this.mutableActiveKey.value) return
|
||||
entry.mounted = false
|
||||
entry.status = 'evicted'
|
||||
entry.evictionReason = reason
|
||||
this.totalEvictions += 1
|
||||
this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason }
|
||||
}
|
||||
|
||||
private shouldRetainInactive(entry: NativeViewEntry) {
|
||||
const policy = entry.route.meta.native?.cache
|
||||
if (policy === false) return false
|
||||
if (policy === 'pin') return true
|
||||
if (this.mutableHistoryKeys.value.includes(entry.key)) return true
|
||||
return entry.route.meta.native?.siblingHistory === 'replace'
|
||||
}
|
||||
|
||||
private enforceCache() {
|
||||
const inactive = this.mutableEntries.value
|
||||
.filter((entry) => entry.key !== this.mutableActiveKey.value && entry.mounted && entry.status === 'inactive')
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
for (const entry of inactive.slice(this.maxInactive)) {
|
||||
if (entry.route.meta.native?.cache === false || inactive.length > this.maxInactive) {
|
||||
entry.mounted = false
|
||||
entry.status = 'evicted'
|
||||
for (const entry of inactive) {
|
||||
if (!this.shouldRetainInactive(entry)) {
|
||||
const reason: NativeEvictionReason = entry.route.meta.native?.cache === false
|
||||
? 'cache-disabled'
|
||||
: 'popped'
|
||||
this.evictEntry(entry, reason)
|
||||
}
|
||||
}
|
||||
const retained = inactive.filter((entry) => entry.mounted && entry.route.meta.native?.cache !== 'pin')
|
||||
for (const entry of retained.slice(this.maxInactive)) {
|
||||
this.evictEntry(entry, 'cache-limit')
|
||||
}
|
||||
this.touchEntries()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ export type NativePresentationName =
|
||||
export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss'
|
||||
export type NativeDirection = 'forward' | 'back' | 'up' | 'down'
|
||||
export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted'
|
||||
export type NativeViewRole = 'active' | 'inactive' | 'from' | 'to'
|
||||
export type NativeCachePolicy = boolean | 'pin'
|
||||
export type NativeEvictionReason =
|
||||
| 'cache-disabled'
|
||||
| 'cache-limit'
|
||||
| 'navigation-rejected'
|
||||
| 'popped'
|
||||
| 'trimmed'
|
||||
| 'memory-pressure'
|
||||
|
||||
export interface NativeRouteOptions {
|
||||
navigator?: string
|
||||
@@ -27,7 +36,8 @@ export interface NativeRouteOptions {
|
||||
siblingGroup?: string
|
||||
siblingOrder?: number
|
||||
siblingHistory?: 'push' | 'replace'
|
||||
cache?: boolean
|
||||
/** `false` disables retention; `pin` exempts the route from LRU trimming. */
|
||||
cache?: NativeCachePolicy
|
||||
gesture?: boolean | 'edge' | 'full'
|
||||
}
|
||||
|
||||
@@ -48,6 +58,30 @@ export interface NativeViewEntry {
|
||||
lastUsed: number
|
||||
scrollX: number
|
||||
scrollY: number
|
||||
evictionReason?: NativeEvictionReason
|
||||
}
|
||||
|
||||
export interface NativeCacheStats {
|
||||
maxInactive: number
|
||||
descriptors: number
|
||||
mounted: number
|
||||
inactive: number
|
||||
pinned: number
|
||||
evicted: number
|
||||
totalEvictions: number
|
||||
lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
|
||||
}
|
||||
|
||||
export interface NativeViewLifecycle {
|
||||
readonly key: string
|
||||
readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>
|
||||
readonly status: Readonly<{ value: NativeViewStatus }>
|
||||
readonly role: Readonly<{ value: NativeViewRole }>
|
||||
readonly isActive: Readonly<{ value: boolean }>
|
||||
readonly isVisible: Readonly<{ value: boolean }>
|
||||
readonly isPreview: Readonly<{ value: boolean }>
|
||||
readonly isCached: Readonly<{ value: boolean }>
|
||||
readonly evictionReason: Readonly<{ value: NativeEvictionReason | undefined }>
|
||||
}
|
||||
|
||||
export interface NativeSourceRect {
|
||||
@@ -114,6 +148,7 @@ export interface NativeRouterRuntime {
|
||||
readonly activeKey: Readonly<{ value: string }>
|
||||
readonly transaction: Readonly<{ value: NativeTransaction | null }>
|
||||
readonly canGoBack: Readonly<{ value: boolean }>
|
||||
readonly cacheStats: Readonly<{ value: NativeCacheStats }>
|
||||
install(app: App): void
|
||||
push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
|
||||
replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
|
||||
@@ -126,6 +161,8 @@ export interface NativeRouterRuntime {
|
||||
updateInteractive(progress: number, velocity?: number): void
|
||||
finishInteractive(forceCommit?: boolean): Promise<boolean>
|
||||
cancelInteractive(): Promise<void>
|
||||
/** Unmount inactive cached views while retaining route/history descriptors. */
|
||||
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void
|
||||
registerPresentation(definition: NativePresentationDefinition): void
|
||||
presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined
|
||||
dispose(): void
|
||||
|
||||
@@ -13,6 +13,7 @@ declare global {
|
||||
nativeVueHost?: {
|
||||
onBack(callback: () => void): () => void
|
||||
onForward?(callback: () => void): () => void
|
||||
onMemoryPressure?(callback: () => void): () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,9 +26,13 @@ export function createElectronRendererAdapter(): NativePlatformAdapter {
|
||||
if (runtime.canGoBack.value) void runtime.pop()
|
||||
})
|
||||
const removeForward = window.nativeVueHost?.onForward?.(() => runtime.router.forward())
|
||||
const removeMemoryPressure = window.nativeVueHost?.onMemoryPressure?.(() => {
|
||||
runtime.trimCache({ reason: 'memory-pressure' })
|
||||
})
|
||||
return () => {
|
||||
removeBack?.()
|
||||
removeForward?.()
|
||||
removeMemoryPressure?.()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user