Improve caching

This commit is contained in:
2026-07-21 18:45:59 +10:00
parent 92c61abcfe
commit 7d134ff8c9
24 changed files with 519 additions and 105 deletions

View 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)
}

View File

@@ -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
}

View File

@@ -17,7 +17,7 @@ const platform = isElectron
const nativeRouter = createNativeRouter({
router,
cache: { maxInactive: 8 },
cache: { maxInactive: 4 },
platform,
})

View File

@@ -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' } },
},
{

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>