import { computed, ref, shallowRef } from "vue"; import type { RouteLocationNormalizedLoaded } from "vue-router"; import type { NativeCacheStats, NativeEvictionReason, NativeTransaction, NativeViewEntry, } from "../types"; import { DiagnosticChannel } from "./diagnostics"; import { NativeHistoryLedger } from "./history-ledger"; import { createViewEntry, diagnosticRoute, monotonicNow } from "./route-entry"; export class ViewStore { readonly entries = shallowRef([]); readonly activeKey = ref(""); readonly cacheStats; private totalEvictions = 0; private lastEviction: NativeCacheStats["lastEviction"]; constructor( private readonly maxInactive: number, private readonly history: NativeHistoryLedger, private readonly diagnostics: DiagnosticChannel, ) { this.cacheStats = computed(() => { const entries = this.entries.value; const mounted = entries.filter((entry) => entry.mounted); const inactive = mounted.filter( (entry) => entry.key !== this.activeKey.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, }; }); } initialize(route: RouteLocationNormalizedLoaded) { if (this.entries.value.length) return; const initial = createViewEntry(route, "active"); this.entries.value = [initial]; this.activeKey.value = initial.key; this.history.initialize(initial.key); } active() { return this.byKey(this.activeKey.value); } byKey(key: string) { return this.entries.value.find((entry) => entry.key === key); } findReusable(fullPath: string) { return [...this.entries.value] .reverse() .find( (entry) => entry.route.fullPath === fullPath && entry.key !== this.activeKey.value && !this.history.contains(entry.key), ); } findHistoryEntry(fullPath: string) { for (const key of [...this.history.keys.value].reverse()) { const entry = this.byKey(key); if (entry?.route.fullPath === fullPath) return entry; } return undefined; } appendPreview(route: RouteLocationNormalizedLoaded, synthetic = false) { const entry = createViewEntry(route, "preview", synthetic); this.entries.value = [...this.entries.value, entry]; return entry; } prepareForward(route: RouteLocationNormalizedLoaded, replace: boolean) { let entry = replace ? this.findHistoryEntry(route.fullPath) : undefined; entry ??= this.findReusable(route.fullPath); if (!entry) return { entry: this.appendPreview(route), needsMount: true }; const needsMount = !entry.mounted; entry.route = route; entry.mounted = true; entry.status = "preview"; entry.synthetic = false; entry.lastUsed = monotonicNow(); entry.evictionReason = undefined; this.touch(); return { entry, needsMount }; } revive(entry: NativeViewEntry, route: RouteLocationNormalizedLoaded) { entry.route = route; entry.mounted = true; entry.status = "inactive"; entry.evictionReason = undefined; this.touch(); } commitTarget(entry: NativeViewEntry) { entry.status = "active"; entry.synthetic = false; entry.committed = true; entry.lastUsed = monotonicNow(); this.activeKey.value = entry.key; } acceptRoute( route: RouteLocationNormalizedLoaded, transaction: NativeTransaction | null, ) { const previousActiveKey = this.activeKey.value; let target = transaction ? this.byKey(transaction.toKey) : undefined; if (target && target.route.fullPath !== route.fullPath) target = undefined; if (!transaction) target ??= this.findHistoryEntry(route.fullPath); target ??= this.findReusable(route.fullPath); if (!target && this.active()?.route.fullPath === route.fullPath) target = this.active(); if (!target) { target = createViewEntry(route, "active"); const activeIndex = this.entries.value.findIndex( (entry) => entry.key === this.activeKey.value, ); const head = activeIndex >= 0 ? this.entries.value.slice(0, activeIndex + 1) : this.entries.value; this.entries.value = [...head, target]; } else { target.route = route; target.presentation ??= route.meta.native?.presentation ?? route.meta.native?.transition; target.mounted = true; target.status = "active"; target.committed = true; target.lastUsed = monotonicNow(); target.evictionReason = undefined; this.touch(); } if (target.presentation === "sheet" && target.key !== previousActiveKey) target.underlayKey = previousActiveKey || undefined; this.activeKey.value = target.key; this.history.accept(target, transaction); this.markStatuses(transaction); if (!transaction) this.enforceCache(); } unload(fullPath: string, transaction: NativeTransaction | null) { let unloaded = 0; for (const entry of this.entries.value) { if (entry.route.fullPath !== fullPath || !entry.mounted) continue; if (entry.key === this.activeKey.value) continue; if ( entry.key === transaction?.fromKey || entry.key === transaction?.toKey ) continue; this.evict(entry, "manual"); unloaded += 1; } if (unloaded) this.touch(); return unloaded; } trim( options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}, ) { const reason = options.reason ?? "trimmed"; for (const entry of this.entries.value) { if ( entry.key === this.activeKey.value || !entry.mounted || entry.status !== "inactive" ) continue; if (!options.includePinned && entry.route.meta.native?.cache === "pin") continue; this.evict(entry, reason); } this.touch(); } markStatuses(transaction: NativeTransaction | null) { for (const entry of this.entries.value) { if (entry.key === this.activeKey.value) entry.status = "active"; else if (transaction?.toKey === entry.key && entry.status === "preview") entry.status = "preview"; else if (entry.mounted) entry.status = "inactive"; else entry.status = "evicted"; } this.touch(); } removePreview(key: string) { const entry = this.byKey(key); if (!entry || entry.key === this.activeKey.value) return; if (entry.status === "preview" || entry.synthetic) { if (entry.synthetic || !entry.committed) { this.entries.value = this.entries.value.filter( (candidate) => candidate.key !== key, ); } else { entry.status = "inactive"; this.touch(); } } } discardTarget(key: string, reason: NativeEvictionReason) { const entry = this.byKey(key); if (!entry || entry.key === this.activeKey.value) return; if (!entry.committed || entry.synthetic) { this.entries.value = this.entries.value.filter( (candidate) => candidate.key !== key, ); return; } this.evict(entry, reason); } enforceCache() { const protectedUnderlayKeys = new Set(); let presentedEntry = this.active(); while ( presentedEntry?.presentation === "sheet" && presentedEntry.underlayKey && !protectedUnderlayKeys.has(presentedEntry.underlayKey) ) { protectedUnderlayKeys.add(presentedEntry.underlayKey); presentedEntry = this.byKey(presentedEntry.underlayKey); } const inactive = this.entries.value .filter( (entry) => entry.key !== this.activeKey.value && entry.mounted && entry.status === "inactive", ) .sort((a, b) => b.lastUsed - a.lastUsed); for (const entry of inactive) { if (protectedUnderlayKeys.has(entry.key)) continue; if (!this.shouldRetainInactive(entry)) { const reason: NativeEvictionReason = entry.route.meta.native?.cache === false ? "cache-disabled" : "popped"; this.evict(entry, reason); } } const retained = inactive.filter( (entry) => entry.mounted && entry.route.meta.native?.cache !== "pin" && !protectedUnderlayKeys.has(entry.key), ); for (const entry of retained.slice(this.maxInactive)) this.evict(entry, "cache-limit"); this.touch(); } touch() { this.entries.value = [...this.entries.value]; } private shouldRetainInactive(entry: NativeViewEntry) { const policy = entry.route.meta.native?.cache; if (policy === false) return false; if (policy === "pin") return true; if (this.history.contains(entry.key)) return true; return entry.route.meta.native?.siblingHistory === "replace"; } private evict(entry: NativeViewEntry, reason: NativeEvictionReason) { if (!entry.mounted || entry.key === this.activeKey.value) return; entry.mounted = false; entry.status = "evicted"; entry.evictionReason = reason; this.totalEvictions += 1; this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason }; this.diagnostics.emit("view-evicted", { route: diagnosticRoute(entry.route), details: { reason }, }); } }