Separate out into files
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
async function captureTransitions(page: Page) {
|
async function captureTransitions(page: Page) {
|
||||||
|
await page.locator(".nvr-router-view").waitFor({ state: "attached" });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const state = window as typeof window & {
|
const state = window as typeof window & {
|
||||||
__nvrEvents?: Array<{
|
__nvrEvents?: Array<{
|
||||||
@@ -260,7 +261,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
|
|||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||||
timeout: 2_000,
|
timeout: 4_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lab = page.getByTestId("runtime-lab-view");
|
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).toHaveURL(/\/profile\/runtime-lab$/);
|
||||||
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||||
timeout: 2_000,
|
timeout: 4_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
|
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
|
||||||
"data-mount-id",
|
"data-mount-id",
|
||||||
|
|||||||
@@ -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.
|
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`.
|
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
|
## Route metadata
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
79
packages/core/src/runtime/animation.ts
Normal file
79
packages/core/src/runtime/animation.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
packages/core/src/runtime/diagnostics.ts
Normal file
23
packages/core/src/runtime/diagnostics.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
packages/core/src/runtime/domains.test.ts
Normal file
66
packages/core/src/runtime/domains.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/core/src/runtime/history-ledger.ts
Normal file
51
packages/core/src/runtime/history-ledger.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/core/src/runtime/presentations.ts
Normal file
38
packages/core/src/runtime/presentations.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
packages/core/src/runtime/route-entry.ts
Normal file
52
packages/core/src/runtime/route-entry.ts
Normal 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";
|
||||||
|
}
|
||||||
302
packages/core/src/runtime/view-store.ts
Normal file
302
packages/core/src/runtime/view-store.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
117
packages/core/src/runtime/vue-router-bridge.ts
Normal file
117
packages/core/src/runtime/vue-router-bridge.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user