Separate out into files
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
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