Files
Native-Router-Vue/packages/core/src/runtime.ts
2026-07-22 02:12:05 +00:00

984 lines
31 KiB
TypeScript

import {
computed,
nextTick,
ref,
shallowRef,
type App,
type CSSProperties,
} from "vue";
import {
isNavigationFailure,
loadRouteLocation,
START_LOCATION,
type NavigationFailure,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
type RouteLocationResolved,
} from "vue-router";
import type {
NativeDirection,
NativeDiagnosticEvent,
NativeDiagnosticEventType,
NativeEvictionReason,
NativeGestureKind,
NativeNavigationOptions,
NativePlatformAdapter,
NativePresentationDefinition,
NativePresentationName,
NativeRouterOptions,
NativeRouterRuntime,
NativeTransaction,
NativeViewEntry,
} from "./types";
export const nativeRouterKey = Symbol("native-vue-router");
let entrySequence = 0;
function now() {
return typeof performance === "undefined" ? Date.now() : performance.now();
}
function diagnosticRoute(
route: RouteLocationNormalizedLoaded | RouteLocationResolved,
) {
return route.name != null
? String(route.name)
: (route.matched.at(-1)?.path ?? route.path);
}
function entryFor(
route: RouteLocationNormalizedLoaded,
status: NativeViewEntry["status"],
synthetic = false,
): NativeViewEntry {
return {
key: `${route.fullPath}::${++entrySequence}`,
route,
status,
mounted: true,
synthetic,
committed: status !== "preview",
lastUsed: now(),
scrollX: 0,
scrollY: 0,
};
}
export function shouldCommitGesture(
progress: number,
velocity: number,
threshold = 0.36,
) {
return progress >= threshold || (progress >= 0.08 && velocity >= 1.1);
}
/**
* Converts release velocity (normalized route progress per second) into the
* rate at which the spring is simulated. A deliberate flick can settle up to
* three times faster while a stationary release keeps the baseline spring.
*/
export function springTimeScaleForVelocity(velocity: number) {
return 1 + Math.min(2, Math.abs(velocity) * 0.3);
}
export function definePresentation(definition: NativePresentationDefinition) {
return definition;
}
const builtinPresentations: 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" },
];
class NativeRouterRuntimeImpl implements NativeRouterRuntime {
readonly router;
readonly entries;
readonly activeKey;
readonly transaction;
readonly canGoBack;
readonly cacheStats;
private readonly mutableEntries = shallowRef<NativeViewEntry[]>([]);
private readonly mutableActiveKey = ref("");
private readonly mutableTransaction = shallowRef<NativeTransaction | null>(
null,
);
private readonly mutableHistoryKeys = shallowRef<string[]>([]);
private readonly maxInactive: number;
private readonly platform?: NativePlatformAdapter;
private readonly presentations = new Map<
NativePresentationName,
NativePresentationDefinition
>();
private transactionSequence = 0;
private beginAttemptSequence = 0;
private readonly pendingNavigations = new Map<
number,
Promise<NavigationFailure | void | true>
>();
private removeAfterEach?: () => void;
private platformCleanup?: () => void;
private pendingPop?: (failure?: NavigationFailure | void) => void;
private totalEvictions = 0;
private lastEviction?: {
key: string;
route: string;
reason: NativeEvictionReason;
};
private memoryPressureCleanup?: () => void;
private readonly diagnosticListeners = new Set<
(event: NativeDiagnosticEvent) => void
>();
constructor(options: NativeRouterOptions) {
this.router = options.router;
this.maxInactive = Math.max(0, options.cache?.maxInactive ?? 4);
this.platform = options.platform;
this.entries = computed(() => this.mutableEntries.value);
this.activeKey = computed(() => this.mutableActiveKey.value);
this.transaction = computed(() => this.mutableTransaction.value);
this.cacheStats = computed(() => {
const entries = this.mutableEntries.value;
const mounted = entries.filter((entry) => entry.mounted);
const inactive = mounted.filter(
(entry) => entry.key !== this.mutableActiveKey.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,
};
});
this.canGoBack = computed(() => {
const transaction = this.mutableTransaction.value;
const committingForwardEntry =
transaction &&
transaction.phase !== "interactive" &&
transaction.kind !== "pop" &&
transaction.kind !== "dismiss" &&
!transaction.replace;
return (
Boolean(committingForwardEntry) ||
this.mutableHistoryKeys.value.length > 1 ||
Boolean(this.activeEntry()?.route.meta.native?.parent)
);
});
for (const definition of builtinPresentations)
this.registerPresentation(definition);
for (const definition of options.presentations ?? [])
this.registerPresentation(definition);
this.removeAfterEach = this.router.afterEach((to, from, failure) => {
if (!failure) this.acceptRoute(to, from);
this.pendingPop?.(failure);
this.pendingPop = undefined;
});
}
install(app: App) {
app.provide(nativeRouterKey, this);
app.config.globalProperties.$nativeRouter = this;
void this.router.isReady().then(() => {
if (
this.mutableEntries.value.length === 0 &&
this.router.currentRoute.value !== START_LOCATION
) {
const initial = entryFor(this.router.currentRoute.value, "active");
this.mutableEntries.value = [initial];
this.mutableActiveKey.value = initial.key;
this.mutableHistoryKeys.value = [initial.key];
}
});
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) {
const resolved = this.router.resolve(to);
return await this.loadResolvedRoute(resolved);
}
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.mutableTransaction.value;
if (live) {
if (live.phase === "interactive") return null;
await this.interruptSettling(live);
if (
attempt !== this.beginAttemptSequence ||
this.mutableTransaction.value
)
return null;
}
const from = this.activeEntry();
if (!from) return null;
let target: NativeViewEntry | undefined;
let synthetic = false;
let needsMount = false;
const isBack = kind === "pop" || kind === "dismiss";
if (isBack) {
const history = this.mutableHistoryKeys.value;
target =
history.length > 1
? this.entryByKey(history[history.length - 2])
: undefined;
if (!target) {
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.router.resolve(parentLocation),
attempt,
);
if (
attempt !== this.beginAttemptSequence ||
this.mutableTransaction.value
)
return null;
target = entryFor(route, "preview", true);
synthetic = true;
needsMount = true;
this.mutableEntries.value = [...this.mutableEntries.value, target];
} else if (!target.mounted) {
const route = await this.loadResolvedRoute(
this.router.resolve(target.route.fullPath),
attempt,
);
if (
attempt !== this.beginAttemptSequence ||
this.mutableTransaction.value
)
return null;
target.route = route;
target.mounted = true;
target.status = "inactive";
target.evictionReason = undefined;
needsMount = true;
this.touchEntries();
}
} else {
if (!to) return null;
const resolved = this.router.resolve(to);
if (resolved.fullPath === from.route.fullPath) return null;
const route = await this.loadResolvedRoute(resolved, attempt);
if (
attempt !== this.beginAttemptSequence ||
this.mutableTransaction.value
)
return null;
const replace =
options.replace ?? route.meta.native?.siblingHistory === "replace";
target = replace ? this.findHistoryEntry(route.fullPath) : undefined;
target ??= this.findReusable(route.fullPath);
if (target) {
needsMount = !target.mounted;
target.route = route;
target.mounted = true;
target.status = "preview";
target.synthetic = false;
target.lastUsed = now();
target.evictionReason = undefined;
this.touchEntries();
} else {
target = entryFor(route, "preview");
needsMount = true;
this.mutableEntries.value = [...this.mutableEntries.value, target];
}
}
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: NativeDirection =
options.direction ??
(kind === "pop" || kind === "dismiss"
? "back"
: kind === "present"
? "up"
: kind === "sibling"
? this.siblingDirection(from.route, target.route)
: "forward");
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 (attempt !== this.beginAttemptSequence || this.mutableTransaction.value)
return null;
this.mutableTransaction.value = transaction;
this.emitDiagnostic("transaction-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: {
kind: transaction.kind,
direction: transaction.direction,
presentation: transaction.presentation,
cold: needsMount,
},
});
if (synthetic) target.synthetic = true;
if (needsMount) {
const prepareStarted = now();
this.emitDiagnostic("view-prepare-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
});
await this.prepareMountedView();
this.emitDiagnostic("view-prepare-end", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
duration: now() - prepareStarted,
});
if (!this.isCurrent(transaction.id)) return null;
}
return transaction.id;
}
updateInteractive(progress: number, velocity = 0) {
const current = this.mutableTransaction.value;
if (!current || current.phase !== "interactive") return;
this.mutableTransaction.value = {
...current,
progress: Math.max(0, Math.min(1, progress)),
velocity,
};
}
async finishInteractive(forceCommit?: boolean) {
const current = this.mutableTransaction.value;
if (!current) return false;
const commit =
forceCommit ?? shouldCommitGesture(current.progress, current.velocity);
if (!commit) {
await this.cancelInteractive();
return false;
}
this.mutableTransaction.value = { ...current, phase: "committing" };
this.emitDiagnostic("commit-start", {
transactionId: current.id,
route: this.entryByKey(current.toKey)
? diagnosticRoute(this.entryByKey(current.toKey)!.route)
: undefined,
});
void this.platform?.haptic?.("commit");
const navigation = this.commitRoute(current);
this.pendingNavigations.set(current.id, navigation);
const result = navigation.then(
(failure) => ({ failed: Boolean(failure) }),
() => ({ failed: true }),
);
await this.animateProgress(1, current.velocity);
const { failed } = await result;
this.pendingNavigations.delete(current.id);
if (!this.isCurrent(current.id)) return !failed;
if (failed) {
await this.animateProgress(0, 0);
if (this.isCurrent(current.id)) this.finalizeCancelled(current, true);
return false;
}
this.finalizeCommitted(current);
return true;
}
async cancelInteractive() {
const current = this.mutableTransaction.value;
if (!current || current.phase !== "interactive") return;
this.mutableTransaction.value = { ...current, phase: "cancelled" };
await this.animateProgress(0, 0);
if (!this.isCurrent(current.id)) return;
void this.platform?.haptic?.("cancel");
this.finalizeCancelled(current);
}
unload(to: RouteLocationRaw) {
const fullPath = this.router.resolve(to).fullPath;
const transaction = this.mutableTransaction.value;
let unloaded = 0;
for (const entry of this.mutableEntries.value) {
if (entry.route.fullPath !== fullPath || !entry.mounted) continue;
if (entry.key === this.mutableActiveKey.value) continue;
if (
entry.key === transaction?.fromKey ||
entry.key === transaction?.toKey
)
continue;
this.evictEntry(entry, "manual");
unloaded += 1;
}
if (unloaded) this.touchEntries();
return unloaded;
}
trimCache(
options: { includePinned?: boolean; reason?: NativeEvictionReason } = {},
) {
const reason = options.reason ?? "trimmed";
for (const entry of this.mutableEntries.value) {
if (
entry.key === this.mutableActiveKey.value ||
!entry.mounted ||
entry.status !== "inactive"
)
continue;
if (!options.includePinned && entry.route.meta.native?.cache === "pin")
continue;
this.evictEntry(entry, reason);
}
this.touchEntries();
}
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) {
this.diagnosticListeners.add(listener);
return () => this.diagnosticListeners.delete(listener);
}
registerPresentation(definition: NativePresentationDefinition) {
this.presentations.set(definition.name, definition);
}
presentationFor(name: NativePresentationName) {
return this.presentations.get(name);
}
layerStyle(entry: NativeViewEntry): CSSProperties | undefined {
const transaction = this.mutableTransaction.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.removeAfterEach?.();
this.platformCleanup?.();
this.memoryPressureCleanup?.();
}
private activeEntry() {
return this.entryByKey(this.mutableActiveKey.value);
}
private activeIndex() {
return this.mutableEntries.value.findIndex(
(entry) => entry.key === this.mutableActiveKey.value,
);
}
private entryByKey(key: string) {
return this.mutableEntries.value.find((entry) => entry.key === key);
}
private findReusable(fullPath: string) {
return [...this.mutableEntries.value]
.reverse()
.find(
(entry) =>
entry.route.fullPath === fullPath &&
entry.key !== this.mutableActiveKey.value &&
!this.mutableHistoryKeys.value.includes(entry.key),
);
}
private findHistoryEntry(fullPath: string) {
for (const key of [...this.mutableHistoryKeys.value].reverse()) {
const entry = this.entryByKey(key);
if (entry?.route.fullPath === fullPath) return entry;
}
return undefined;
}
private 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";
}
private touchEntries() {
this.mutableEntries.value = [...this.mutableEntries.value];
}
private clearTransaction() {
this.mutableTransaction.value = null;
}
private isCurrent(id: number) {
return this.mutableTransaction.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.mutableTransaction.value = {
...this.mutableTransaction.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.entryByKey(transaction.toKey);
this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: evictTarget ? "rejected" : "cancelled" },
});
this.clearTransaction();
if (evictTarget)
this.discardTarget(transaction.toKey, "navigation-rejected");
else this.removePreview(transaction.toKey);
this.markStatuses();
this.enforceCache();
}
private finalizeCommitted(transaction: NativeTransaction) {
if (!this.isCurrent(transaction.id)) return;
const target = this.entryByKey(transaction.toKey);
if (
target &&
this.router.currentRoute.value.fullPath !== target.route.fullPath
) {
// A redirect is already authoritative; discard only the stale preview.
this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: { outcome: "redirected" },
});
this.clearTransaction();
this.discardTarget(transaction.toKey, "navigation-rejected");
this.markStatuses();
this.enforceCache();
return;
}
if (target) {
target.status = "active";
target.synthetic = false;
target.committed = true;
target.lastUsed = now();
this.mutableActiveKey.value = target.key;
}
this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: "committed" },
});
this.clearTransaction();
this.markStatuses();
this.enforceCache();
}
private async commitRoute(transaction: NativeTransaction) {
const target = this.entryByKey(transaction.toKey);
if (!target) return true;
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 history = this.mutableHistoryKeys.value;
const targetIndex = history.lastIndexOf(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);
}
private async navigateHistory(delta: number) {
return await new Promise<NavigationFailure | void>((resolve) => {
this.pendingPop = resolve;
this.router.go(delta);
window.setTimeout(() => {
if (this.pendingPop === resolve) {
this.pendingPop = undefined;
resolve();
}
}, 1200);
});
}
private acceptRoute(
to: RouteLocationNormalizedLoaded,
_from: RouteLocationNormalizedLoaded,
) {
const transaction = this.mutableTransaction.value;
let target = transaction ? this.entryByKey(transaction.toKey) : undefined;
if (target && target.route.fullPath !== to.fullPath) target = undefined;
if (!transaction) target ??= this.findHistoryEntry(to.fullPath);
target ??= this.findReusable(to.fullPath);
if (!target && this.activeEntry()?.route.fullPath === to.fullPath)
target = this.activeEntry();
if (!target) {
target = entryFor(to, "active");
const activeIndex = this.activeIndex();
const head =
activeIndex >= 0
? this.mutableEntries.value.slice(0, activeIndex + 1)
: this.mutableEntries.value;
this.mutableEntries.value = [...head, target];
} else {
target.route = to;
target.mounted = true;
target.status = "active";
target.committed = true;
target.lastUsed = now();
target.evictionReason = undefined;
this.touchEntries();
}
this.mutableActiveKey.value = target.key;
this.acceptHistory(target, transaction);
this.markStatuses();
if (!transaction) this.enforceCache();
}
private acceptHistory(
target: NativeViewEntry,
transaction: NativeTransaction | null,
) {
const history = this.mutableHistoryKeys.value;
if (!history.length) {
this.mutableHistoryKeys.value = [target.key];
return;
}
if (transaction?.kind === "pop" || transaction?.kind === "dismiss") {
const targetIndex = history.lastIndexOf(target.key);
this.mutableHistoryKeys.value =
targetIndex >= 0
? history.slice(0, targetIndex + 1)
: [...history.slice(0, -1), target.key];
return;
}
if (transaction?.replace) {
const targetIndex = history.lastIndexOf(target.key);
this.mutableHistoryKeys.value =
targetIndex >= 0
? history.slice(0, targetIndex + 1)
: [...history.slice(0, -1), target.key];
return;
}
const existingIndex = history.lastIndexOf(target.key);
this.mutableHistoryKeys.value = transaction
? [...history, target.key]
: existingIndex >= 0
? history.slice(0, existingIndex + 1)
: [...history, target.key];
}
private markStatuses() {
const transaction = this.mutableTransaction.value;
for (const entry of this.mutableEntries.value) {
if (entry.key === this.mutableActiveKey.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.touchEntries();
}
private removePreview(key: string) {
const entry = this.entryByKey(key);
if (!entry || entry.key === this.mutableActiveKey.value) return;
if (entry.status === "preview" || entry.synthetic) {
if (entry.synthetic || !entry.committed) {
this.mutableEntries.value = this.mutableEntries.value.filter(
(candidate) => candidate.key !== key,
);
} else {
entry.status = "inactive";
this.touchEntries();
}
}
}
private discardTarget(key: string, reason: NativeEvictionReason) {
const entry = this.entryByKey(key);
if (!entry || entry.key === this.mutableActiveKey.value) return;
if (!entry.committed || entry.synthetic) {
this.mutableEntries.value = this.mutableEntries.value.filter(
(candidate) => candidate.key !== key,
);
return;
}
this.evictEntry(entry, reason);
}
private evictEntry(entry: NativeViewEntry, reason: NativeEvictionReason) {
if (!entry.mounted || entry.key === this.mutableActiveKey.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.emitDiagnostic("view-evicted", {
route: diagnosticRoute(entry.route),
details: { reason },
});
}
private shouldRetainInactive(entry: NativeViewEntry) {
const policy = entry.route.meta.native?.cache;
if (policy === false) return false;
if (policy === "pin") return true;
if (this.mutableHistoryKeys.value.includes(entry.key)) return true;
return entry.route.meta.native?.siblingHistory === "replace";
}
private enforceCache() {
const inactive = this.mutableEntries.value
.filter(
(entry) =>
entry.key !== this.mutableActiveKey.value &&
entry.mounted &&
entry.status === "inactive",
)
.sort((a, b) => b.lastUsed - a.lastUsed);
for (const entry of inactive) {
if (!this.shouldRetainInactive(entry)) {
const reason: NativeEvictionReason =
entry.route.meta.native?.cache === false
? "cache-disabled"
: "popped";
this.evictEntry(entry, reason);
}
}
const retained = inactive.filter(
(entry) => entry.mounted && entry.route.meta.native?.cache !== "pin",
);
for (const entry of retained.slice(this.maxInactive)) {
this.evictEntry(entry, "cache-limit");
}
this.touchEntries();
}
private async 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()),
);
}
private async loadResolvedRoute(
route: RouteLocationResolved,
attempt?: number,
) {
const started = now();
const label = diagnosticRoute(route);
this.emitDiagnostic("route-load-start", { attempt, route: label });
try {
return await loadRouteLocation(route);
} finally {
this.emitDiagnostic("route-load-end", {
attempt,
route: label,
duration: now() - started,
});
}
}
private emitDiagnostic(
type: NativeDiagnosticEventType,
event: Omit<NativeDiagnosticEvent, "type" | "timestamp"> = {},
) {
if (!this.diagnosticListeners.size) return;
const diagnostic = { type, timestamp: now(), ...event };
for (const listener of this.diagnosticListeners) listener(diagnostic);
}
private animateProgress(target: number, initialVelocity: number) {
const transaction = this.mutableTransaction.value;
if (!transaction) return Promise.resolve();
if (
typeof window === "undefined" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
this.mutableTransaction.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 = now();
const step = (time: number) => {
const live = this.mutableTransaction.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;
// Substeps keep the spring stable when a high-velocity flick advances
// several frames of simulated time in one display frame.
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;
this.mutableTransaction.value = {
...live,
progress: done ? target : Math.max(0, Math.min(1, position)),
velocity,
phase: "settling",
};
if (done) resolve();
else requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
}
}
export function createNativeRouter(
options: NativeRouterOptions,
): NativeRouterRuntime {
return new NativeRouterRuntimeImpl(options);
}
export function isFailedNavigation(value: unknown) {
return Boolean(value && isNavigationFailure(value));
}