530 lines
16 KiB
TypeScript
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);
|
|
}
|