Separate out into files

This commit is contained in:
2026-07-22 11:37:46 +00:00
parent 6aed7606ad
commit 5a514906eb
11 changed files with 906 additions and 657 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { nextTick, type ShallowRef } from "vue";
import type { NativeTransaction } from "../types";
import { monotonicNow } from "./route-entry";
export function shouldCommitGesture(
progress: number,
velocity: number,
threshold = 0.36,
) {
return progress >= threshold || (progress >= 0.08 && velocity >= 1.1);
}
/** Convert release velocity into a bounded spring simulation rate. */
export function springTimeScaleForVelocity(velocity: number) {
return 1 + Math.min(2, Math.abs(velocity) * 0.3);
}
export async function prepareMountedView() {
await nextTick();
if (typeof window === "undefined") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
await new Promise<void>((resolve) =>
window.requestAnimationFrame(() => resolve()),
);
}
export function animateProgress(
transactionState: ShallowRef<NativeTransaction | null>,
target: number,
initialVelocity: number,
) {
const transaction = transactionState.value;
if (!transaction) return Promise.resolve();
if (
typeof window === "undefined" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
transactionState.value = {
...transaction,
progress: target,
velocity: 0,
phase: "settling",
};
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let position = transaction.progress;
let velocity = Math.max(-12, Math.min(12, initialVelocity));
const timeScale = springTimeScaleForVelocity(initialVelocity);
let previous = monotonicNow();
const step = (time: number) => {
const live = transactionState.value;
if (!live || live.id !== transaction.id) return resolve();
const elapsed =
Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) * timeScale;
previous = time;
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const dt = elapsed / iterations;
for (let iteration = 0; iteration < iterations; iteration += 1) {
const displacement = target - position;
const acceleration = displacement * 280 - velocity * 30;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02;
transactionState.value = {
...live,
progress: done ? target : Math.max(0, Math.min(1, position)),
velocity,
phase: "settling",
};
if (done) resolve();
else requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
}

View File

@@ -0,0 +1,23 @@
import type {
NativeDiagnosticEvent,
NativeDiagnosticEventType,
} from "../types";
import { monotonicNow } from "./route-entry";
type DiagnosticListener = (event: NativeDiagnosticEvent) => void;
type DiagnosticPayload = Omit<NativeDiagnosticEvent, "type" | "timestamp">;
export class DiagnosticChannel {
private readonly listeners = new Set<DiagnosticListener>();
subscribe(listener: DiagnosticListener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
emit(type: NativeDiagnosticEventType, event: DiagnosticPayload = {}) {
if (!this.listeners.size) return;
const diagnostic = { type, timestamp: monotonicNow(), ...event };
for (const listener of this.listeners) listener(diagnostic);
}
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { NativeTransaction, NativeViewEntry } from "../types";
import { NativeHistoryLedger } from "./history-ledger";
import { PresentationRegistry } from "./presentations";
function entry(key: string): NativeViewEntry {
return {
key,
route: { fullPath: `/${key}` } as NativeViewEntry["route"],
status: "active",
mounted: true,
synthetic: false,
committed: true,
lastUsed: 0,
scrollX: 0,
scrollY: 0,
};
}
function transaction(
overrides: Partial<NativeTransaction> = {},
): NativeTransaction {
return {
id: 1,
kind: "push",
direction: "forward",
presentation: "push",
fromKey: "a",
toKey: "b",
progress: 1,
velocity: 0,
phase: "committing",
replace: false,
...overrides,
};
}
describe("runtime domains", () => {
it("keeps push, replace, and pop semantics inside the history ledger", () => {
const history = new NativeHistoryLedger();
const a = entry("a");
const b = entry("b");
const c = entry("c");
history.initialize(a.key);
history.accept(b, transaction());
expect(history.keys.value).toEqual(["a", "b"]);
expect(history.previousKey).toBe("a");
history.accept(c, transaction({ fromKey: "b", toKey: "c" }));
history.accept(a, transaction({ kind: "pop", fromKey: "c", toKey: "a" }));
expect(history.keys.value).toEqual(["a"]);
history.accept(b, transaction());
history.accept(a, transaction({ replace: true, fromKey: "b", toKey: "a" }));
expect(history.keys.value).toEqual(["a"]);
});
it("keeps built-in and custom presentations in a dedicated registry", () => {
const custom = { name: "flip", axis: "x" as const };
const presentations = new PresentationRegistry([custom]);
expect(presentations.get("sheet")).toMatchObject({ axis: "y" });
expect(presentations.get("flip")).toBe(custom);
});
});

View File

@@ -0,0 +1,51 @@
import { shallowRef } from "vue";
import type { NativeTransaction, NativeViewEntry } from "../types";
export class NativeHistoryLedger {
readonly keys = shallowRef<string[]>([]);
get length() {
return this.keys.value.length;
}
get previousKey() {
return this.keys.value.at(-2);
}
initialize(key: string) {
this.keys.value = [key];
}
contains(key: string) {
return this.keys.value.includes(key);
}
indexOf(key: string) {
return this.keys.value.lastIndexOf(key);
}
accept(target: NativeViewEntry, transaction: NativeTransaction | null) {
const history = this.keys.value;
if (!history.length) return this.initialize(target.key);
if (
transaction?.kind === "pop" ||
transaction?.kind === "dismiss" ||
transaction?.replace
) {
const targetIndex = history.lastIndexOf(target.key);
this.keys.value =
targetIndex >= 0
? history.slice(0, targetIndex + 1)
: [...history.slice(0, -1), target.key];
return;
}
const existingIndex = history.lastIndexOf(target.key);
this.keys.value = transaction
? [...history, target.key]
: existingIndex >= 0
? history.slice(0, existingIndex + 1)
: [...history, target.key];
}
}

View File

@@ -0,0 +1,38 @@
import type {
NativePresentationDefinition,
NativePresentationName,
} from "../types";
export function definePresentation(definition: NativePresentationDefinition) {
return definition;
}
const builtins: NativePresentationDefinition[] = [
{ name: "push", axis: "x" },
{ name: "reveal", axis: "x" },
{ name: "slide", axis: "x" },
{ name: "fade", axis: "x" },
{ name: "modal", axis: "y" },
{ name: "sheet", axis: "y" },
{ name: "none", axis: "x" },
];
export class PresentationRegistry {
private readonly definitions = new Map<
NativePresentationName,
NativePresentationDefinition
>();
constructor(custom: NativePresentationDefinition[] = []) {
for (const definition of [...builtins, ...custom])
this.register(definition);
}
register(definition: NativePresentationDefinition) {
this.definitions.set(definition.name, definition);
}
get(name: NativePresentationName) {
return this.definitions.get(name);
}
}

View File

@@ -0,0 +1,52 @@
import type {
RouteLocationNormalizedLoaded,
RouteLocationResolved,
} from "vue-router";
import type { NativeDirection, NativeViewEntry } from "../types";
let entrySequence = 0;
export function monotonicNow() {
return typeof performance === "undefined" ? Date.now() : performance.now();
}
export function diagnosticRoute(
route: RouteLocationNormalizedLoaded | RouteLocationResolved,
) {
return route.name != null
? String(route.name)
: (route.matched.at(-1)?.path ?? route.path);
}
export function createViewEntry(
route: RouteLocationNormalizedLoaded,
status: NativeViewEntry["status"],
synthetic = false,
): NativeViewEntry {
return {
key: `${route.fullPath}::${++entrySequence}`,
route,
presentation:
route.meta.native?.presentation ?? route.meta.native?.transition,
status,
mounted: true,
synthetic,
committed: status !== "preview",
lastUsed: monotonicNow(),
scrollX: 0,
scrollY: 0,
};
}
export function siblingDirection(
from: RouteLocationNormalizedLoaded,
to: RouteLocationNormalizedLoaded,
): NativeDirection {
const fromOrder = from.meta.native?.siblingOrder;
const toOrder = to.meta.native?.siblingOrder;
return typeof fromOrder === "number" &&
typeof toOrder === "number" &&
toOrder < fromOrder
? "back"
: "forward";
}

View File

@@ -0,0 +1,302 @@
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 },
});
}
}

View File

@@ -0,0 +1,117 @@
import type { App } from "vue";
import {
isNavigationFailure,
loadRouteLocation,
routeLocationKey,
START_LOCATION,
type NavigationFailure,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
type RouteLocationResolved,
type Router,
} from "vue-router";
import type { NativeTransaction, NativeViewEntry } from "../types";
import type { NativeHistoryLedger } from "./history-ledger";
export type NavigationResult = NavigationFailure | void | true;
export function isFailedNavigation(value: unknown) {
return Boolean(value && isNavigationFailure(value));
}
const nativeScopedRouteProperty = "__nativeVueRouterScopedRoute";
export class VueRouterBridge {
private removeAfterEach?: () => void;
private pendingPop?: (failure?: NavigationFailure | void) => void;
constructor(
readonly router: Router,
acceptRoute: (route: RouteLocationNormalizedLoaded) => void,
) {
this.removeAfterEach = router.afterEach((to, _from, failure) => {
if (!failure) acceptRoute(to);
this.pendingPop?.(failure);
this.pendingPop = undefined;
});
}
installRouteScope(app: App) {
// Vue Router's global `$route` always reads currentRoute. Preview trees
// provide their own routeLocationKey, so bridge that injection to the
// Options API too.
app.mixin({
inject: {
[nativeScopedRouteProperty]: { from: routeLocationKey },
},
computed: {
$route() {
return (
this as unknown as Record<
typeof nativeScopedRouteProperty,
RouteLocationNormalizedLoaded
>
)[nativeScopedRouteProperty];
},
},
});
}
async initialRoute() {
await this.router.isReady();
return this.router.currentRoute.value === START_LOCATION
? undefined
: this.router.currentRoute.value;
}
resolve(to: RouteLocationRaw) {
return this.router.resolve(to);
}
currentFullPath() {
return this.router.currentRoute.value.fullPath;
}
load(route: RouteLocationResolved) {
return loadRouteLocation(route);
}
async commit(
transaction: NativeTransaction,
target: NativeViewEntry,
history: NativeHistoryLedger,
): Promise<NavigationResult> {
if (transaction.kind === "pop" || transaction.kind === "dismiss") {
if (target.synthetic)
return await this.router.replace(target.route.fullPath);
return await this.navigateHistory(-1);
}
if (transaction.replace) {
const targetIndex = history.indexOf(target.key);
if (targetIndex >= 0 && targetIndex < history.length - 1)
return await this.navigateHistory(targetIndex - (history.length - 1));
}
return transaction.replace
? await this.router.replace(target.route.fullPath)
: await this.router.push(target.route.fullPath);
}
dispose() {
this.removeAfterEach?.();
}
private async navigateHistory(delta: number) {
return await new Promise<NavigationFailure | void>((resolve) => {
this.pendingPop = resolve;
this.router.go(delta);
globalThis.setTimeout(() => {
if (this.pendingPop === resolve) {
this.pendingPop = undefined;
resolve();
}
}, 1200);
});
}
}