Files
Native-Router-Vue/docs/architecture.md
2026-07-22 11:37:46 +00:00

8.7 KiB

Architecture

Ownership boundary

Vue Router owns matching, lazy components, committed routes, redirects, guards, URL serialization, and browser history. Native Vue Router owns a separate visual ledger containing mounted route entries, scroll/focus state, cache status, and the current interactive transaction.

A forward drag calls router.resolve() and Vue Router's public loadRouteLocation(), then renders the location through <RouterView :route> without calling push(). The URL and application history remain unchanged until the gesture commits.

Each preview subtree receives a scoped routeLocationKey, so useRoute() returns preview params even though the global route is not committed. A normal router.push() or replace() runs only after the gesture chooses to commit. A guard failure cancels the transaction and removes the preview.

Preview routes can contain ordinary Vue <Suspense> boundaries. A route may therefore become a live navigation surface immediately, show its fallback while async child setup continues, and preserve the resolved child when the route later moves into the mounted cache. Route guards remain commit-time authority and can still reject that cached destination on a later entry attempt.

Transaction lifecycle

Transactions move through interactive, committing, settling, and cancellation states. They expose normalized progress and velocity plus fromKey, toKey, direction, presentation, and optional source geometry.

The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.

Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. When a destination needs a new component tree, the runtime resolves its lazy route, mounts it, and gives the browser a preparation frame before motion starts; cached destinations skip that wait. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. Reduced-motion mode settles immediately.

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

interface NativeRouteOptions {
  navigator?: string;
  presentation?:
    "push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | string;
  parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
  siblingGroup?: string;
  siblingOrder?: number;
  siblingHistory?: "push" | "replace";
  cache?: boolean | "pin";
  gesture?: boolean | "edge" | "full";
}

parent supplies a predictive back target when a deep link starts without an in-memory predecessor. Sibling routes replace history by default and use the built-in slide presentation, which moves both pages one-to-one as adjacent surfaces. Direction comes from siblingOrder. Set siblingHistory: 'push' when browser back should visit prior sibling selections.

Custom presentations

nativeRouter.registerPresentation(
  definePresentation({
    name: "scale-fade",
    axis: "x",
    layerStyle({ role, progress }) {
      return role === "to"
        ? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
        : { opacity: 1 - progress * 0.4 };
    },
  }),
);

Applications can call beginInteractive(), updateInteractive(), and finishInteractive() to drive the same transaction engine from a bespoke recognizer.

Cache semantics

The cache is lazy: application startup mounts the current route, not every sibling. A replace-style sibling is created when it is first visited or previewed and can then remain mounted without becoming a browser-back entry. Recent history targets can also stay warm so predictive Back restores component-local state such as a scrolled list immediately.

The default limit is four inactive views per runtime. cache: false always unmounts an inactive route, while cache: 'pin' exempts it from ordinary LRU and default bulk trimming. A pushed detail route that is popped or dismissed is unmounted after its exit animation unless it is explicitly pinned. If a guard rejects a cached destination, that component tree is evicted because it is no longer a valid navigation target. Older entries keep lightweight route descriptors and are lazily reconstructed if history reaches them again.

Applications can explicitly release an inactive location with nativeRouter.unload('/stories'). It returns the number of component instances unmounted, retains history descriptors, and refuses to unload the active route or either side of an in-progress transition. trimCache() remains the bulk operation.

This is deliberately not implemented with a single Vue <KeepAlive>. An interactive transition must render the current and destination route instances concurrently, while one <KeepAlive> outlet normally activates one selected child. Separate temporary wrappers would themselves be removed and lose their caches. The runtime therefore owns the small multi-view cache and exposes equivalent route-aware lifecycle signals:

import {
  onNativeViewActivate,
  onNativeViewDeactivate,
  onNativeViewEvict,
  useNativeViewActiveEffect,
  useNativeViewLifecycle,
} from "@native-vue-router/core";

const view = useNativeViewLifecycle();

useNativeViewActiveEffect(() => {
  const timer = startPolling();
  return () => stopPolling(timer);
});

onNativeViewEvict((reason) => saveDraft(view.route.value, reason));

isActive means the route is authoritative. isVisible also includes either side of an in-progress transition and the visible, inert route beneath a partial sheet. Use useNativeViewActiveEffect for polling and other work that should pause in a cached tab, or useNativeViewVisibleEffect for work needed during the animation or while painted beneath a sheet. Application data that must survive eviction belongs in an application store.

Optional performance profiler

createNativeNavigationProfiler(runtime) correlates requestAnimationFrame intervals with timing-safe runtime events: route loading, cold view preparation, transaction start/commit/end, and eviction. It estimates the device's actual refresh interval instead of assuming 60 Hz, then flags frame gaps larger than 1.5 times that baseline. Reports also include Long Tasks, layout shifts, event timing, and resource timing when the host implements those Performance Observer entry types.

Safari does not currently expose every Chromium performance entry, so rAF cadence and native-router events are the portable ground truth. Visibility changes are retained because backgrounding or the share sheet can throttle rAF and would otherwise resemble dropped frames. Sampling is completely opt-in and capped at 30,000 frames by default. Route labels use record names or declared path patterns, never params or query values.