Files
2026-07-22 11:37:46 +00:00

530 lines
16 KiB
TypeScript

import { computed, shallowRef, type App, type CSSProperties } from "vue";
import type { RouteLocationRaw, RouteLocationResolved } from "vue-router";
import {
animateProgress,
prepareMountedView,
shouldCommitGesture,
springTimeScaleForVelocity,
} from "./runtime/animation";
import { DiagnosticChannel } from "./runtime/diagnostics";
import { NativeHistoryLedger } from "./runtime/history-ledger";
import {
definePresentation,
PresentationRegistry,
} from "./runtime/presentations";
import {
diagnosticRoute,
monotonicNow,
siblingDirection,
} from "./runtime/route-entry";
import { ViewStore } from "./runtime/view-store";
import {
isFailedNavigation,
VueRouterBridge,
type NavigationResult,
} from "./runtime/vue-router-bridge";
import type {
NativeDiagnosticEvent,
NativeEvictionReason,
NativeGestureKind,
NativeNavigationOptions,
NativePlatformAdapter,
NativePresentationDefinition,
NativePresentationName,
NativeRouterOptions,
NativeRouterRuntime,
NativeTransaction,
NativeViewEntry,
} from "./types";
export {
definePresentation,
isFailedNavigation,
shouldCommitGesture,
springTimeScaleForVelocity,
};
export const nativeRouterKey = Symbol("native-vue-router");
class NativeRouterRuntimeImpl implements NativeRouterRuntime {
readonly router;
readonly entries;
readonly activeKey;
readonly transaction;
readonly canGoBack;
readonly cacheStats;
private readonly transactionState = shallowRef<NativeTransaction | null>(
null,
);
private readonly history: NativeHistoryLedger;
private readonly diagnostics: DiagnosticChannel;
private readonly views: ViewStore;
private readonly bridge: VueRouterBridge;
private readonly presentations: PresentationRegistry;
private readonly platform?: NativePlatformAdapter;
private transactionSequence = 0;
private beginAttemptSequence = 0;
private readonly pendingNavigations = new Map<
number,
Promise<NavigationResult>
>();
private platformCleanup?: () => void;
private memoryPressureCleanup?: () => void;
constructor(options: NativeRouterOptions) {
this.router = options.router;
this.platform = options.platform;
this.history = new NativeHistoryLedger();
this.diagnostics = new DiagnosticChannel();
this.views = new ViewStore(
Math.max(0, options.cache?.maxInactive ?? 4),
this.history,
this.diagnostics,
);
this.presentations = new PresentationRegistry(options.presentations);
this.bridge = new VueRouterBridge(this.router, (route) =>
this.views.acceptRoute(route, this.transactionState.value),
);
this.entries = computed(() => this.views.entries.value);
this.activeKey = computed(() => this.views.activeKey.value);
this.transaction = computed(() => this.transactionState.value);
this.cacheStats = this.views.cacheStats;
this.canGoBack = computed(() => {
const transaction = this.transactionState.value;
const committingForwardEntry =
transaction &&
transaction.phase !== "interactive" &&
transaction.kind !== "pop" &&
transaction.kind !== "dismiss" &&
!transaction.replace;
return (
Boolean(committingForwardEntry) ||
this.history.length > 1 ||
Boolean(this.views.active()?.route.meta.native?.parent)
);
});
}
install(app: App) {
app.provide(nativeRouterKey, this);
app.config.globalProperties.$nativeRouter = this;
this.bridge.installRouteScope(app);
void this.bridge.initialRoute().then((route) => {
if (route) this.views.initialize(route);
});
if (this.platform?.install) {
void Promise.resolve(this.platform.install(this)).then((cleanup) => {
if (cleanup) this.platformCleanup = cleanup;
});
}
if (typeof window !== "undefined") {
const trimForPressure = () =>
this.trimCache({ reason: "memory-pressure" });
window.addEventListener("memorypressure", trimForPressure);
this.memoryPressureCleanup = () =>
window.removeEventListener("memorypressure", trimForPressure);
}
}
async preload(to: RouteLocationRaw) {
return await this.loadResolvedRoute(this.bridge.resolve(to));
}
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
const id = await this.beginInteractive("push", to, options);
if (id === null) return false;
return await this.finishInteractive(true);
}
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
const id = await this.beginInteractive("push", to, {
...options,
replace: true,
});
if (id === null) return false;
return await this.finishInteractive(true);
}
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
const id = await this.beginInteractive("sibling", to, options);
if (id === null) return false;
return await this.finishInteractive(true);
}
async pop() {
const id = await this.beginInteractive("pop");
if (id === null) return false;
return await this.finishInteractive(true);
}
async present(
to: RouteLocationRaw,
presentation: NativePresentationName = "modal",
) {
const id = await this.beginInteractive("present", to, { presentation });
if (id === null) return false;
return await this.finishInteractive(true);
}
async dismiss() {
const id = await this.beginInteractive("dismiss");
if (id === null) return false;
return await this.finishInteractive(true);
}
async beginInteractive(
kind: NativeGestureKind,
to?: RouteLocationRaw,
options: NativeNavigationOptions = {},
) {
const attempt = ++this.beginAttemptSequence;
const live = this.transactionState.value;
if (live) {
if (live.phase === "interactive") return null;
await this.interruptSettling(live);
if (attempt !== this.beginAttemptSequence || this.transactionState.value)
return null;
}
const from = this.views.active();
if (!from) return null;
let target: NativeViewEntry;
let needsMount = false;
const isBack = kind === "pop" || kind === "dismiss";
if (isBack) {
const previousKey = this.history.previousKey;
const priorEntry = previousKey
? this.views.byKey(previousKey)
: undefined;
if (!priorEntry) {
const parent = from.route.meta.native?.parent;
if (!parent) return null;
const parentLocation =
typeof parent === "function" ? parent(from.route) : parent;
const route = await this.loadResolvedRoute(
this.bridge.resolve(parentLocation),
attempt,
);
if (this.isStaleAttempt(attempt)) return null;
target = this.views.appendPreview(route, true);
needsMount = true;
} else {
target = priorEntry;
if (!target.mounted) {
const route = await this.loadResolvedRoute(
this.bridge.resolve(target.route.fullPath),
attempt,
);
if (this.isStaleAttempt(attempt)) return null;
this.views.revive(target, route);
needsMount = true;
}
}
} else {
if (!to) return null;
const resolved = this.bridge.resolve(to);
if (resolved.fullPath === from.route.fullPath) return null;
const route = await this.loadResolvedRoute(resolved, attempt);
if (this.isStaleAttempt(attempt)) return null;
const replace =
options.replace ?? route.meta.native?.siblingHistory === "replace";
const prepared = this.views.prepareForward(route, replace);
target = prepared.entry;
needsMount = prepared.needsMount;
}
const presentationRoute = isBack ? from.route : target.route;
const presentation =
options.presentation ??
presentationRoute.meta.native?.presentation ??
presentationRoute.meta.native?.transition ??
(kind === "present" || kind === "dismiss"
? "modal"
: kind === "sibling"
? "slide"
: "push");
const direction =
options.direction ??
(kind === "pop" || kind === "dismiss"
? "back"
: kind === "present"
? "up"
: kind === "sibling"
? siblingDirection(from.route, target.route)
: "forward");
if (isBack) {
from.presentation ??= presentation;
} else {
target.presentation = presentation;
target.underlayKey = presentation === "sheet" ? from.key : undefined;
}
const transaction: NativeTransaction = {
id: ++this.transactionSequence,
kind,
direction,
presentation,
fromKey: from.key,
toKey: target.key,
progress: 0,
velocity: 0,
phase: "interactive",
replace:
options.replace ??
target.route.meta.native?.siblingHistory === "replace",
sourceRect: options.sourceRect,
};
if (this.isStaleAttempt(attempt)) return null;
this.transactionState.value = transaction;
this.diagnostics.emit("transaction-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: {
kind: transaction.kind,
direction: transaction.direction,
presentation: transaction.presentation,
cold: needsMount,
},
});
if (needsMount) {
const prepareStarted = monotonicNow();
this.diagnostics.emit("view-prepare-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
});
await prepareMountedView();
this.diagnostics.emit("view-prepare-end", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
duration: monotonicNow() - prepareStarted,
});
if (!this.isCurrent(transaction.id)) return null;
}
return transaction.id;
}
updateInteractive(progress: number, velocity = 0) {
const current = this.transactionState.value;
if (!current || current.phase !== "interactive") return;
this.transactionState.value = {
...current,
progress: Math.max(0, Math.min(1, progress)),
velocity,
};
}
async finishInteractive(forceCommit?: boolean) {
const current = this.transactionState.value;
if (!current) return false;
const commit =
forceCommit ?? shouldCommitGesture(current.progress, current.velocity);
if (!commit) {
await this.cancelInteractive();
return false;
}
this.transactionState.value = { ...current, phase: "committing" };
const target = this.views.byKey(current.toKey);
this.diagnostics.emit("commit-start", {
transactionId: current.id,
route: target ? diagnosticRoute(target.route) : undefined,
});
void this.platform?.haptic?.("commit");
const navigation = target
? this.bridge.commit(current, target, this.history)
: Promise.resolve(true as const);
this.pendingNavigations.set(current.id, navigation);
const result = navigation.then(
(failure) => ({ failed: Boolean(failure) }),
() => ({ failed: true }),
);
await animateProgress(this.transactionState, 1, current.velocity);
const { failed } = await result;
this.pendingNavigations.delete(current.id);
if (!this.isCurrent(current.id)) return !failed;
if (failed) {
await animateProgress(this.transactionState, 0, 0);
if (this.isCurrent(current.id)) this.finalizeCancelled(current, true);
return false;
}
this.finalizeCommitted(current);
return true;
}
async cancelInteractive() {
const current = this.transactionState.value;
if (!current || current.phase !== "interactive") return;
this.transactionState.value = { ...current, phase: "cancelled" };
await animateProgress(this.transactionState, 0, 0);
if (!this.isCurrent(current.id)) return;
void this.platform?.haptic?.("cancel");
this.finalizeCancelled(current);
}
unload(to: RouteLocationRaw) {
return this.views.unload(
this.bridge.resolve(to).fullPath,
this.transactionState.value,
);
}
trimCache(
options: { includePinned?: boolean; reason?: NativeEvictionReason } = {},
) {
this.views.trim(options);
}
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) {
return this.diagnostics.subscribe(listener);
}
registerPresentation(definition: NativePresentationDefinition) {
this.presentations.register(definition);
}
presentationFor(name: NativePresentationName) {
return this.presentations.get(name);
}
layerStyle(entry: NativeViewEntry): CSSProperties | undefined {
const transaction = this.transactionState.value;
if (!transaction) return undefined;
const role =
entry.key === transaction.fromKey
? "from"
: entry.key === transaction.toKey
? "to"
: undefined;
if (!role) return undefined;
return this.presentationFor(transaction.presentation)?.layerStyle?.({
progress: transaction.progress,
role,
direction: transaction.direction,
sourceRect: transaction.sourceRect,
});
}
dispose() {
this.bridge.dispose();
this.platformCleanup?.();
this.memoryPressureCleanup?.();
}
private isStaleAttempt(attempt: number) {
return (
attempt !== this.beginAttemptSequence ||
Boolean(this.transactionState.value)
);
}
private clearTransaction() {
this.transactionState.value = null;
}
private isCurrent(id: number) {
return this.transactionState.value?.id === id;
}
private async interruptSettling(transaction: NativeTransaction) {
if (!this.isCurrent(transaction.id) || transaction.phase === "interactive")
return;
const navigation = this.pendingNavigations.get(transaction.id);
if (!navigation) {
this.finalizeCancelled(transaction);
return;
}
const failed = await navigation.then(
(failure) => Boolean(failure),
() => true,
);
this.pendingNavigations.delete(transaction.id);
if (!this.isCurrent(transaction.id)) return;
this.transactionState.value = {
...this.transactionState.value!,
progress: failed ? 0 : 1,
velocity: 0,
phase: "settling",
};
if (failed) this.finalizeCancelled(transaction, true);
else this.finalizeCommitted(transaction);
}
private finalizeCancelled(
transaction: NativeTransaction,
evictTarget = false,
) {
if (!this.isCurrent(transaction.id)) return;
const target = this.views.byKey(transaction.toKey);
this.diagnostics.emit("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: evictTarget ? "rejected" : "cancelled" },
});
this.clearTransaction();
if (evictTarget)
this.views.discardTarget(transaction.toKey, "navigation-rejected");
else this.views.removePreview(transaction.toKey);
this.views.markStatuses(null);
this.views.enforceCache();
}
private finalizeCommitted(transaction: NativeTransaction) {
if (!this.isCurrent(transaction.id)) return;
const target = this.views.byKey(transaction.toKey);
if (target && this.bridge.currentFullPath() !== target.route.fullPath) {
this.diagnostics.emit("transaction-end", {
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: { outcome: "redirected" },
});
this.clearTransaction();
this.views.discardTarget(transaction.toKey, "navigation-rejected");
this.views.markStatuses(null);
this.views.enforceCache();
return;
}
if (target) this.views.commitTarget(target);
this.diagnostics.emit("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: "committed" },
});
this.clearTransaction();
this.views.markStatuses(null);
this.views.enforceCache();
}
private async loadResolvedRoute(
route: RouteLocationResolved,
attempt?: number,
) {
const started = monotonicNow();
const label = diagnosticRoute(route);
this.diagnostics.emit("route-load-start", { attempt, route: label });
try {
return await this.bridge.load(route);
} finally {
this.diagnostics.emit("route-load-end", {
attempt,
route: label,
duration: monotonicNow() - started,
});
}
}
}
export function createNativeRouter(
options: NativeRouterOptions,
): NativeRouterRuntime {
return new NativeRouterRuntimeImpl(options);
}