From 5a514906ebb026f1bd766d75cbef41e48f34dc0a Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Wed, 22 Jul 2026 11:37:46 +0000 Subject: [PATCH] Separate out into files --- apps/demo/e2e/navigation.spec.ts | 5 +- docs/architecture.md | 22 + packages/core/src/runtime.ts | 808 ++++-------------- packages/core/src/runtime/animation.ts | 79 ++ packages/core/src/runtime/diagnostics.ts | 23 + packages/core/src/runtime/domains.test.ts | 66 ++ packages/core/src/runtime/history-ledger.ts | 51 ++ packages/core/src/runtime/presentations.ts | 38 + packages/core/src/runtime/route-entry.ts | 52 ++ packages/core/src/runtime/view-store.ts | 302 +++++++ .../core/src/runtime/vue-router-bridge.ts | 117 +++ 11 files changed, 906 insertions(+), 657 deletions(-) create mode 100644 packages/core/src/runtime/animation.ts create mode 100644 packages/core/src/runtime/diagnostics.ts create mode 100644 packages/core/src/runtime/domains.test.ts create mode 100644 packages/core/src/runtime/history-ledger.ts create mode 100644 packages/core/src/runtime/presentations.ts create mode 100644 packages/core/src/runtime/route-entry.ts create mode 100644 packages/core/src/runtime/view-store.ts create mode 100644 packages/core/src/runtime/vue-router-bridge.ts diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts index ddb6fa9..fd7a9d3 100644 --- a/apps/demo/e2e/navigation.spec.ts +++ b/apps/demo/e2e/navigation.spec.ts @@ -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", diff --git a/docs/architecture.md b/docs/architecture.md index c59ddfa..f88053b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index dc3e79b..36e0ebf 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,25 +1,30 @@ +import { computed, shallowRef, type App, type CSSProperties } from "vue"; +import type { RouteLocationRaw, RouteLocationResolved } from "vue-router"; import { - computed, - nextTick, - ref, - shallowRef, - type App, - type CSSProperties, -} from "vue"; + animateProgress, + prepareMountedView, + shouldCommitGesture, + springTimeScaleForVelocity, +} from "./runtime/animation"; +import { DiagnosticChannel } from "./runtime/diagnostics"; +import { NativeHistoryLedger } from "./runtime/history-ledger"; import { - isNavigationFailure, - loadRouteLocation, - routeLocationKey, - START_LOCATION, - type NavigationFailure, - type RouteLocationNormalizedLoaded, - type RouteLocationRaw, - type RouteLocationResolved, -} from "vue-router"; + 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 { - NativeDirection, NativeDiagnosticEvent, - NativeDiagnosticEventType, NativeEvictionReason, NativeGestureKind, NativeNavigationOptions, @@ -32,73 +37,14 @@ import type { NativeViewEntry, } from "./types"; +export { + definePresentation, + isFailedNavigation, + shouldCommitGesture, + springTimeScaleForVelocity, +}; + export const nativeRouterKey = Symbol("native-vue-router"); -const nativeScopedRouteProperty = "__nativeVueRouterScopedRoute"; - -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, - presentation: - route.meta.native?.presentation ?? route.meta.native?.transition, - 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; @@ -108,66 +54,45 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { readonly canGoBack; readonly cacheStats; - private readonly mutableEntries = shallowRef([]); - private readonly mutableActiveKey = ref(""); - private readonly mutableTransaction = shallowRef( + private readonly transactionState = shallowRef( null, ); - private readonly mutableHistoryKeys = shallowRef([]); - private readonly maxInactive: number; + 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 readonly presentations = new Map< - NativePresentationName, - NativePresentationDefinition - >(); private transactionSequence = 0; private beginAttemptSequence = 0; private readonly pendingNavigations = new Map< number, - Promise + Promise >(); - 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.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.mutableTransaction.value; + const transaction = this.transactionState.value; const committingForwardEntry = transaction && transaction.phase !== "interactive" && @@ -176,54 +101,18 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { !transaction.replace; return ( Boolean(committingForwardEntry) || - this.mutableHistoryKeys.value.length > 1 || - Boolean(this.activeEntry()?.route.meta.native?.parent) + this.history.length > 1 || + Boolean(this.views.active()?.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; - // Vue Router's global `$route` getter always reads currentRoute. Preview - // trees instead provide their target through routeLocationKey, so bridge - // that injection into the Options API as well as useRoute(). - app.mixin({ - inject: { - [nativeScopedRouteProperty]: { from: routeLocationKey }, - }, - computed: { - $route() { - return ( - this as unknown as Record< - typeof nativeScopedRouteProperty, - RouteLocationNormalizedLoaded - > - )[nativeScopedRouteProperty]; - }, - }, - }); - 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]; - } + 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) => { @@ -240,8 +129,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } async preload(to: RouteLocationRaw) { - const resolved = this.router.resolve(to); - return await this.loadResolvedRoute(resolved); + return await this.loadResolvedRoute(this.bridge.resolve(to)); } async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) { @@ -292,93 +180,61 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { options: NativeNavigationOptions = {}, ) { const attempt = ++this.beginAttemptSequence; - const live = this.mutableTransaction.value; + const live = this.transactionState.value; if (live) { if (live.phase === "interactive") return null; await this.interruptSettling(live); - if ( - attempt !== this.beginAttemptSequence || - this.mutableTransaction.value - ) + if (attempt !== this.beginAttemptSequence || this.transactionState.value) return null; } - const from = this.activeEntry(); + + const from = this.views.active(); if (!from) return null; - let target: NativeViewEntry | undefined; - let synthetic = false; + let target: NativeViewEntry; 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 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.router.resolve(parentLocation), + this.bridge.resolve(parentLocation), attempt, ); - if ( - attempt !== this.beginAttemptSequence || - this.mutableTransaction.value - ) - return null; - target = entryFor(route, "preview", true); - synthetic = true; + if (this.isStaleAttempt(attempt)) return null; + target = this.views.appendPreview(route, 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 { + 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.router.resolve(to); + const resolved = this.bridge.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; + if (this.isStaleAttempt(attempt)) 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 prepared = this.views.prepareForward(route, replace); + target = prepared.entry; + needsMount = prepared.needsMount; } const presentationRoute = isBack ? from.route : target.route; @@ -391,14 +247,14 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { : kind === "sibling" ? "slide" : "push"); - const direction: NativeDirection = + const direction = options.direction ?? (kind === "pop" || kind === "dismiss" ? "back" : kind === "present" ? "up" : kind === "sibling" - ? this.siblingDirection(from.route, target.route) + ? siblingDirection(from.route, target.route) : "forward"); if (isBack) { @@ -423,10 +279,9 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { 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", { + if (this.isStaleAttempt(attempt)) return null; + this.transactionState.value = transaction; + this.diagnostics.emit("transaction-start", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), @@ -437,20 +292,20 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { cold: needsMount, }, }); - if (synthetic) target.synthetic = true; + if (needsMount) { - const prepareStarted = now(); - this.emitDiagnostic("view-prepare-start", { + const prepareStarted = monotonicNow(); + this.diagnostics.emit("view-prepare-start", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), }); - await this.prepareMountedView(); - this.emitDiagnostic("view-prepare-end", { + await prepareMountedView(); + this.diagnostics.emit("view-prepare-end", { attempt, transactionId: transaction.id, route: diagnosticRoute(target.route), - duration: now() - prepareStarted, + duration: monotonicNow() - prepareStarted, }); if (!this.isCurrent(transaction.id)) return null; } @@ -458,9 +313,9 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } updateInteractive(progress: number, velocity = 0) { - const current = this.mutableTransaction.value; + const current = this.transactionState.value; if (!current || current.phase !== "interactive") return; - this.mutableTransaction.value = { + this.transactionState.value = { ...current, progress: Math.max(0, Math.min(1, progress)), velocity, @@ -468,7 +323,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } async finishInteractive(forceCommit?: boolean) { - const current = this.mutableTransaction.value; + const current = this.transactionState.value; if (!current) return false; const commit = forceCommit ?? shouldCommitGesture(current.progress, current.velocity); @@ -477,26 +332,27 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { return false; } - this.mutableTransaction.value = { ...current, phase: "committing" }; - this.emitDiagnostic("commit-start", { + this.transactionState.value = { ...current, phase: "committing" }; + const target = this.views.byKey(current.toKey); + this.diagnostics.emit("commit-start", { transactionId: current.id, - route: this.entryByKey(current.toKey) - ? diagnosticRoute(this.entryByKey(current.toKey)!.route) - : undefined, + route: target ? diagnosticRoute(target.route) : undefined, }); void this.platform?.haptic?.("commit"); - const navigation = this.commitRoute(current); + 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 this.animateProgress(1, current.velocity); + 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 this.animateProgress(0, 0); + await animateProgress(this.transactionState, 0, 0); if (this.isCurrent(current.id)) this.finalizeCancelled(current, true); return false; } @@ -505,59 +361,34 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } async cancelInteractive() { - const current = this.mutableTransaction.value; + const current = this.transactionState.value; if (!current || current.phase !== "interactive") return; - this.mutableTransaction.value = { ...current, phase: "cancelled" }; - await this.animateProgress(0, 0); + 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) { - 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; + return this.views.unload( + this.bridge.resolve(to).fullPath, + this.transactionState.value, + ); } 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(); + this.views.trim(options); } onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) { - this.diagnosticListeners.add(listener); - return () => this.diagnosticListeners.delete(listener); + return this.diagnostics.subscribe(listener); } registerPresentation(definition: NativePresentationDefinition) { - this.presentations.set(definition.name, definition); + this.presentations.register(definition); } presentationFor(name: NativePresentationName) { @@ -565,7 +396,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } layerStyle(entry: NativeViewEntry): CSSProperties | undefined { - const transaction = this.mutableTransaction.value; + const transaction = this.transactionState.value; if (!transaction) return undefined; const role = entry.key === transaction.fromKey @@ -583,67 +414,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } dispose() { - this.removeAfterEach?.(); + this.bridge.dispose(); 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 isStaleAttempt(attempt: number) { + return ( + attempt !== this.beginAttemptSequence || + Boolean(this.transactionState.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; + this.transactionState.value = null; } private isCurrent(id: number) { - return this.mutableTransaction.value?.id === id; + return this.transactionState.value?.id === id; } private async interruptSettling(transaction: NativeTransaction) { @@ -660,8 +448,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { ); this.pendingNavigations.delete(transaction.id); if (!this.isCurrent(transaction.id)) return; - this.mutableTransaction.value = { - ...this.mutableTransaction.value!, + this.transactionState.value = { + ...this.transactionState.value!, progress: failed ? 0 : 1, velocity: 0, phase: "settling", @@ -675,349 +463,63 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { evictTarget = false, ) { if (!this.isCurrent(transaction.id)) return; - const target = this.entryByKey(transaction.toKey); - this.emitDiagnostic("transaction-end", { + 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.discardTarget(transaction.toKey, "navigation-rejected"); - else this.removePreview(transaction.toKey); - this.markStatuses(); - this.enforceCache(); + 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.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", { + 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.discardTarget(transaction.toKey, "navigation-rejected"); - this.markStatuses(); - this.enforceCache(); + this.views.discardTarget(transaction.toKey, "navigation-rejected"); + this.views.markStatuses(null); + this.views.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", { + 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.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((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 previousActiveKey = this.mutableActiveKey.value; - 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.presentation ??= - to.meta.native?.presentation ?? to.meta.native?.transition; - target.mounted = true; - target.status = "active"; - target.committed = true; - target.lastUsed = now(); - target.evictionReason = undefined; - this.touchEntries(); - } - if (target.presentation === "sheet" && target.key !== previousActiveKey) - target.underlayKey = previousActiveKey || undefined; - 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 protectedUnderlayKeys = new Set(); - let presentedEntry = this.activeEntry(); - while ( - presentedEntry?.presentation === "sheet" && - presentedEntry.underlayKey && - !protectedUnderlayKeys.has(presentedEntry.underlayKey) - ) { - protectedUnderlayKeys.add(presentedEntry.underlayKey); - presentedEntry = this.entryByKey(presentedEntry.underlayKey); - } - 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 (protectedUnderlayKeys.has(entry.key)) continue; - 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" && - !protectedUnderlayKeys.has(entry.key), - ); - 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((resolve) => - window.requestAnimationFrame(() => resolve()), - ); + this.views.markStatuses(null); + this.views.enforceCache(); } private async loadResolvedRoute( route: RouteLocationResolved, attempt?: number, ) { - const started = now(); + const started = monotonicNow(); const label = diagnosticRoute(route); - this.emitDiagnostic("route-load-start", { attempt, route: label }); + this.diagnostics.emit("route-load-start", { attempt, route: label }); try { - return await loadRouteLocation(route); + return await this.bridge.load(route); } finally { - this.emitDiagnostic("route-load-end", { + this.diagnostics.emit("route-load-end", { attempt, route: label, - duration: now() - started, + duration: monotonicNow() - started, }); } } - - private emitDiagnostic( - type: NativeDiagnosticEventType, - event: Omit = {}, - ) { - 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((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( @@ -1025,7 +527,3 @@ export function createNativeRouter( ): NativeRouterRuntime { return new NativeRouterRuntimeImpl(options); } - -export function isFailedNavigation(value: unknown) { - return Boolean(value && isNavigationFailure(value)); -} diff --git a/packages/core/src/runtime/animation.ts b/packages/core/src/runtime/animation.ts new file mode 100644 index 0000000..09e1812 --- /dev/null +++ b/packages/core/src/runtime/animation.ts @@ -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((resolve) => + window.requestAnimationFrame(() => resolve()), + ); +} + +export function animateProgress( + transactionState: ShallowRef, + 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((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); + }); +} diff --git a/packages/core/src/runtime/diagnostics.ts b/packages/core/src/runtime/diagnostics.ts new file mode 100644 index 0000000..08b794d --- /dev/null +++ b/packages/core/src/runtime/diagnostics.ts @@ -0,0 +1,23 @@ +import type { + NativeDiagnosticEvent, + NativeDiagnosticEventType, +} from "../types"; +import { monotonicNow } from "./route-entry"; + +type DiagnosticListener = (event: NativeDiagnosticEvent) => void; +type DiagnosticPayload = Omit; + +export class DiagnosticChannel { + private readonly listeners = new Set(); + + 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); + } +} diff --git a/packages/core/src/runtime/domains.test.ts b/packages/core/src/runtime/domains.test.ts new file mode 100644 index 0000000..c4d98ab --- /dev/null +++ b/packages/core/src/runtime/domains.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/runtime/history-ledger.ts b/packages/core/src/runtime/history-ledger.ts new file mode 100644 index 0000000..148fa1b --- /dev/null +++ b/packages/core/src/runtime/history-ledger.ts @@ -0,0 +1,51 @@ +import { shallowRef } from "vue"; +import type { NativeTransaction, NativeViewEntry } from "../types"; + +export class NativeHistoryLedger { + readonly keys = shallowRef([]); + + 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]; + } +} diff --git a/packages/core/src/runtime/presentations.ts b/packages/core/src/runtime/presentations.ts new file mode 100644 index 0000000..288170d --- /dev/null +++ b/packages/core/src/runtime/presentations.ts @@ -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); + } +} diff --git a/packages/core/src/runtime/route-entry.ts b/packages/core/src/runtime/route-entry.ts new file mode 100644 index 0000000..0d241bc --- /dev/null +++ b/packages/core/src/runtime/route-entry.ts @@ -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"; +} diff --git a/packages/core/src/runtime/view-store.ts b/packages/core/src/runtime/view-store.ts new file mode 100644 index 0000000..548e099 --- /dev/null +++ b/packages/core/src/runtime/view-store.ts @@ -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([]); + 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(); + 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 }, + }); + } +} diff --git a/packages/core/src/runtime/vue-router-bridge.ts b/packages/core/src/runtime/vue-router-bridge.ts new file mode 100644 index 0000000..bf438a5 --- /dev/null +++ b/packages/core/src/runtime/vue-router-bridge.ts @@ -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 { + 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((resolve) => { + this.pendingPop = resolve; + this.router.go(delta); + globalThis.setTimeout(() => { + if (this.pendingPop === resolve) { + this.pendingPop = undefined; + resolve(); + } + }, 1200); + }); + } +}