Files
Native-Router-Vue/packages/core/src/runtime/view-store.ts
T
2026-07-22 11:37:46 +00:00

303 lines
9.3 KiB
TypeScript

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<NativeViewEntry[]>([]);
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<string>();
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 },
});
}
}