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

View File

@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
async function captureTransitions(page: Page) {
await page.locator(".nvr-router-view").waitFor({ state: "attached" });
await page.evaluate(() => {
const state = window as typeof window & {
__nvrEvents?: Array<{
@@ -260,7 +261,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
"page",
);
await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000,
timeout: 4_000,
});
const lab = page.getByTestId("runtime-lab-view");
@@ -278,7 +279,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000,
timeout: 4_000,
});
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
"data-mount-id",

View File

@@ -20,6 +20,28 @@ Pointer movement changes only a CSS progress variable. Built-in presentations re
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
### Runtime domains
`runtime.ts` is the public transaction coordinator. Its supporting domains live
under `packages/core/src/runtime`:
- `vue-router-bridge.ts` contains all Vue Router-specific integration: route
resolution and loading, commit operations, `afterEach` reconciliation,
browser-history completion, and the scoped Options API `$route` bridge.
- `history-ledger.ts` owns native push, replace, and pop history semantics.
- `view-store.ts` owns mounted route entries, active state, preview reuse,
eviction, underlay protection, and cache statistics.
- `animation.ts` owns gesture commit policy, view preparation, and spring
settling.
- `presentations.ts` owns built-in and application-defined presentations.
- `diagnostics.ts` owns runtime diagnostic subscriptions and event emission.
- `route-entry.ts` owns route-entry construction, labels, timestamps, and
sibling direction.
Keeping the Vue Router adapter separate makes the compatibility work visible
and prevents history, cache, rendering, and animation policy from accumulating
inside the integration layer.
Component gesture owners stop propagation before a containing navigator can claim the same pointer. Navigator gestures wait for horizontal intent, use pointer capture, respect form controls and `data-native-gesture="ignore"`, and preserve normal vertical scrolling through `touch-action: pan-y`.
## Route metadata

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