Improve caching
This commit is contained in:
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user