Files
Native-Router-Vue/usage.md

810 lines
28 KiB
Markdown

# Native Vue Router usage guide
Native Vue Router adds gesture-driven, interruptible navigation to Vue 3 while
leaving Vue Router responsible for route matching, URLs, redirects, guards, and
browser history. It renders a provisional destination beside the current route,
lets a pointer gesture control the transition, and commits the Vue Router
navigation only when the gesture completes.
The current support contract is Vue 3.5+, Vue Router 5, and client-side DOM
rendering. SSR hydration of a live native view stack is not yet a complete
feature.
## Packages
| Package | Purpose |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `@native-vue-router/core` | Runtime, route surfaces, gestures, caching, lifecycle APIs, and profiler |
| `@native-vue-router/preset-native` | Native-looking back and tab controls, safe-area CSS, and motion tokens |
| `@native-vue-router/capacitor` | Hardware Back, deep links, app lifecycle, root exit, and haptics |
| `@native-vue-router/electron` | Host back/forward integration and Chromium history-gesture suppression |
## Install
Install the core package alongside its peer dependencies:
```bash
npm install vue@^3.5 vue-router@^5 @native-vue-router/core
```
Add optional packages only when the application uses them:
```bash
npm install @native-vue-router/preset-native
npm install @native-vue-router/capacitor @capacitor/app @capacitor/core @capacitor/haptics
npm install @native-vue-router/electron
```
When consuming this repository directly, build the packages first and install
the required package directories or packed tarballs into the target project:
```bash
npm install
npm run build:packages
npm pack --workspace @native-vue-router/core
```
Import the core stylesheet once from the application entry point. Import the
preset stylesheet as well when using its controls:
```ts
import "@native-vue-router/core/style.css";
import "@native-vue-router/preset-native/style.css"; // optional
```
The elements containing the navigator must have a definite height. A typical
full-screen application uses:
```css
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
```
## Minimal setup
Create the Vue Router first, then create and install the native runtime. Install
Vue Router before Native Vue Router so route injection and Options API `$route`
scoping are configured correctly.
```ts
// src/main.ts
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createNativeRouter } from "@native-vue-router/core";
import App from "./App.vue";
import HomeView from "./views/HomeView.vue";
import ProductView from "./views/ProductView.vue";
import "@native-vue-router/core/style.css";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", name: "home", component: HomeView },
{
path: "/products/:id",
name: "product",
component: ProductView,
meta: {
native: {
presentation: "push",
parent: "/",
gesture: "edge",
},
},
},
],
});
export const nativeRouter = createNativeRouter({
router,
cache: { maxInactive: 4 },
});
const app = createApp(App);
app.use(router);
app.use(nativeRouter);
await router.isReady();
app.mount("#app");
```
Replace the application-level `<RouterView>` with `<NativeRouterView>` and wrap
the navigation surface in `<NativeNavigator>` to enable predictive Back.
```vue
<!-- src/App.vue -->
<script setup lang="ts">
import { NativeNavigator, NativeRouterView } from "@native-vue-router/core";
</script>
<template>
<NativeNavigator>
<NativeRouterView />
</NativeNavigator>
</template>
```
Nested `<RouterView>` components inside route components continue to work. Use
one application-level `NativeRouterView` for one native runtime; independent
nested native navigators are not currently a complete feature.
## Route metadata
Declare presentation, topology, history, cache, and gesture policy next to each
route:
```ts
import type { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
{
path: "/inbox",
name: "inbox",
component: () => import("./views/InboxView.vue"),
meta: {
native: {
siblingGroup: "primary",
siblingOrder: 0,
siblingHistory: "replace",
cache: "pin",
gesture: "full",
},
},
},
{
path: "/chat/:id",
name: "chat",
component: () => import("./views/ChatView.vue"),
meta: {
native: {
presentation: "push",
parent: "/inbox",
gesture: "edge",
},
},
},
{
path: "/chat/:id/details",
name: "chat-details",
component: () => import("./views/ChatDetailsView.vue"),
meta: {
native: {
parent: (route) => ({
name: "chat",
params: { id: route.params.id },
query: route.query,
}),
},
},
},
];
```
| Option | Meaning |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `presentation` | `push`, `reveal`, `slide`, `fade`, `modal`, `sheet`, `none`, or a registered custom name |
| `transition` | Compatibility alias for `presentation`; prefer `presentation` in new code |
| `parent` | Logical Back target for a cold-start/deep-linked route; may be a location or a function of the current route |
| `siblingOrder` | Numeric visual order used to derive sibling direction |
| `siblingHistory` | `replace` keeps peer selections out of Back history; `push` makes them Back destinations |
| `cache` | `false` unmounts when inactive, `true` uses normal retention, and `pin` exempts the view from ordinary LRU trimming |
| `gesture` | `false` disables navigator gestures for the route; `edge` and `full` describe intended policy |
| `navigator`, `siblingGroup` | Reserved topology labels; the current navigator still receives its sibling list explicitly |
Declare `parent` for detail, settings, modal, and other routes that should have a
predictive destination when opened directly. It is a logical product
relationship, not proof that a matching browser history entry exists.
## Navigate
Use `useNativeRouter()` inside `setup()`:
```vue
<script setup lang="ts">
import { useNativeRouter } from "@native-vue-router/core";
const native = useNativeRouter();
async function openProduct(id: string) {
const committed = await native.push({
name: "product",
params: { id },
query: { source: "featured" },
hash: "#summary",
});
if (committed) {
// Vue Router accepted the navigation.
}
}
</script>
```
The same runtime is available as `this.$nativeRouter` in Options API
components.
| Method | Use |
| ---------------------------- | --------------------------------------------------------------------- |
| `push(to, options?)` | Add an animated history entry |
| `replace(to, options?)` | Replace the current history entry |
| `sibling(to, options?)` | Move between ordered peer routes |
| `pop()` | Navigate to the previous native history entry or declared parent |
| `present(to, presentation?)` | Present a route, using `modal` by default |
| `dismiss()` | Return from a presented route |
| `preload(to)` | Resolve and load a lazy route without mounting or committing it |
| `unload(to)` | Unmount inactive instances of one location and return the count |
| `trimCache(options?)` | Unmount inactive cached views while retaining lightweight descriptors |
Navigation options can override `presentation`, `replace`, `direction`, and
`sourceRect`. The navigation methods return `true` when Vue Router accepts the
commit and `false` for a no-op, cancellation, or rejected navigation.
Use normal `router.push()` for redirects or flows that intentionally do not need
a native preview. The runtime reconciles external Vue Router navigations, but
they do not receive the same preview-driven transition.
## Links and gesture components
### NativeLink
`NativeLink` renders a real anchor, resolves its `href`, preserves modified-click
behavior, and routes an ordinary primary click through the native runtime.
```vue
<script setup lang="ts">
import { NativeLink } from "@native-vue-router/core";
</script>
<template>
<NativeLink
:to="{ name: 'product', params: { id: '42' } }"
presentation="push"
class="product-link"
>
Product 42
</NativeLink>
</template>
```
Its navigation props are `to`, `replace`, and `presentation`; other attributes
are passed to the anchor.
### NativeGestureLink
`NativeGestureLink` lets a horizontal drag on a component reveal its destination.
It accepts `to`, `presentation` (default `reveal`), `replace`, `direction`
(`left`, `right`, or `any`), and `as` (default `div`).
```vue
<NativeGestureLink
as="article"
:to="{ name: 'product', params: { id: product.id } }"
presentation="reveal"
direction="left"
>
<ProductCard :product="product" />
</NativeGestureLink>
```
Choose a semantic `as` element and provide keyboard behavior when the result is
interactive. A normal click also invokes native `push()`.
### NativeNavigator
Pass ordered peer locations to enable full-surface horizontal sibling paging.
The leading edge remains reserved for Back when `canGoBack` is true.
```vue
<NativeNavigator
:siblings="['/inbox', '/stories', '/profile']"
:edge-width="28"
>
<NativeRouterView />
</NativeNavigator>
```
Inputs, editable content, links, buttons, and elements carrying
`data-native-gesture="ignore"` are excluded from automatic gesture recognition.
Use the explicit attribute for carousels, maps, editors, canvases, or other
regions that own horizontal input.
### NativeDismissGesture
Wrap a custom full-height modal surface to make a downward drag dismiss it:
```vue
<script setup lang="ts">
import { NativeDismissGesture, useNativeRouter } from "@native-vue-router/core";
const native = useNativeRouter();
</script>
<template>
<NativeDismissGesture as="main" class="sheet">
<button type="button" @click="native.dismiss()">Close</button>
<!-- sheet content -->
</NativeDismissGesture>
</template>
```
### NativeSheet
Use `NativeSheet` inside a route whose presentation is `sheet`. It keeps the
surface below the device's safe top inset, leaves the previous route visible but
inert beneath a backdrop, and includes a drag handle.
With no breakpoints, the surface grows to its content and is capped at the
available device height:
```vue
<script setup lang="ts">
import { NativeSheet } from "@native-vue-router/core";
</script>
<template>
<NativeSheet aria-label="Filters">
<FilterForm />
</NativeSheet>
</template>
```
Supply fractional breakpoints to create snap points. Fractions are measured
against the route height after the safe top inset has been reserved:
```vue
<script setup lang="ts">
import { ref } from "vue";
import { NativeSheet } from "@native-vue-router/core";
const breakpoint = ref(0.55);
</script>
<template>
<NativeSheet
v-model="breakpoint"
:breakpoints="[0.3, 0.55, 1]"
:initial-breakpoint="0.55"
aria-label="Choose a location"
@breakpoint-change="savePreferredSheetSize"
>
<LocationPicker />
</NativeSheet>
</template>
```
Users drag the handle or use Arrow Up/Down, Home, and End while it is focused.
Dragging below the smallest point dismisses the route. The backdrop and Escape
also dismiss by default.
The scrollable sheet body participates in the same gesture automatically. At an
interior scroll position, the content scrolls normally. When the content is at
the top, pulling down hands the gesture to the sheet so it can move to a lower
point or dismiss. When the content is at the bottom, pushing upward grows the
sheet to its next point. Mouse/pen dragging, touch input, and thresholded
trackpad/wheel overscroll follow the same boundary rules.
Gesture ownership is chosen from the initial directional intent and remains
locked until release. A gesture that starts while content exists in that
direction stays a content gesture even if it reaches an edge or reverses. Lift
and begin a new gesture at the edge to resize the sheet. This prevents content
and sheet movement from overlapping and prevents previously scrolled distance
from becoming a sheet-height jump.
| Prop | Meaning |
| ------------------- | --------------------------------------------------------------------------------------- |
| `breakpoints` | Unique fractions greater than `0` and at most `1`; an empty list enables content height |
| `initialBreakpoint` | Initial fraction, snapped to the nearest declared point |
| `modelValue` | Current fractional point for `v-model` |
| `dismissible` | Enables sheet-triggered drag and keyboard dismissal; default `true` |
| `backdropDismiss` | Lets a backdrop click dismiss; default `true` |
| `showHandle` | Renders the built-in drag/keyboard handle; default `true` |
| `ariaLabel` | Accessible dialog label; default `Sheet` |
Use the `handle` slot to replace the visual handle without replacing its input
behavior. Theme the surface with `--nvr-sheet-background`,
`--nvr-sheet-backdrop`, `--nvr-sheet-radius`, and `--nvr-sheet-top-gap`.
For a route opened with `native.present(to, "sheet")`, the runtime remembers the
sheet presentation after commit. Defining `meta.native.presentation: "sheet"`
as well makes direct URL entry and raw Vue Router navigation use the same
contained layout.
During presentation and dismissal, `NativeSheet` moves its actual surface by
that surface's height rather than translating a transparent viewport-sized
route wrapper. The source route remains continuously scaled as the sheet's
underlay, avoiding a geometry jump when the route transaction commits.
## Tabs and back controls
The optional native preset supplies a Back button and tab bar:
```vue
<script setup lang="ts">
import {
NativeBackButton,
NativeTabBar,
type NativeTabItem,
} from "@native-vue-router/preset-native";
const tabs: NativeTabItem[] = [
{ label: "Inbox", to: "/inbox", icon: "◉" },
{ label: "Stories", to: "/stories", icon: "◎" },
{
label: "Profile",
to: "/profile",
icon: "◇",
activeWhen: (route) => route.path.startsWith("/profile"),
},
];
</script>
<template>
<header><NativeBackButton label="Back" /></header>
<NativeTabBar :items="tabs" />
</template>
```
The preset tab bar uses replace-style sibling navigation. Build a product-specific
control with `native.sibling()` when tabs need different history semantics.
## Route params, query, hash, and injected route state
Pass any normal `RouteLocationRaw` to native navigation methods and components.
`useRoute()`, `useRouter()`, and Options API `this.$route` work within active and
preview route trees. During a held gesture, the destination subtree sees its own
params, query, hash, matched records, and metadata even though
`router.currentRoute` still points at the committed source route.
This distinction is intentional:
- Read `useRoute()` or `$route` inside a route component for that surface's
scoped route.
- Read `router.currentRoute` only when the application needs the globally
committed route.
- Expect the two values to differ while a preview is visible.
Normal Vue `provide()` and `inject()` work across the route surface. App-level
provides, plugin provides, and values provided by route components remain
available to descendants.
## Lifecycle and cached views
Native Vue Router can keep inactive route component trees mounted. Vue's normal
mount, update, and unmount hooks continue to describe component lifetime, but
being mounted does not mean the route is the current screen.
Use the native lifecycle for route visibility and activity:
```vue
<script setup lang="ts">
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
useNativeViewActiveEffect,
useNativeViewLifecycle,
useNativeViewVisibleEffect,
} from "@native-vue-router/core";
const view = useNativeViewLifecycle();
useNativeViewActiveEffect(() => {
const controller = new AbortController();
startPolling({ signal: controller.signal });
return () => controller.abort();
});
useNativeViewVisibleEffect(() => {
const stop = startAnimationNeededDuringTransitions();
return stop;
});
onNativeViewActivate(() => resumeMedia());
onNativeViewDeactivate(() => pauseMedia());
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
</script>
```
`isActive` means Vue Router has made the route authoritative. `isVisible` is
also true for either side of an interactive transition and for a sheet's visual
underlay. An underlay remains inert and is not active. `isCached` identifies a
mounted inactive view; `isPreview` identifies an uncommitted destination.
Use active effects for polling, subscriptions, media, analytics, and work that
should run only on the current route. Use visible effects for rendering work
needed while the route is on screen during a transition. Put durable state in a
store or persistence layer because cache eviction unmounts component-local
state.
The default cache limit is four inactive, non-pinned views. History descriptors
remain after a component tree is evicted and are remounted if navigation reaches
them later.
## Vue built-in components
`NativeRouterView` exposes `Component`, `route`, and `entry` through its default
slot, so normal Vue wrappers can be used inside each native route layer:
```vue
<NativeRouterView v-slot="{ Component, route }">
<Suspense>
<Transition name="route-content" mode="out-in">
<KeepAlive :max="3">
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</Transition>
<template #fallback>
<RouteSkeleton />
</template>
</Suspense>
</NativeRouterView>
```
These components retain their normal Vue meaning:
- `<KeepAlive>` caches components selected within that route layer. It is not a
replacement for the native multi-route cache, and native route changes alone
do not imply Vue `onActivated()` or `onDeactivated()`.
- `<Transition>` animates changes inside a layer. Native presentations animate
the route layers themselves.
- `<Suspense>` may show a fallback while an async preview component resolves.
- `<Teleport>` can move DOM outside the layer. Because teleported DOM is outside
the layer's `inert` and `aria-hidden` boundary, close or hide overlays whenever
the owning native view is not visible.
A visibility-safe teleported overlay looks like this:
```vue
<script setup lang="ts">
import { ref } from "vue";
import { useNativeViewLifecycle } from "@native-vue-router/core";
const open = ref(false);
const view = useNativeViewLifecycle();
</script>
<template>
<button type="button" @click="open = true">Open overlay</button>
<Teleport to="body">
<MyOverlay v-if="open && view.isVisible.value" @close="open = false" />
</Teleport>
</template>
```
Options API lifecycle hooks (`beforeCreate`, `created`, `beforeMount`,
`mounted`, `beforeUpdate`, `updated`, `beforeUnmount`, and `unmounted`) and the
corresponding Composition API hooks keep their standard Vue behavior.
## Guards, redirects, and preview side effects
Forward navigation resolves and loads the route component before commit so the
user can drag a live destination. Vue Router guards run when the runtime commits
the real `push()`, `replace()`, or Back operation. A rejected guard removes the
preview and restores the source route; a redirect is reconciled to the route Vue
Router accepts.
Consequently, a preview component may execute `setup()` and mount before a guard
allows entry, then unmount without ever becoming active. Avoid irreversible work
such as analytics events, mutations, purchases, or permanent subscriptions in
unconditional setup/mount code. Tie committed-screen behavior to
`useNativeViewActiveEffect()` or an explicit committed application action.
## Custom presentations
Register a presentation at runtime or pass it in `createNativeRouter()`:
```ts
import {
createNativeRouter,
definePresentation,
} from "@native-vue-router/core";
const scaleFade = definePresentation({
name: "scale-fade",
axis: "x",
layerStyle({ role, progress }) {
return role === "to"
? {
opacity: progress,
transform: `scale(${0.94 + progress * 0.06})`,
}
: { opacity: 1 - progress * 0.25 };
},
});
const nativeRouter = createNativeRouter({
router,
presentations: [scaleFade],
});
// This is also valid later:
nativeRouter.registerPresentation(scaleFade);
```
Reference the registered name from route metadata or a navigation option.
Presentation functions should derive compositor-friendly styles from progress;
they must not mutate history or application state.
Advanced interactions can call `beginInteractive()`, `updateInteractive()`,
`finishInteractive()`, and `cancelInteractive()` directly. Progress is normalized
from `0` to `1`, and velocity is normalized route progress per second. Cancel the
transaction when the owning component unmounts and ignore stale async results by
checking the returned transaction ID.
## Platform adapters
### Capacitor
```ts
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
const nativeRouter = createNativeRouter({
router,
platform: createCapacitorAdapter({
haptics: true,
exitAtRoot: true,
trimCacheOnPause: true,
deepLinkPath: (url) => `${url.pathname}${url.search}${url.hash}`,
}),
});
```
The adapter handles hardware Back, launch/app URLs, pause cancellation, cache
trimming, optional haptics, and optional exit at the root. Configure Universal
Links/App Links in the native project separately.
### Electron
Disable Chromium's competing overscroll navigation in the main process before
`app.whenReady()`:
```ts
import { app } from "electron";
import { disableElectronHistoryGestures } from "@native-vue-router/electron";
disableElectronHistoryGestures(app.commandLine);
```
Install the renderer adapter after exposing the documented `window.nativeVueHost`
back/forward/memory-pressure bridge from a secure preload:
```ts
import { createElectronRendererAdapter } from "@native-vue-router/electron";
const nativeRouter = createNativeRouter({
router,
platform: createElectronRendererAdapter(),
});
```
Use hash history for packaged `file:` applications unless the Electron host
serves navigation URLs through an application protocol.
### Browser and PWA
The core works in normal browser tabs, but a browser may reserve an edge gesture
before page JavaScript can claim it. An installed iOS PWA can improve gesture
ownership with an early non-passive edge guard, but web content cannot change
`WKWebView.allowsBackForwardNavigationGestures`. Use Capacitor when deterministic
native-level ownership is required.
## Add to an existing Vue Router application
Adopt the library incrementally:
1. Confirm Vue 3.5+ and Vue Router 5, then install core and its CSS.
2. Create the native runtime from the existing router and install it after
`app.use(router)`.
3. Replace only the root visual outlet with `NativeRouterView`; leave nested
router views in route components intact.
4. Change navigation that needs native motion from `router.push()`/`RouterLink`
to runtime methods or `NativeLink`. Keep ordinary Vue Router calls where no
native transition is wanted.
5. Add `parent` metadata to deep-linkable child routes.
6. Identify peer routes such as tabs, assign `siblingOrder`, choose
`siblingHistory`, and pass their locations to `NativeNavigator`.
7. Move active-screen side effects from unconditional mount hooks into native
active or visible effects.
8. Mark nested horizontal controls with `data-native-gesture="ignore"` and add
component gesture links only where the product intends them.
9. Choose cache policy per route and move durable state out of component-local
memory.
10. Exercise URLs, redirects, rejected guards, direct deep links, browser Back
and Forward, held/cancelled gestures, reduced motion, and keyboard/focus
behavior before broad rollout.
For a lower-risk migration, start with button-driven `push()`, `pop()`, and
`present()`. Add predictive Back, siblings, and component-originated gestures
after the route topology and lifecycle behavior are verified.
## Diagnostics and profiling
Inspect the runtime's reactive state while integrating:
```ts
const native = useNativeRouter();
watchEffect(() => {
console.table(native.cacheStats.value);
console.log(native.transaction.value);
});
const stop = native.onDiagnostic((event) => {
console.debug("native-navigation", event);
});
```
The opt-in profiler records frame cadence and timing-safe navigation events:
```ts
import { createNativeNavigationProfiler } from "@native-vue-router/core";
const profiler = createNativeNavigationProfiler(nativeRouter, {
metadata: { build: import.meta.env.VITE_BUILD_ID },
});
profiler.start();
// Reproduce the navigation.
const report = profiler.stop();
const json = profiler.toJSON(report);
profiler.dispose();
```
Route params, query values, and application state are omitted from profiler
route labels.
## Troubleshooting
**The route surface is blank or has zero height.** Give `html`, `body`, `#app`,
and the application shell a definite height, and import the core stylesheet.
**`useNativeRouter()` says the plugin is not installed.** Create one runtime and
call `app.use(nativeRouter)` before mounting the app.
**`useNativeViewLifecycle()` throws.** Call it only from a component rendered
inside `NativeRouterView`.
**A route mounts even though a guard rejects it.** This is preview behavior, not
a committed navigation. Move irreversible work to an active effect.
**Back has no visual destination after a direct deep link.** Add a `parent`
location or parent function to that route's native metadata.
**A tab appears in browser Back history.** Set `siblingHistory: "replace"` and
use `native.sibling()` or the preset tab bar.
**A carousel or editor fights the route gesture.** Put
`data-native-gesture="ignore"` on the region that owns the input.
**A teleported dialog remains interactive from an inactive cached route.** Gate
the teleport content on `useNativeViewLifecycle().isVisible` and close it on
hide/deactivate when appropriate.
**Local state disappears.** The cache is bounded and can be trimmed by a host.
Use `cache: "pin"` sparingly or store durable state outside the route component.
## Further reading
- [How the preview and commit model works](docs/how-it-works.md)
- [Architecture reference](docs/architecture.md)
- [Engineering constraints and trade-offs](docs/challenges-and-tradeoffs.md)
- [Scalability and extension points](docs/principles-and-scalability.md)
- [Platform integration](docs/platforms.md)
- [Interactive demo](apps/demo)
## AI agent skill
This repository includes a portable integration skill at
[`skills/integrate-native-vue-router`](skills/integrate-native-vue-router).
Copy that complete directory into the skills location recognized by the agent
(for Codex, normally `~/.codex/skills/`) and invoke it as
`$integrate-native-vue-router`. Keep `SKILL.md`, `agents/openai.yaml`, and the
`references` directory together so the integration workflow retains its API and
verification reference.