Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.

This commit is contained in:
2026-07-22 07:04:39 +00:00
parent bfe364c57d
commit 6aed7606ad
48 changed files with 5454 additions and 84 deletions

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ apps/demo/dev-dist
*.njsproj
*.sln
*.sw?
docker-compose.yml

View File

@@ -9,7 +9,7 @@ The repository includes a reusable headless core, a platform-adaptive visual pre
- Interactive edge pop that can be held indefinitely at any progress.
- Ordered horizontal route paging with replace-by-default history.
- Component-originated route dragging with a live target route.
- Interactive push, adjacent-page sibling slide, modal, sheet, fade, and application-defined presentations.
- Interactive push, adjacent-page sibling slide, modal, safe-area-contained content/snap-point sheets with scroll-boundary handoff, fade, and application-defined presentations.
- Concurrent `from` and `to` routes using only public Vue Router 5 APIs.
- Guarded commits: previews do not alter the URL, and rejected navigation springs back.
- Cold-start predictive back through declared parent routes.
@@ -127,6 +127,7 @@ No rAF loop or browser performance observer runs before `start()`, and `stop()`
Design and engineering documentation:
- [Complete installation and usage guide](usage.md)
- [How it works and why the pattern is uncommon](docs/how-it-works.md)
- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)

View File

@@ -389,6 +389,69 @@ test("manually unloads an inactive route through the public API demo", async ({
await expect(page.getByTestId("stories-view")).toHaveCount(0);
});
test("exercises Vue built-ins, lifecycle hooks, injection, and scoped route state", async ({
page,
}) => {
await page.goto("/profile");
await page.getByRole("link", { name: /Vue compatibility lab/ }).click();
await expect(page).toHaveURL(
/\/profile\/vue-lab\/alpha\?mode=manual#route-state$/,
);
const lab = page.getByTestId("vue-compatibility-view");
const firstInstance = await lab.getAttribute("data-instance-id");
await expect(page.getByTestId("compat-param")).toHaveText("alpha");
await expect(page.getByTestId("compat-query")).toContainText("manual");
await expect(page.getByTestId("compat-hash")).toHaveText("#route-state");
await expect(page.getByTestId("options-lifecycle-probe")).toContainText(
"/profile/vue-lab/alpha?mode=manual#route-state",
);
await expect(page.getByTestId("inject-probe-route-tree")).toContainText(
"Injected from the demo application root",
);
await page.getByRole("button", { name: "Increment A" }).click();
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
await page.getByTestId("compat-switch-keepalive").click();
await expect(page.getByTestId("keep-alive-B")).toBeVisible();
await page.getByTestId("compat-switch-keepalive").click();
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
await page.getByTestId("compat-update-probes").click();
await expect(page.getByTestId("compat-event-log")).toContainText("onUpdated");
await page.getByTestId("compat-toggle-transition").click();
await expect(page.getByTestId("compat-transition-card")).toHaveCount(0);
await expect(page.getByTestId("compat-event-log")).toContainText(
"after-leave",
);
await page.getByTestId("compat-open-teleport").click();
const teleport = page.getByTestId("compat-teleport-overlay");
await expect(teleport).toBeVisible();
await expect(page.getByTestId("inject-probe-teleport")).toContainText(
"Injected from the demo application root",
);
await page.getByRole("button", { name: "Close teleported overlay" }).click();
await page.getByTestId("compat-reload-suspense").click();
await expect(page.getByTestId("compat-suspense-fallback")).toBeVisible();
await expect(page.getByTestId("compat-suspense-ready")).toBeVisible({
timeout: 2_000,
});
await page.getByTestId("compat-open-away").click();
await expect(page.getByTestId("vue-compatibility-away")).toBeVisible();
await page.getByTestId("compat-unload-return").click();
await expect(page.getByTestId("vue-compatibility-view")).toBeVisible();
await expect(page.getByTestId("vue-compatibility-view")).not.toHaveAttribute(
"data-instance-id",
firstInstance!,
);
await expect(page.getByTestId("compat-event-log")).toContainText(
"onUnmounted",
);
});
test("records a navigation frame profile across route changes", async ({
page,
}) => {
@@ -421,10 +484,213 @@ test("opens and dismisses the compose sheet", async ({ page }) => {
await expect(
page.getByRole("heading", { name: "New message" }),
).toBeVisible();
await waitForTransition(page);
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
"data-native-sheet-breakpoint",
"0.62",
);
await expect(page.locator('[data-native-role="underlay"]')).toHaveAttribute(
"aria-hidden",
"true",
);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test("contains a sheet below the safe top and supports snap and content sizes", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click();
await expect(page).toHaveURL(/\/compose$/);
await waitForTransition(page);
const routerView = page.locator(".nvr-router-view");
const activeLayer = page.locator('[data-native-role="active"]');
const frame = await routerView.boundingBox();
const layer = await activeLayer.boundingBox();
if (!frame || !layer) throw new Error("Sheet route did not render");
expect(layer.y).toBeGreaterThanOrEqual(frame.y + 7);
expect(layer.y + layer.height).toBeLessThanOrEqual(
frame.y + frame.height + 1,
);
const handle = page.getByRole("slider", {
name: "Resize or dismiss sheet",
});
await handle.press("ArrowUp");
await expect(page.getByTestId("sheet-size")).toHaveText("100%");
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
"data-native-sheet-breakpoint",
"1",
);
await page.getByRole("button", { name: "Fit content" }).click();
await expect(page.getByTestId("sheet-size")).toHaveText("Auto");
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
"data-native-sheet-mode",
"content",
);
});
test("animates the partial surface immediately and preserves underlay geometry", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
const opening = await page.evaluate(async () => {
for (let frame = 0; frame < 60; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
const routerView = document.querySelector<HTMLElement>(
".nvr-router-view--interactive",
);
const surface = document.querySelector<HTMLElement>(
'[data-native-role="to"] [data-native-sheet]',
);
const progress = Number(
routerView?.style.getPropertyValue("--native-progress") ?? 0,
);
if (routerView && surface && progress >= 0.03) {
const frameRect = routerView.getBoundingClientRect();
const surfaceRect = surface.getBoundingClientRect();
return {
progress,
frameBottom: frameRect.bottom,
surfaceTop: surfaceRect.top,
surfaceHeight: surfaceRect.height,
};
}
}
return undefined;
});
expect(opening).toBeDefined();
expect(opening!.surfaceTop).toBeLessThan(opening!.frameBottom);
await waitForTransition(page);
const settledSurfaceHeight = await page
.locator("[data-native-sheet]")
.evaluate((element) => element.getBoundingClientRect().height);
expect(opening!.surfaceHeight).toBeCloseTo(settledSurfaceHeight, 0);
const openUnderlay = await page
.locator('[data-native-role="underlay"]')
.evaluate((element) => {
const matrix = new DOMMatrix(getComputedStyle(element).transform);
const rect = element.getBoundingClientRect();
return { scale: matrix.a, top: rect.top };
});
expect(openUnderlay.scale).toBeCloseTo(0.96, 2);
expect(openUnderlay.top).toBeGreaterThan(0);
await page.getByRole("button", { name: "Cancel" }).dispatchEvent("click");
const closing = await page.evaluate(async () => {
for (let frame = 0; frame < 60; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
const routerView = document.querySelector<HTMLElement>(
'.nvr-router-view--interactive[data-native-direction="back"]',
);
const destination = document.querySelector<HTMLElement>(
'[data-native-role="to"]',
);
const progress = Number(
routerView?.style.getPropertyValue("--native-progress") ?? 0,
);
if (routerView && destination && progress >= 0.03) {
const matrix = new DOMMatrix(getComputedStyle(destination).transform);
return { progress, scale: matrix.a };
}
}
return undefined;
});
expect(closing).toBeDefined();
expect(closing!.scale).toBeGreaterThanOrEqual(0.96);
expect(closing!.scale).toBeLessThan(1);
await waitForTransition(page);
await expect(page).toHaveURL(/\/inbox$/);
const restoredScale = await page
.locator('[data-native-role="active"]')
.evaluate(
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
);
expect(restoredScale).toBeCloseTo(1, 3);
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
await page.waitForFunction(() => {
const routerView = document.querySelector<HTMLElement>(
'.nvr-router-view--interactive[data-native-presentation="sheet"]',
);
const surface = document.querySelector<HTMLElement>(
'[data-native-role="to"] [data-native-sheet]',
);
return Boolean(
routerView &&
surface &&
surface.getBoundingClientRect().top <
routerView.getBoundingClientRect().bottom,
);
});
await waitForTransition(page);
const repeatedUnderlayScale = await page
.locator('[data-native-role="underlay"]')
.evaluate(
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
);
expect(repeatedUnderlayScale).toBeCloseTo(0.96, 2);
});
test("hands content overscroll to adjacent sheet breakpoints", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click();
await expect(page).toHaveURL(/\/compose$/);
await waitForTransition(page);
const surface = page.locator("[data-native-sheet]");
const body = page.locator(".nvr-sheet__body");
const box = await body.boundingBox();
if (!box) throw new Error("Sheet scroll body did not render");
await body.evaluate((element) => {
element.scrollTop = Math.min(
40,
Math.max(0, element.scrollHeight - element.clientHeight - 10),
);
});
await page.mouse.move(box.x + 100, box.y + box.height * 0.5);
await page.mouse.wheel(0, 1_000);
await page.mouse.wheel(0, 80);
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
await page.waitForTimeout(280);
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await page.mouse.move(box.x + 100, box.y + box.height * 0.7);
await page.mouse.down();
await page.mouse.move(box.x + 100, box.y + box.height * 0.35, { steps: 10 });
await page.mouse.up();
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "1");
await body.evaluate((element) => {
element.scrollTop = 0;
});
const expandedBox = await body.boundingBox();
if (!expandedBox) throw new Error("Expanded sheet body did not render");
await page.mouse.move(
expandedBox.x + 100,
expandedBox.y + expandedBox.height * 0.35,
);
await page.mouse.down();
await page.mouse.move(
expandedBox.x + 100,
expandedBox.y + expandedBox.height * 0.55,
{ steps: 10 },
);
await page.mouse.up();
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
});
test("keeps the target live during a held component drag", async ({ page }) => {
await page.goto("/inbox");
const row = page.locator(".conversation-row").first();
@@ -528,13 +794,16 @@ test("always opens compose as a vertical sheet after prior navigation", async ({
test("drags a sheet down to dismiss it", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click();
const sheet = await page.locator(".sheet-screen").boundingBox();
if (!sheet) throw new Error("Sheet did not render");
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
await expect(page).toHaveURL(/\/compose$/);
await waitForTransition(page);
const sheet = await page.locator("[data-native-sheet]").boundingBox();
const handle = await page.locator(".nvr-sheet__handle").boundingBox();
if (!sheet || !handle) throw new Error("Sheet did not render");
await page.mouse.move(handle.x + handle.width / 2, handle.y + 12);
await page.mouse.down();
await page.mouse.move(
sheet.x + sheet.width / 2,
sheet.y + sheet.height * 0.55,
handle.x + handle.width / 2,
handle.y + sheet.height * 0.75,
{ steps: 14 },
);
await page.mouse.up();

View File

@@ -0,0 +1,44 @@
import { reactive, type InjectionKey, type Ref } from "vue";
export interface CompatibilityLabEvent {
id: number;
timestamp: string;
source: string;
hook: string;
detail?: string;
}
export interface CompatibilityLabContext {
source: string;
routeLabel: Readonly<Ref<string>>;
}
export const demoAppValueKey: InjectionKey<string> = Symbol("demo-app-value");
export const compatibilityLabContextKey: InjectionKey<CompatibilityLabContext> =
Symbol("compatibility-lab-context");
let eventSequence = 0;
const startedAt = performance.now();
export const compatibilityLab = reactive({
events: [] as CompatibilityLabEvent[],
});
export function recordCompatibilityEvent(
source: string,
hook: string,
detail?: string,
) {
compatibilityLab.events.unshift({
id: ++eventSequence,
timestamp: `${(performance.now() - startedAt).toFixed(0)} ms`,
source,
hook,
detail,
});
if (compatibilityLab.events.length > 120) compatibilityLab.events.splice(120);
}
export function clearCompatibilityEvents() {
compatibilityLab.events.splice(0);
}

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { inject } from "vue";
import { useRoute } from "vue-router";
import {
compatibilityLabContextKey,
demoAppValueKey,
recordCompatibilityEvent,
} from "../compatibility-lab";
const props = defineProps<{ requestId: number }>();
const route = useRoute();
const appValue = inject(demoAppValueKey, "missing app injection");
const labContext = inject(compatibilityLabContextKey);
recordCompatibilityEvent(
"Suspense",
"async setup started",
`request ${props.requestId}`,
);
await new Promise((resolve) => window.setTimeout(resolve, 750));
recordCompatibilityEvent(
"Suspense",
"async setup resolved",
`request ${props.requestId}`,
);
</script>
<template>
<article
class="compat-probe compat-probe--resolved"
data-testid="compat-suspense-ready"
>
<span class="compat-probe__badge">Suspense resolved</span>
<strong>Async request {{ requestId }} complete</strong>
<p>{{ appValue }}</p>
<p>{{ labContext?.routeLabel.value }}</p>
<p>{{ route.fullPath }}</p>
</article>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import {
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
onUnmounted,
onUpdated,
ref,
} from "vue";
import { useRoute } from "vue-router";
import { recordCompatibilityEvent } from "../compatibility-lab";
const props = defineProps<{
instanceName: string;
revision: number;
}>();
const route = useRoute();
const localCount = ref(0);
const record = (hook: string) =>
recordCompatibilityEvent(props.instanceName, hook, route.fullPath);
onBeforeMount(() => record("onBeforeMount"));
onMounted(() => record("onMounted"));
onBeforeUpdate(() => record("onBeforeUpdate"));
onUpdated(() => record("onUpdated"));
onBeforeUnmount(() => record("onBeforeUnmount"));
onUnmounted(() => record("onUnmounted"));
</script>
<template>
<article class="compat-probe" data-testid="composition-lifecycle-probe">
<span class="compat-probe__badge">Composition API</span>
<strong>{{ instanceName }}</strong>
<p><code>useRoute()</code>: {{ route.fullPath }}</p>
<p>Revision {{ revision }} · local count {{ localCount }}</p>
<button type="button" @click="localCount += 1">
Increment local state
</button>
</article>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { useRoute } from "vue-router";
import {
compatibilityLabContextKey,
demoAppValueKey,
} from "../compatibility-lab";
defineProps<{ location: "route tree" | "teleport" }>();
const route = useRoute();
const appValue = inject(demoAppValueKey, "missing app injection");
const labContext = inject(compatibilityLabContextKey);
const labValue = computed(
() => labContext?.routeLabel.value ?? "missing page injection",
);
</script>
<template>
<article
class="compat-probe"
:data-testid="`inject-probe-${location.replace(' ', '-')}`"
>
<span class="compat-probe__badge">provide / inject · {{ location }}</span>
<strong>{{ appValue }}</strong>
<p>Page injection: {{ labValue }}</p>
<p>Scoped route: {{ route.fullPath }}</p>
</article>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import {
onActivated,
onBeforeMount,
onBeforeUnmount,
onDeactivated,
onMounted,
onUnmounted,
ref,
} from "vue";
import { recordCompatibilityEvent } from "../compatibility-lab";
const props = defineProps<{ name: string }>();
const count = ref(0);
const record = (hook: string) =>
recordCompatibilityEvent(`KeepAlive ${props.name}`, hook);
onBeforeMount(() => record("onBeforeMount"));
onMounted(() => record("onMounted"));
onActivated(() => record("onActivated"));
onDeactivated(() => record("onDeactivated"));
onBeforeUnmount(() => record("onBeforeUnmount"));
onUnmounted(() => record("onUnmounted"));
</script>
<template>
<article
class="compat-probe compat-probe--keepalive"
:data-testid="`keep-alive-${name}`"
>
<span class="compat-probe__badge">Kept instance {{ name }}</span>
<strong>Counter: {{ count }}</strong>
<p>Increment, switch instances, then return to verify preserved state.</p>
<button type="button" @click="count += 1">Increment {{ name }}</button>
</article>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { defineComponent } from "vue";
import type { RouteLocationNormalizedLoaded } from "vue-router";
import { recordCompatibilityEvent } from "../compatibility-lab";
export default defineComponent({
name: "CompatibilityOptionsProbe",
props: {
instanceName: { type: String, required: true },
revision: { type: Number, required: true },
},
data: () => ({ localCount: 0 }),
beforeCreate() {
recordCompatibilityEvent(this.instanceName, "beforeCreate");
},
created() {
recordCompatibilityEvent(this.instanceName, "created", this.routePath());
},
beforeMount() {
recordCompatibilityEvent(this.instanceName, "beforeMount");
},
mounted() {
recordCompatibilityEvent(this.instanceName, "mounted");
},
beforeUpdate() {
recordCompatibilityEvent(this.instanceName, "beforeUpdate");
},
updated() {
recordCompatibilityEvent(this.instanceName, "updated");
},
beforeUnmount() {
recordCompatibilityEvent(this.instanceName, "beforeUnmount");
},
unmounted() {
recordCompatibilityEvent(this.instanceName, "unmounted");
},
methods: {
routePath() {
return (this as unknown as { $route: RouteLocationNormalizedLoaded })
.$route.fullPath;
},
},
});
</script>
<template>
<article class="compat-probe" data-testid="options-lifecycle-probe">
<span class="compat-probe__badge">Options API</span>
<strong>{{ instanceName }}</strong>
<p><code>$route</code>: {{ routePath() }}</p>
<p>Revision {{ revision }} · local count {{ localCount }}</p>
<button type="button" @click="localCount += 1">
Increment local state
</button>
</article>
</template>

View File

@@ -3,6 +3,7 @@ import { createNativeRouter } from "@native-vue-router/core";
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
import { createElectronRendererAdapter } from "@native-vue-router/electron";
import App from "./App.vue";
import { demoAppValueKey } from "./compatibility-lab";
import { createPwaAdapter } from "./pwa";
import { router } from "./router";
import "./style.css";
@@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({
const app = createApp(App);
app.use(router);
app.use(nativeRouter);
app.provide(demoAppValueKey, "Injected from the demo application root");
await router.isReady();
app.mount("#app");

View File

@@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [
native: { presentation: "push", parent: "/profile", gesture: "edge" },
},
},
{
path: "/profile/vue-lab/:sample",
name: "vue-compatibility",
component: () => import("./views/VueCompatibilityView.vue"),
meta: {
native: { presentation: "push", parent: "/profile", gesture: "edge" },
},
},
{
path: "/profile/vue-lab/:sample/away",
name: "vue-compatibility-away",
component: () => import("./views/VueCompatibilityAwayView.vue"),
meta: {
native: {
presentation: "push",
parent: (route) => ({
name: "vue-compatibility",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
}),
gesture: "edge",
},
},
},
];
export const router = createRouter({

View File

@@ -28,7 +28,8 @@ html,
body,
#app {
width: 100%;
height: 100%;
/* Commenting this out for now. Its causing a black bar at the bottom of the screen on iOS */
/* height: 100%; */
margin: 0;
overflow: hidden;
overscroll-behavior: none;
@@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body {
.app-frame {
position: relative;
width: 100%;
height: 100dvh;
height: 100vh;
max-width: 740px;
margin: 0 auto;
overflow: hidden;
@@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body {
font-size: 21px;
}
.sheet-screen {
border-radius: 22px 22px 0 0;
.compose-sheet {
--nvr-sheet-background: #0b0d12;
--nvr-sheet-backdrop: rgba(0, 0, 0, 0.42);
}
.sheet-handle {
position: sticky;
z-index: 12;
top: 8px;
width: 38px;
height: 5px;
margin: 8px auto 0;
border-radius: 4px;
background: #4e5360;
.compose-sheet-content {
min-height: 0;
background:
radial-gradient(
circle at 88% 4%,
rgba(124, 92, 255, 0.09),
transparent 24%
),
#0b0d12;
}
.sheet-header {
display: grid;
@@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body {
margin: 0;
font-size: 16px;
}
.sheet-header small {
display: block;
margin-top: 2px;
color: var(--muted);
text-align: center;
font-size: 10px;
}
.sheet-header button {
justify-self: start;
border: 0;
color: #9b88ff;
background: transparent;
}
.sheet-header button:last-child {
justify-self: end;
font-size: 11px;
}
.compose-search {
margin: 0 16px 14px;
}
@@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body {
background: #ff6c76;
box-shadow: 0 0 9px #ff6c76;
}
.compatibility-screen {
padding-bottom: calc(34px + env(safe-area-inset-bottom));
}
.compat-header-state {
padding: 5px 8px;
border: 1px solid rgba(255, 194, 92, 0.28);
border-radius: 999px;
color: #ffc86c;
background: rgba(255, 194, 92, 0.08);
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
}
.compat-header-state.active {
border-color: rgba(61, 217, 170, 0.3);
color: #6ce5bf;
background: rgba(61, 217, 170, 0.08);
}
.compat-intro > span {
width: 64px;
font-size: 15px;
}
.compat-route-state code {
max-width: 52%;
overflow-wrap: anywhere;
color: #b9adff;
font-size: 11px;
text-align: right;
}
.compat-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0 16px 18px;
}
.compat-controls--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.compat-controls button,
.compat-inline-actions button,
.compat-probe button,
.compat-event-heading button {
min-height: 42px;
border: 1px solid rgba(124, 92, 255, 0.25);
border-radius: 12px;
color: #c7beff;
background: rgba(124, 92, 255, 0.1);
font-size: 11px;
font-weight: 750;
}
.settings-group > .compat-inline-actions {
min-height: auto;
justify-content: flex-start;
}
.compat-inline-actions button {
flex: 1;
}
.settings-group > .compat-probe-grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 10px;
padding: 12px;
}
.compat-probe {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin: 10px 12px;
padding: 13px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 15px;
background: rgba(255, 255, 255, 0.025);
}
.compat-probe-grid .compat-probe {
margin: 0;
}
.compat-probe__badge {
color: #a998ff;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.compat-probe strong {
font-size: 13px;
}
.compat-probe p {
margin: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.compat-probe code {
color: #b9adff;
}
.compat-probe button {
width: 100%;
margin-top: auto;
}
.compat-probe--keepalive {
border-color: rgba(61, 217, 170, 0.16);
background: rgba(61, 217, 170, 0.045);
}
.compat-probe--resolved {
border-color: rgba(61, 217, 170, 0.2);
}
.compat-probe--loading {
min-height: 96px;
flex-direction: row;
align-items: center;
}
.compat-transition-card {
margin: 10px 12px;
padding: 22px 14px;
border: 1px solid rgba(255, 194, 92, 0.22);
border-radius: 15px;
color: #ffd28a;
background: rgba(255, 194, 92, 0.07);
font-size: 12px;
font-weight: 750;
text-align: center;
}
.compat-fade-enter-active,
.compat-fade-leave-active {
transition:
opacity 220ms ease,
transform 220ms ease;
}
.compat-fade-enter-from,
.compat-fade-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
.compat-teleport {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(2, 3, 6, 0.74);
backdrop-filter: blur(12px);
}
.compat-teleport > section {
position: relative;
width: min(440px, 100%);
padding: 20px;
border: 1px solid rgba(169, 152, 255, 0.32);
border-radius: 22px;
background: #141720;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58);
}
.compat-teleport > section > button {
position: absolute;
top: 10px;
right: 10px;
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
color: #fff;
background: rgba(255, 255, 255, 0.08);
font-size: 20px;
}
.compat-teleport h2 {
margin: 0 44px 15px 0;
font-size: 18px;
}
.compat-teleport .compat-probe {
margin: 0;
}
.settings-group > .compat-event-heading {
min-height: 66px;
}
.compat-event-heading button {
min-height: 34px;
padding: 0 12px;
}
.compat-event-log {
max-height: 360px;
margin: 0;
padding: 0;
overflow-y: auto;
border-top: 1px solid var(--line);
list-style: none;
}
.compat-event-log li {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 9px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
}
.compat-event-log time,
.compat-event-log small {
color: #686e7b;
font-size: 9px;
}
.compat-event-log span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.compat-event-log strong {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compat-event-log code {
color: #afa1ff;
font-size: 10px;
}
.empty-state {
display: grid;
place-items: center;
@@ -926,6 +1159,19 @@ html[data-pwa-edge-guard="active"] body {
}
}
@media (max-width: 520px) {
.compat-controls--three,
.settings-group > .compat-probe-grid {
grid-template-columns: 1fr;
}
.compat-event-log li {
grid-template-columns: 52px minmax(0, 1fr);
}
.compat-event-log li > small {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -1,37 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
import AppAvatar from '../components/AppAvatar.vue'
import { useDemoStore } from '../data'
import { computed, ref } from "vue";
import { NativeSheet, useNativeRouter } from "@native-vue-router/core";
import AppAvatar from "../components/AppAvatar.vue";
import { useDemoStore } from "../data";
const native = useNativeRouter()
const store = useDemoStore()
const query = ref('')
const native = useNativeRouter();
const store = useDemoStore();
const query = ref("");
const sheetBreakpoint = ref(0.62);
const snapEnabled = ref(true);
const sheetBreakpoints = computed(() =>
snapEnabled.value ? [0.38, 0.62, 1] : [],
);
async function choose(id: string) {
await native.cancelInteractive()
await native.replace(`/chat/${id}`, { presentation: 'push' })
await native.cancelInteractive();
await native.replace(`/chat/${id}`, { presentation: "push" });
}
</script>
<template>
<NativeDismissGesture as="main" class="screen sheet-screen">
<div class="sheet-handle" aria-hidden="true" />
<NativeSheet
v-model="sheetBreakpoint"
class="compose-sheet"
aria-label="New message"
:breakpoints="sheetBreakpoints"
:initial-breakpoint="0.62"
>
<main class="compose-sheet-content">
<header class="sheet-header">
<button type="button" @click="native.dismiss()">Cancel</button>
<div>
<h1>New message</h1>
<span />
<small data-testid="sheet-size">
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
</small>
</div>
<button type="button" @click="snapEnabled = !snapEnabled">
{{ snapEnabled ? "Fit content" : "Use snap points" }}
</button>
</header>
<label class="search-field compose-search">
<span>To:</span>
<input v-model="query" autofocus placeholder="Search people" />
</label>
<div class="conversation-list">
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
<button
v-for="person in store.people.value.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase()),
)"
:key="person.id"
class="conversation-row"
@click="choose(person.id)"
>
<AppAvatar :person="person" />
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
<div class="conversation-copy">
<strong>{{ person.name }}</strong>
<p>{{ person.handle }}</p>
</div>
<span class="chevron"></span>
</button>
</div>
</NativeDismissGesture>
</main>
</NativeSheet>
</template>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
import AppHeader from "../components/AppHeader.vue";
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
const native = useNativeRouter()
const native = useNativeRouter();
function toggleStoryGuard() {
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
}
</script>
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
</div>
</section>
<section class="settings-list">
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span></span><strong>Runtime stress lab</strong><i></i></a>
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
<span></span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i>
<a
href="/profile/runtime-lab"
@click.prevent="native.sibling('/profile/runtime-lab')"
><span></span><strong>Runtime stress lab</strong><i></i></a
>
<button
type="button"
aria-label="Block Stories re-entry"
:aria-pressed="storyEntryGuard.blockEntry"
@click="toggleStoryGuard"
>
<span></span><strong>Block cached Stories re-entry</strong
><i data-testid="story-guard-status">{{
storyEntryGuard.blockEntry ? storyEntryGuard.status : "Off"
}}</i>
</button>
<NativeLink to="/settings"
><span></span><strong>Navigation lab</strong><i></i></NativeLink
>
<NativeLink to="/profile/vue-lab/alpha?mode=manual#route-state"
><span>Vue</span><strong>Vue compatibility lab</strong
><i></i></NativeLink
>
<a href="https://github.com" target="_blank" rel="noreferrer"
><span></span><strong>Project source</strong><i></i></a
>
<button type="button">
<span></span><strong>Appearance</strong><i>System</i>
</button>
<NativeLink to="/settings"><span></span><strong>Navigation lab</strong><i></i></NativeLink>
<a href="https://github.com" target="_blank" rel="noreferrer"><span></span><strong>Project source</strong><i></i></a>
<button type="button"><span></span><strong>Appearance</strong><i>System</i></button>
</section>
</main>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useNativeRouter } from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import {
compatibilityLab,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const unloadStatus = ref("Lab remains cached");
const labLocation = computed(() => ({
name: "vue-compatibility",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
}));
function unloadLab() {
const count = native.unload(labLocation.value);
unloadStatus.value = count
? "Lab view evicted; Back will create a new instance"
: "No matching inactive lab view was mounted";
recordCompatibilityEvent("Away screen", "native.unload", `${count} view(s)`);
}
async function unloadAndReturn() {
unloadLab();
await native.pop();
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-away"
>
<AppHeader
title="Cached-route checkpoint"
subtitle="The compatibility lab is behind this view"
back
/>
<section class="lab-intro compat-intro">
<span></span>
<div>
<strong>Inspect native cache behavior</strong>
<p>
The previous lab instance is inactive but still mounted until you
explicitly unload it.
</p>
</div>
</section>
<section class="settings-group">
<h2>Inactive route controls</h2>
<div>
<span
><strong>Compatibility lab</strong
><small>{{ unloadStatus }}</small></span
><b>behind</b>
</div>
<p>
Use normal Back to observe native activate/show without Vue remount
hooks. Use Unload and return to observe beforeUnmount/unmounted
followed by a fresh component instance.
</p>
</section>
<div class="compat-controls">
<button type="button" data-testid="compat-unload-lab" @click="unloadLab">
Unload cached lab
</button>
<button
type="button"
data-testid="compat-unload-return"
@click="unloadAndReturn"
>
Unload and return
</button>
</div>
<section class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Shared lifecycle journal</strong
><small>Events survive route eviction</small></span
>
</div>
<ol class="compat-event-log" data-testid="compat-away-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>

View File

@@ -0,0 +1,427 @@
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
provide,
ref,
} from "vue";
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeRouter,
useNativeViewLifecycle,
} from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import CompatibilityAsyncProbe from "../components/CompatibilityAsyncProbe.vue";
import CompatibilityCompositionProbe from "../components/CompatibilityCompositionProbe.vue";
import CompatibilityInjectProbe from "../components/CompatibilityInjectProbe.vue";
import CompatibilityKeepAliveProbe from "../components/CompatibilityKeepAliveProbe.vue";
import CompatibilityOptionsProbe from "../components/CompatibilityOptionsProbe.vue";
import {
clearCompatibilityEvents,
compatibilityLab,
compatibilityLabContextKey,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const nativeLifecycle = useNativeViewLifecycle();
const instanceId = Math.random().toString(36).slice(2, 7);
const revision = ref(0);
const probesMounted = ref(true);
const keepAliveVariant = ref<"A" | "B">("A");
const transitionVisible = ref(true);
const teleportOpen = ref(false);
const suspenseRequest = ref(1);
const routeLabel = computed(() => route.fullPath);
const nativeVisible = nativeLifecycle.isVisible;
const nativeActive = nativeLifecycle.isActive;
const eventCount = computed(() => compatibilityLab.events.length);
provide(compatibilityLabContextKey, {
source: `Vue compatibility lab ${instanceId}`,
routeLabel,
});
const recordPageEvent = (hook: string, detail?: string) =>
recordCompatibilityEvent(`Lab page ${instanceId}`, hook, detail);
onMounted(() => recordPageEvent("onMounted", route.fullPath));
onBeforeUnmount(() => recordPageEvent("onBeforeUnmount"));
onUnmounted(() => recordPageEvent("onUnmounted"));
onNativeViewActivate(() => recordPageEvent("native activate"));
onNativeViewDeactivate(() => recordPageEvent("native deactivate"));
onNativeViewShow(() => recordPageEvent("native show"));
onNativeViewHide(() => recordPageEvent("native hide"));
onNativeViewEvict((reason) => recordPageEvent("native evict", String(reason)));
function updateProbes() {
revision.value += 1;
recordPageEvent("revision changed", String(revision.value));
}
function toggleProbeMount() {
probesMounted.value = !probesMounted.value;
recordPageEvent(probesMounted.value ? "probes inserted" : "probes removed");
}
function switchKeptInstance() {
keepAliveVariant.value = keepAliveVariant.value === "A" ? "B" : "A";
}
function recordTransition(hook: string) {
recordCompatibilityEvent("Transition", hook);
}
function reloadSuspense() {
suspenseRequest.value += 1;
}
function pushAlternateRoute() {
const sample = route.params.sample === "alpha" ? "beta" : "alpha";
void native.push({
name: "vue-compatibility",
params: { sample },
query: { mode: "parameter", revision: revision.value },
hash: "#route-state",
});
}
function replaceQueryAndHash() {
void native.replace(
{
name: "vue-compatibility",
params: { sample: route.params.sample },
query: { mode: "replaced", tick: Date.now().toString().slice(-5) },
hash: "#event-log",
},
{ presentation: "fade" },
);
}
function openAwayRoute() {
void native.push({
name: "vue-compatibility-away",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
});
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-view"
:data-instance-id="instanceId"
>
<AppHeader
title="Vue compatibility"
subtitle="Native component laboratory"
back
>
<span class="compat-header-state" :class="{ active: nativeActive }">{{
nativeActive ? "active" : nativeVisible ? "transitioning" : "cached"
}}</span>
</AppHeader>
<section class="lab-intro compat-intro">
<span>Vue</span>
<div>
<strong>Exercise real framework behavior</strong>
<p>
Every control below runs inside a routed, cached NativeRouterView
entry.
</p>
</div>
</section>
<section
id="route-state"
class="settings-group compat-route-state"
data-testid="compat-route-state"
>
<h2>Scoped route state</h2>
<div>
<span
><strong>Full path</strong
><small>useRoute() and Options API $route</small></span
><code data-testid="compat-full-path">{{ route.fullPath }}</code>
</div>
<div>
<span
><strong>Named route</strong
><small>Matched record identity</small></span
><code>{{ String(route.name) }}</code>
</div>
<div>
<span><strong>Param</strong><small>route.params.sample</small></span
><code data-testid="compat-param">{{ route.params.sample }}</code>
</div>
<div>
<span><strong>Query</strong><small>route.query</small></span
><code data-testid="compat-query">{{
JSON.stringify(route.query)
}}</code>
</div>
<div>
<span><strong>Hash</strong><small>route.hash</small></span
><code data-testid="compat-hash">{{ route.hash || "(empty)" }}</code>
</div>
<div>
<span><strong>Matched</strong><small>route.matched</small></span
><code>{{
route.matched.map((record) => String(record.name)).join(" → ")
}}</code>
</div>
</section>
<div class="compat-controls compat-controls--three">
<button
type="button"
data-testid="compat-change-param"
@click="pushAlternateRoute"
>
Push alternate param
</button>
<button
type="button"
data-testid="compat-change-query"
@click="replaceQueryAndHash"
>
Replace query + hash
</button>
<button
type="button"
data-testid="compat-open-away"
@click="openAwayRoute"
>
Cache this view
</button>
</div>
<section class="settings-group">
<h2>provide() / inject()</h2>
<CompatibilityInjectProbe location="route tree" />
<p>
The same probe is rendered inside the Teleport below to verify
logical-tree injection and scoped routing.
</p>
</section>
<section class="settings-group">
<h2>Options and Composition lifecycle</h2>
<div>
<span
><strong>Shared revision</strong
><small>Changing it triggers beforeUpdate / updated</small></span
><b data-testid="compat-revision">{{ revision }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-update-probes"
@click="updateProbes"
>
Update props
</button>
<button
type="button"
data-testid="compat-toggle-probes"
@click="toggleProbeMount"
>
{{ probesMounted ? "Unmount probes" : "Mount probes" }}
</button>
</div>
<div v-if="probesMounted" class="compat-probe-grid">
<CompatibilityOptionsProbe
:instance-name="`Options ${instanceId}`"
:revision="revision"
/>
<CompatibilityCompositionProbe
:instance-name="`Composition ${instanceId}`"
:revision="revision"
/>
</div>
</section>
<section class="settings-group">
<h2>KeepAlive</h2>
<div>
<span
><strong>Current cached child</strong
><small>Switch away and back after incrementing</small></span
><b>{{ keepAliveVariant }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-switch-keepalive"
@click="switchKeptInstance"
>
Switch to {{ keepAliveVariant === "A" ? "B" : "A" }}
</button>
</div>
<KeepAlive>
<CompatibilityKeepAliveProbe
:key="keepAliveVariant"
:name="keepAliveVariant"
/>
</KeepAlive>
</section>
<section class="settings-group">
<h2>Transition</h2>
<div>
<span
><strong>CSS transition target</strong
><small>Hooks are recorded in the event journal</small></span
><b>{{ transitionVisible ? "shown" : "removed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-toggle-transition"
@click="transitionVisible = !transitionVisible"
>
Toggle transition
</button>
</div>
<Transition
name="compat-fade"
@before-enter="recordTransition('before-enter')"
@after-enter="recordTransition('after-enter')"
@before-leave="recordTransition('before-leave')"
@after-leave="recordTransition('after-leave')"
>
<article
v-if="transitionVisible"
class="compat-transition-card"
data-testid="compat-transition-card"
>
Transition child is mounted
</article>
</Transition>
</section>
<section class="settings-group">
<h2>Teleport</h2>
<div>
<span
><strong>Body-level overlay</strong
><small
>Automatically hidden when this native view is inactive</small
></span
><b>{{ teleportOpen ? "armed" : "closed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-open-teleport"
@click="teleportOpen = true"
>
Open teleported overlay
</button>
</div>
</section>
<Teleport to="body">
<Transition name="compat-fade">
<div
v-if="teleportOpen && nativeVisible"
class="compat-teleport"
data-testid="compat-teleport-overlay"
@click.self="teleportOpen = false"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="compat-teleport-title"
>
<button
type="button"
aria-label="Close teleported overlay"
@click="teleportOpen = false"
>
×
</button>
<h2 id="compat-teleport-title">Teleported route content</h2>
<CompatibilityInjectProbe location="teleport" />
</section>
</div>
</Transition>
</Teleport>
<section class="settings-group">
<h2>Suspense</h2>
<div>
<span
><strong>Async setup request</strong
><small>Fallback remains for 750 ms</small></span
><b>#{{ suspenseRequest }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-reload-suspense"
@click="reloadSuspense"
>
Reload async child
</button>
</div>
<Suspense
:key="suspenseRequest"
:timeout="0"
@pending="recordCompatibilityEvent('Suspense', 'pending')"
@fallback="recordCompatibilityEvent('Suspense', 'fallback')"
@resolve="recordCompatibilityEvent('Suspense', 'resolve')"
>
<CompatibilityAsyncProbe :request-id="suspenseRequest" />
<template #fallback>
<article
class="compat-probe compat-probe--loading"
data-testid="compat-suspense-fallback"
>
<span class="lab-spinner" />
<div>
<strong>Suspense fallback</strong>
<p>Waiting for async setup</p>
</div>
</article>
</template>
</Suspense>
</section>
<section id="event-log" class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Lifecycle event journal</strong
><small>{{ eventCount }} retained events · newest first</small></span
>
<button
type="button"
data-testid="compat-clear-events"
@click="clearCompatibilityEvents"
>
Clear
</button>
</div>
<ol class="compat-event-log" data-testid="compat-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>

View File

@@ -87,7 +87,7 @@ useNativeViewActiveEffect(() => {
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
```
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store.
`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

View File

@@ -70,9 +70,9 @@ Transactions are interruptible. Starting a new navigation while a spring is sett
## Rendering and presentation
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is visible and interactive. During a transaction, exactly the `from` and `to` entries receive active roles.
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is interactive. A partial sheet also keeps its prior route visible but inert as an underlay. During a transaction, exactly the `from` and `to` entries receive interactive roles.
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sheets retain their presentation after commit so they remain below the device safe top and can use content height or developer-defined snap points while the inert route beneath stays visible. Partial sheets animate their measured surface instead of a transparent viewport-sized wrapper, preserving motion from the first frame and keeping the underlay scale continuous across commit. Their scroll body hands top/down and bottom/up overscroll to sheet resizing while retaining ordinary content scrolling at interior positions. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry.
@@ -88,6 +88,8 @@ Ownership is explicit:
- A component gesture link owns drags that begin on that component away from the back edge.
- A sheet dismissal surface owns downward vertical drags.
Scrollable sheets choose content or sheet ownership from the gesture's initial directional intent and keep that owner until release. A content-owned gesture does not become a sheet gesture merely because it later reaches an edge or reverses; the next gesture can begin at that edge and resize the sheet. This prevents concurrent scrolling and avoids applying distance accumulated by content to sheet geometry.
Pointer capture keeps delivery stable after recognition. Recognizer state is detached synchronously at pointer release, before route loading or animation promises are awaited. This is essential: a delayed callback from one gesture must never erase the state of a newer gesture.
## History and cache are intentionally separate

View File

@@ -26,4 +26,4 @@ The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics
## Accessibility
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. A partial sheet's visible underlay is also inert and `aria-hidden`; it remains painted only to provide visual context beneath the sheet backdrop. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.

View File

@@ -0,0 +1,38 @@
# Vue Router guard/history experiment
This small app compares the browser's current URL and history position with
Vue Router's `currentRoute` while a real `beforeResolve` guard is pending.
## Run it
From the repository root:
```sh
npm --prefix experiments/router-guard-history run dev
```
Open the URL printed by Vite.
## Suggested tests
### Forward navigation
1. Enable **Pause the next `beforeResolve`**.
2. Click **Push Alpha**.
3. While paused, compare **Browser URL** with **router.currentRoute**.
4. Allow or reject the navigation.
For `router.push()`, both values remain on the outgoing route until the guard
allows confirmation.
### Back navigation
1. Push **Alpha**, then push **Beta**.
2. Enable **Pause the next `beforeResolve`**.
3. Click **Router Back**.
4. While paused, compare **Browser URL** with **router.currentRoute**.
5. Reject the navigation and watch the browser restore its previous history
entry, or repeat the test and allow it.
The event timeline separately records the raw browser `popstate`, Vue Router
guards, the URL, `currentRoute`, and the navigation result.

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Router Guard History Experiment</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{
"name": "router-guard-history-experiment",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue": "^3.5.39",
"vue-router": "^5.0.6",
"vue-tsc": "^3.3.5"
}
}

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { RouterView } from "vue-router";
import { experiment, record } from "./experiment";
import { router } from "./router";
const renderTick = ref(0);
const browserUrl = computed(() => {
renderTick.value;
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
});
const historyPosition = computed(() => {
renderTick.value;
return window.history.state?.position ?? "not available";
});
function refreshBrowserState() {
renderTick.value += 1;
}
window.addEventListener("popstate", refreshBrowserState);
router.afterEach(refreshBrowserState);
function navigate(kind: "push" | "replace", target: string) {
record(`action: ${kind}(${target})`, router.currentRoute.value.fullPath, {
target,
});
void router[kind](target).then(() => refreshBrowserState());
}
function back() {
record("action: router.back()", router.currentRoute.value.fullPath);
router.back();
}
function settle(allow: boolean) {
experiment.pendingGuard?.settle(allow);
}
function clearLog() {
experiment.logs.splice(0);
}
</script>
<template>
<main>
<header>
<p class="eyebrow">Vue Router experiment</p>
<h1>What changes while <code>beforeResolve</code> is pending?</h1>
<p class="intro">
Pause the next resolve guard, navigate, and compare the browser URL with
Vue Router's authoritative route.
</p>
</header>
<section class="status-grid">
<div>
<span>Browser URL</span>
<strong>{{ browserUrl }}</strong>
</div>
<div>
<span>router.currentRoute</span>
<strong>{{ router.currentRoute.value.fullPath }}</strong>
</div>
<div>
<span>history.state.position</span>
<strong>{{ historyPosition }}</strong>
</div>
</section>
<section class="controls">
<label class="hold-toggle">
<input v-model="experiment.holdNextResolve" type="checkbox" />
Pause the next <code>beforeResolve</code>
</label>
<div class="button-row">
<button @click="navigate('push', '/home')">Push Home</button>
<button @click="navigate('push', '/alpha')">Push Alpha</button>
<button @click="navigate('push', '/beta')">Push Beta</button>
<button @click="navigate('replace', '/alpha')">Replace Alpha</button>
<button class="back" @click="back">Router Back</button>
</div>
<div v-if="experiment.pendingGuard" class="gate">
<div>
<span>Navigation paused</span>
<strong>
{{ experiment.pendingGuard.from }} →
{{ experiment.pendingGuard.to }}
</strong>
</div>
<button class="allow" @click="settle(true)">Allow navigation</button>
<button class="reject" @click="settle(false)">Reject navigation</button>
</div>
</section>
<RouterView />
<section class="log-panel">
<div class="log-heading">
<div>
<p class="eyebrow">Event timeline</p>
<h2>Newest event first</h2>
</div>
<button class="quiet" @click="clearLog">Clear log</button>
</div>
<div class="log-table">
<div class="log-row log-labels">
<span>Time / event</span>
<span>Browser URL</span>
<span>Current route</span>
<span>Target / result</span>
</div>
<div v-for="entry in experiment.logs" :key="entry.id" class="log-row">
<span
><small>{{ entry.elapsed }}</small
>{{ entry.event }}</span
>
<code>{{ entry.browserUrl }}</code>
<code>{{ entry.currentRoute }}</code>
<span>
<code v-if="entry.target">{{ entry.target }}</code>
<small v-if="entry.detail">{{ entry.detail }}</small>
</span>
</div>
</div>
</section>
<aside>
<strong>Suggested test</strong>
Push Alpha, then Beta. Enable the pause checkbox and click Router Back.
While the guard is paused, inspect the two route values and then try both
rejection and approval.
</aside>
</main>
</template>

View File

@@ -0,0 +1,78 @@
import { reactive } from "vue";
import type {
NavigationFailure,
RouteLocationNormalizedLoaded,
} from "vue-router";
export interface ExperimentLog {
id: number;
elapsed: string;
event: string;
browserUrl: string;
currentRoute: string;
target?: string;
historyPosition?: unknown;
detail?: string;
}
interface PendingGuard {
to: string;
from: string;
settle(allow: boolean): void;
}
const startedAt = performance.now();
let sequence = 0;
export const experiment = reactive<{
holdNextResolve: boolean;
pendingGuard: PendingGuard | null;
logs: ExperimentLog[];
}>({
holdNextResolve: false,
pendingGuard: null,
logs: [],
});
function browserUrl() {
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
export function record(
event: string,
currentRoute: string,
options: { target?: string; detail?: string } = {},
) {
experiment.logs.unshift({
id: ++sequence,
elapsed: `${(performance.now() - startedAt).toFixed(1)} ms`,
event,
browserUrl: browserUrl(),
currentRoute,
target: options.target,
historyPosition: window.history.state?.position,
detail: options.detail,
});
}
export function waitForDecision(
to: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
) {
return new Promise<boolean>((resolve) => {
experiment.pendingGuard = {
to: to.fullPath,
from: from.fullPath,
settle(allow) {
experiment.pendingGuard = null;
resolve(allow);
},
};
});
}
export function describeFailure(failure?: NavigationFailure | void) {
return failure
? `navigation failure type ${String(failure.type)}`
: "navigation confirmed";
}

View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import { record } from "./experiment";
import { router } from "./router";
import "./style.css";
const app = createApp(App);
app.use(router);
app.mount("#app");
void router.isReady().then(() => {
record("router: ready", router.currentRoute.value.fullPath);
});

View File

@@ -0,0 +1,71 @@
import {
createRouter,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import ExperimentPage from "./views/ExperimentPage.vue";
import {
describeFailure,
experiment,
record,
waitForDecision,
} from "./experiment";
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/home" },
{
path: "/home",
component: ExperimentPage,
props: { title: "Home", color: "#6d5efc" },
},
{
path: "/alpha",
component: ExperimentPage,
props: { title: "Alpha", color: "#ef5da8" },
},
{
path: "/beta",
component: ExperimentPage,
props: { title: "Beta", color: "#2bbf8a" },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
// This listener sees the raw browser traversal. For a back/forward traversal,
// compare its URL with router.currentRoute in the event log.
window.addEventListener("popstate", () => {
record("window: popstate", router.currentRoute.value.fullPath);
});
router.beforeEach((to) => {
record("router: beforeEach", router.currentRoute.value.fullPath, {
target: to.fullPath,
});
});
router.beforeResolve(async (to, from) => {
record("router: beforeResolve entered", router.currentRoute.value.fullPath, {
target: to.fullPath,
});
if (!experiment.holdNextResolve) return;
experiment.holdNextResolve = false;
const allow = await waitForDecision(to, from);
record(
allow ? "beforeResolve: allowed" : "beforeResolve: rejected",
router.currentRoute.value.fullPath,
{ target: to.fullPath },
);
return allow || false;
});
router.afterEach((to, _from, failure) => {
record("router: afterEach", router.currentRoute.value.fullPath, {
target: to.fullPath,
detail: describeFailure(failure),
});
});

View File

@@ -0,0 +1,256 @@
:root {
color: #e8e9f3;
background: #11131a;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
border: 1px solid #3b3e4c;
border-radius: 0.65rem;
padding: 0.7rem 0.95rem;
color: #f5f5fa;
background: #252834;
cursor: pointer;
}
button:hover {
background: #303442;
}
main {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
header {
max-width: 760px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.75rem;
font-size: clamp(2rem, 5vw, 4.25rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
h2 {
margin-bottom: 0;
font-size: 1.1rem;
}
.eyebrow,
.status-grid span,
.gate span,
.route-page span,
small {
color: #979baa;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.intro {
color: #aeb1be;
font-size: 1.05rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 2rem 0 1rem;
}
.status-grid div,
.controls,
.log-panel,
aside {
border: 1px solid #292c38;
border-radius: 1rem;
background: #181a22;
}
.status-grid div {
display: grid;
gap: 0.35rem;
padding: 1rem;
}
.status-grid strong,
.gate strong {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.controls {
padding: 1rem;
}
.hold-toggle {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
}
.button-row,
.gate {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.back {
margin-left: auto;
}
.gate {
align-items: center;
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid #d6a53a;
border-radius: 0.75rem;
background: #2a2418;
}
.gate div {
display: grid;
gap: 0.25rem;
margin-right: auto;
}
.allow {
background: #176b4f;
}
.reject {
background: #7d2d46;
}
.route-page {
min-height: 170px;
display: grid;
align-content: center;
gap: 0.4rem;
margin: 1rem 0;
padding: 1.5rem;
overflow: hidden;
border-radius: 1rem;
background:
radial-gradient(circle at 90% 20%, var(--route-color), transparent 42%),
#1b1d27;
}
.route-page strong {
font-size: 2.5rem;
}
.log-panel {
overflow: hidden;
}
.log-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #292c38;
}
.log-heading .eyebrow {
margin-bottom: 0.2rem;
}
.quiet {
padding: 0.45rem 0.7rem;
background: transparent;
}
.log-table {
overflow-x: auto;
}
.log-row {
min-width: 850px;
display: grid;
grid-template-columns: 1.35fr 1fr 1fr 1.4fr;
gap: 1rem;
padding: 0.7rem 1rem;
border-top: 1px solid #232630;
font-size: 0.85rem;
}
.log-row > span {
display: grid;
align-content: start;
gap: 0.25rem;
}
.log-labels {
color: #7f8390;
border-top: 0;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
code {
color: #d6cffd;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
aside {
margin-top: 1rem;
padding: 1rem;
color: #aeb1be;
line-height: 1.55;
}
aside strong {
color: #f5f5fa;
}
@media (max-width: 720px) {
main {
padding-top: 1.5rem;
}
.status-grid {
grid-template-columns: 1fr;
}
.back {
margin-left: 0;
}
}

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{
title: string;
color: string;
}>();
</script>
<template>
<article class="route-page" :style="{ '--route-color': color }">
<span>Rendered route component</span>
<strong>{{ title }}</strong>
</article>
</template>

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/router-guard-history.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

View File

@@ -0,0 +1,6 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
});

View File

@@ -30,11 +30,33 @@ provide(routeLocationKey, scopedRoute);
const entry = computed(() =>
runtime.entries.value.find((candidate) => candidate.key === props.entryKey),
);
function isUnderlay(entryKey: string) {
let presentedEntry = runtime.entries.value.find(
(candidate) => candidate.key === runtime.activeKey.value,
);
const seen = new Set<string>();
while (
presentedEntry?.presentation === "sheet" &&
presentedEntry.underlayKey &&
!seen.has(presentedEntry.underlayKey)
) {
if (presentedEntry.underlayKey === entryKey) return true;
seen.add(presentedEntry.underlayKey);
presentedEntry = runtime.entries.value.find(
(candidate) => candidate.key === presentedEntry?.underlayKey,
);
}
return false;
}
const role = computed<NativeViewRole>(() => {
const transaction = runtime.transaction.value;
if (!entry.value) return "inactive";
if (!transaction)
if (!transaction) {
if (isUnderlay(entry.value.key)) return "underlay";
return entry.value.key === runtime.activeKey.value ? "active" : "inactive";
}
if (entry.value.key === transaction.fromKey) return "from";
if (entry.value.key === transaction.toKey) return "to";
return "inactive";

View File

@@ -0,0 +1,748 @@
import {
KeepAlive,
Suspense,
Teleport,
Transition,
computed,
createApp,
defineComponent,
h,
inject,
nextTick,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onMounted,
onUnmounted,
onUpdated,
provide,
reactive,
type App,
type Component,
type ComputedRef,
type InjectionKey,
type PropType,
type VNode,
} from "vue";
import {
RouterView,
createMemoryHistory,
createRouter,
useRoute,
useRouter,
type RouteLocationNormalizedLoaded,
type RouteRecordRaw,
type Router,
} from "vue-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNativeRouter } from "../runtime";
import type { NativeRouterRuntime } from "../types";
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeViewLifecycle,
} from "./lifecycle";
import NativeRouterView from "./NativeRouterView.vue";
const appValueKey: InjectionKey<string> = Symbol("compatibility-app-value");
const pageValueKey: InjectionKey<ComputedRef<string>> = Symbol(
"compatibility-page-value",
);
interface CompatibilityState {
revision: number;
kept: "a" | "b";
transitionVisible: boolean;
teleportVisible: boolean;
suspenseVisible: boolean;
}
interface CompatibilityFixtures {
state: CompatibilityState;
events: string[];
routes: RouteRecordRaw[];
resolveSuspense(): void;
}
interface MountedHarness {
app: App;
container: HTMLElement;
native: NativeRouterRuntime;
router: Router;
teleportTarget: HTMLElement;
}
const mountedApps: App[] = [];
beforeEach(() => {
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
vi.restoreAllMocks();
});
async function flushVue() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
async function mountHarness(
routes: RouteRecordRaw[],
initialRoute: string,
rootComponent?: Component,
): Promise<MountedHarness> {
const router = createRouter({ history: createMemoryHistory(), routes });
await router.push(initialRoute);
await router.isReady();
const native = createNativeRouter({ router, cache: { maxInactive: 8 } });
const Root = defineComponent({
name: "CompatibilityTestRoot",
setup: () => () => h(NativeRouterView),
});
const app = createApp(rootComponent ?? Root);
app.use(router);
app.use(native);
app.provide(appValueKey, "provided-by-app");
const teleportTarget = document.createElement("div");
teleportTarget.id = "compatibility-teleport";
document.body.append(teleportTarget);
const container = document.createElement("div");
document.body.append(container);
app.mount(container);
mountedApps.push(app);
await flushVue();
return { app, container, native, router, teleportTarget };
}
function expectHookOrder(events: string[], prefix: string, hooks: string[]) {
let previous = -1;
for (const hook of hooks) {
const index = events.indexOf(`${prefix}${hook}`);
expect(index, `${prefix}${hook} should have fired`).toBeGreaterThan(
previous,
);
previous = index;
}
}
function createCompatibilityFixtures(): CompatibilityFixtures {
const state = reactive<CompatibilityState>({
revision: 0,
kept: "a",
transitionVisible: true,
teleportVisible: true,
suspenseVisible: false,
});
const events: string[] = [];
let resolveSuspense!: () => void;
const suspenseReady = new Promise<void>((resolve) => {
resolveSuspense = resolve;
});
const OptionsLifecycleProbe = defineComponent({
name: "OptionsLifecycleProbe",
props: {
label: { type: String, required: true },
revision: { type: Number, required: true },
},
beforeCreate() {
events.push(`options:${this.label}:beforeCreate`);
},
created() {
events.push(`options:${this.label}:created`);
},
beforeMount() {
events.push(`options:${this.label}:beforeMount`);
},
mounted() {
events.push(`options:${this.label}:mounted`);
},
beforeUpdate() {
events.push(`options:${this.label}:beforeUpdate`);
},
updated() {
events.push(`options:${this.label}:updated`);
},
beforeUnmount() {
events.push(`options:${this.label}:beforeUnmount`);
},
unmounted() {
events.push(`options:${this.label}:unmounted`);
},
render() {
return h(
"span",
{ "data-testid": "options-revision" },
String(this.revision),
);
},
});
const CompositionLifecycleProbe = defineComponent({
name: "CompositionLifecycleProbe",
props: {
label: { type: String, required: true },
revision: { type: Number, required: true },
},
setup(props) {
const event = (hook: string) =>
events.push(`composition:${props.label}:${hook}`);
onBeforeMount(() => event("beforeMount"));
onMounted(() => event("mounted"));
onBeforeUpdate(() => event("beforeUpdate"));
onUpdated(() => event("updated"));
onBeforeUnmount(() => event("beforeUnmount"));
onUnmounted(() => event("unmounted"));
return () =>
h(
"span",
{ "data-testid": "composition-revision" },
String(props.revision),
);
},
});
const KeepAliveProbe = defineComponent({
name: "KeepAliveProbe",
props: {
label: { type: String, required: true },
variant: { type: String as PropType<"a" | "b">, required: true },
},
setup(props) {
const event = (hook: string) =>
events.push(`keep:${props.label}:${props.variant}:${hook}`);
onBeforeMount(() => event("beforeMount"));
onMounted(() => event("mounted"));
onActivated(() => event("activated"));
onDeactivated(() => event("deactivated"));
onBeforeUnmount(() => event("beforeUnmount"));
onUnmounted(() => event("unmounted"));
return () =>
h(
"span",
{ "data-testid": `keep-alive-${props.variant}` },
props.variant,
);
},
});
const NativeLifecycleProbe = defineComponent({
name: "NativeLifecycleProbe",
props: { label: { type: String, required: true } },
setup(props) {
const lifecycle = useNativeViewLifecycle();
const event = (hook: string) =>
events.push(`native:${props.label}:${hook}`);
onNativeViewActivate(() => event("activate"));
onNativeViewDeactivate(() => event("deactivate"));
onNativeViewShow(() => event("show"));
onNativeViewHide(() => event("hide"));
onNativeViewEvict((reason) => event(`evict:${String(reason)}`));
return () =>
h("span", {
"data-testid": "native-lifecycle",
"data-active": String(lifecycle.isActive.value),
"data-visible": String(lifecycle.isVisible.value),
});
},
});
const TeleportedProbe = defineComponent({
name: "TeleportedProbe",
setup() {
const route = useRoute();
const appValue = inject(appValueKey);
const pageValue = inject(pageValueKey);
return () =>
h(
"div",
{
"data-testid": "teleported-probe",
"data-route": route.fullPath,
"data-app-value": appValue,
"data-page-value": pageValue?.value,
},
`teleport revision ${state.revision}`,
);
},
});
const OptionsRouteProbe = defineComponent({
name: "OptionsRouteProbe",
render() {
const route = (
this as unknown as { $route: RouteLocationNormalizedLoaded }
).$route;
return h("span", { "data-testid": "options-route" }, route.fullPath);
},
});
const AsyncProbe = defineComponent({
name: "AsyncProbe",
async setup() {
await suspenseReady;
return () => h("span", { "data-testid": "suspense-ready" }, "ready");
},
});
const CompatibilityPage = defineComponent({
name: "CompatibilityPage",
props: { id: String },
setup(props) {
const route = useRoute();
const router = useRouter();
const appValue = inject(appValueKey);
provide(
pageValueKey,
computed(() => `page:${route.fullPath}`),
);
const transitionEvent = (hook: string) =>
events.push(`transition:${route.fullPath}:${hook}`);
return () => {
const label = route.fullPath;
return h("article", { "data-testid": "compatibility-page" }, [
h("span", { "data-testid": "route-full-path" }, route.fullPath),
h("span", { "data-testid": "route-name" }, String(route.name)),
h("span", { "data-testid": "route-param" }, String(route.params.id)),
h("span", { "data-testid": "route-prop" }, String(props.id)),
h("span", { "data-testid": "route-query" }, String(route.query.tab)),
h("span", { "data-testid": "route-hash" }, route.hash),
h(
"span",
{ "data-testid": "route-meta" },
String(route.meta.section),
),
h(
"span",
{ "data-testid": "route-matched" },
route.matched.map((record) => String(record.name)).join(","),
),
h(
"span",
{ "data-testid": "router-current-route" },
router.currentRoute.value.fullPath,
),
h("span", { "data-testid": "injected-app-value" }, appValue),
h(OptionsRouteProbe),
h(OptionsLifecycleProbe, { label, revision: state.revision }),
h(CompositionLifecycleProbe, { label, revision: state.revision }),
h(KeepAlive, null, {
default: () =>
h(KeepAliveProbe, {
key: state.kept,
label,
variant: state.kept,
}),
}),
h(
Transition,
{
css: false,
onBeforeEnter: () => transitionEvent("beforeEnter"),
onEnter: (_element: Element, done: () => void) => {
transitionEvent("enter");
done();
},
onAfterEnter: () => transitionEvent("afterEnter"),
onBeforeLeave: () => transitionEvent("beforeLeave"),
onLeave: (_element: Element, done: () => void) => {
transitionEvent("leave");
done();
},
onAfterLeave: () => transitionEvent("afterLeave"),
},
{
default: () =>
state.transitionVisible
? h(
"div",
{ "data-testid": "transition-child" },
String(state.revision),
)
: null,
},
),
h(NativeLifecycleProbe, { label }),
state.teleportVisible
? h(Teleport, { to: "#compatibility-teleport" }, h(TeleportedProbe))
: null,
state.suspenseVisible
? h(
Suspense,
{ timeout: 0 },
{
default: () => h(AsyncProbe),
fallback: () =>
h(
"span",
{ "data-testid": "suspense-fallback" },
"loading",
),
},
)
: null,
]);
};
},
});
const UsersLayout = defineComponent({
name: "UsersLayout",
setup: () => () =>
h("div", { "data-testid": "users-layout" }, h(RouterView)),
});
const routes: RouteRecordRaw[] = [
{
path: "/users",
name: "users",
component: UsersLayout,
children: [
{
path: ":id",
name: "user",
component: CompatibilityPage,
props: true,
meta: { section: "people" },
},
],
},
{
path: "/other",
name: "other",
component: CompatibilityPage,
meta: { section: "other" },
},
];
return { state, events, routes, resolveSuspense };
}
function textWithin(root: ParentNode, testId: string) {
return root.querySelector(`[data-testid="${testId}"]`)?.textContent;
}
describe("NativeRouterView Vue compatibility", () => {
it("scopes params, query, hash, metadata, route props, and nested matches to a preview", async () => {
const fixtures = createCompatibilityFixtures();
const { container, native, router, teleportTarget } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary#top",
);
const active = container.querySelector('[data-native-role="active"]')!;
expect(textWithin(active, "route-full-path")).toBe(
"/users/one?tab=summary#top",
);
expect(textWithin(active, "route-name")).toBe("user");
expect(textWithin(active, "route-param")).toBe("one");
expect(textWithin(active, "route-prop")).toBe("one");
expect(textWithin(active, "route-query")).toBe("summary");
expect(textWithin(active, "route-hash")).toBe("#top");
expect(textWithin(active, "route-meta")).toBe("people");
expect(textWithin(active, "route-matched")).toBe("users,user");
expect(textWithin(active, "options-route")).toBe(
"/users/one?tab=summary#top",
);
await native.beginInteractive("push", "/users/two?tab=activity#details");
await flushVue();
expect(router.currentRoute.value.fullPath).toBe(
"/users/one?tab=summary#top",
);
const preview = container.querySelector('[data-native-role="to"]')!;
expect(textWithin(preview, "route-full-path")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(preview, "route-param")).toBe("two");
expect(textWithin(preview, "route-prop")).toBe("two");
expect(textWithin(preview, "route-query")).toBe("activity");
expect(textWithin(preview, "route-hash")).toBe("#details");
expect(textWithin(preview, "options-route")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(preview, "router-current-route")).toBe(
"/users/one?tab=summary#top",
);
expect(
teleportTarget.querySelector(
'[data-route="/users/two?tab=activity#details"]',
),
).toMatchObject({
dataset: {
appValue: "provided-by-app",
pageValue: "page:/users/two?tab=activity#details",
},
});
await native.cancelInteractive();
await flushVue();
expect(container.querySelector('[data-native-role="to"]')).toBeNull();
expect(
teleportTarget.querySelector(
'[data-route="/users/two?tab=activity#details"]',
),
).toBeNull();
await native.push("/users/two?tab=activity#details", {
presentation: "none",
});
await flushVue();
const committed = container.querySelector('[data-native-role="active"]')!;
expect(textWithin(committed, "route-full-path")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(committed, "options-route")).toBe(
"/users/two?tab=activity#details",
);
});
it("runs Options API and Composition API lifecycle hooks through updates and eviction", async () => {
const fixtures = createCompatibilityFixtures();
const { native } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary",
);
const optionsPrefix = "options:/users/one?tab=summary:";
const compositionPrefix = "composition:/users/one?tab=summary:";
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeCreate",
"created",
"beforeMount",
"mounted",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeMount",
"mounted",
]);
expect(fixtures.events).toContain("native:/users/one?tab=summary:activate");
expect(fixtures.events).toContain("native:/users/one?tab=summary:show");
fixtures.state.revision += 1;
await flushVue();
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeUpdate",
"updated",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeUpdate",
"updated",
]);
await native.push("/other", { presentation: "none" });
await flushVue();
expect(fixtures.events).not.toContain(`${optionsPrefix}beforeUnmount`);
expect(fixtures.events).toContain(
"native:/users/one?tab=summary:deactivate",
);
expect(fixtures.events).toContain("native:/users/one?tab=summary:hide");
expect(fixtures.events).toContain("native:/other:activate");
expect(fixtures.events).toContain("native:/other:show");
native.trimCache({ includePinned: true });
await flushVue();
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeUnmount",
"unmounted",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeUnmount",
"unmounted",
]);
expect(fixtures.events).toContain(
"native:/users/one?tab=summary:evict:trimmed",
);
});
it("preserves Vue KeepAlive activation and deactivation semantics", async () => {
const fixtures = createCompatibilityFixtures();
await mountHarness(fixtures.routes, "/users/one");
expectHookOrder(fixtures.events, "keep:/users/one:a:", [
"beforeMount",
"mounted",
"activated",
]);
fixtures.state.kept = "b";
await flushVue();
expect(fixtures.events).toContain("keep:/users/one:a:deactivated");
expectHookOrder(fixtures.events, "keep:/users/one:b:", [
"beforeMount",
"mounted",
"activated",
]);
fixtures.state.kept = "a";
await flushVue();
expect(fixtures.events).toContain("keep:/users/one:b:deactivated");
expect(
fixtures.events.filter(
(event) => event === "keep:/users/one:a:activated",
),
).toHaveLength(2);
expect(
fixtures.events.filter((event) => event === "keep:/users/one:a:mounted"),
).toHaveLength(1);
});
it("supports the standard RouterView Transition and KeepAlive scoped-slot pattern", async () => {
const fixtures = createCompatibilityFixtures();
const WrappedRouterView = defineComponent({
name: "WrappedRouterView",
setup() {
return () =>
h(NativeRouterView, null, {
default: ({
Component: RoutedComponent,
}: {
Component: VNode | null;
}) =>
h(
Transition,
{ css: false },
{
default: () =>
h(KeepAlive, null, {
default: () => RoutedComponent,
}),
},
),
});
},
});
const { container, native } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary",
WrappedRouterView,
);
expect(
textWithin(
container.querySelector('[data-native-role="active"]')!,
"options-route",
),
).toBe("/users/one?tab=summary");
await native.beginInteractive("push", "/users/two?tab=activity");
await flushVue();
const preview = container.querySelector('[data-native-role="to"]')!;
expect(textWithin(preview, "route-full-path")).toBe(
"/users/two?tab=activity",
);
expect(textWithin(preview, "options-route")).toBe(
"/users/two?tab=activity",
);
await native.cancelInteractive();
});
it("runs JavaScript Transition enter and leave hooks", async () => {
const fixtures = createCompatibilityFixtures();
const { container } = await mountHarness(fixtures.routes, "/users/one");
expect(
container.querySelector('[data-testid="transition-child"]'),
).not.toBeNull();
fixtures.state.transitionVisible = false;
await flushVue();
expect(
container.querySelector('[data-testid="transition-child"]'),
).toBeNull();
expectHookOrder(fixtures.events, "transition:/users/one:", [
"beforeLeave",
"leave",
"afterLeave",
]);
fixtures.state.transitionVisible = true;
await flushVue();
expect(
container.querySelector('[data-testid="transition-child"]'),
).not.toBeNull();
expectHookOrder(fixtures.events, "transition:/users/one:", [
"beforeEnter",
"enter",
"afterEnter",
]);
});
it("keeps Teleport reactive, preserves injection, and removes teleported content on eviction", async () => {
const fixtures = createCompatibilityFixtures();
const { native, teleportTarget } = await mountHarness(
fixtures.routes,
"/users/one",
);
const initial = teleportTarget.querySelector<HTMLElement>(
'[data-route="/users/one"]',
)!;
expect(initial.textContent).toBe("teleport revision 0");
expect(initial.dataset.appValue).toBe("provided-by-app");
expect(initial.dataset.pageValue).toBe("page:/users/one");
fixtures.state.revision += 1;
await flushVue();
expect(initial.textContent).toBe("teleport revision 1");
await native.push("/other", { presentation: "none" });
await flushVue();
expect(
teleportTarget.querySelector('[data-route="/users/one"]'),
).not.toBeNull();
expect(
teleportTarget.querySelector('[data-route="/other"]'),
).not.toBeNull();
native.trimCache({ includePinned: true });
await flushVue();
expect(
teleportTarget.querySelector('[data-route="/users/one"]'),
).toBeNull();
expect(
teleportTarget.querySelector('[data-route="/other"]'),
).not.toBeNull();
});
it("renders Suspense fallback and resolved async content inside a native view", async () => {
const fixtures = createCompatibilityFixtures();
const { container } = await mountHarness(fixtures.routes, "/users/one");
fixtures.state.suspenseVisible = true;
await flushVue();
expect(
container.querySelector('[data-testid="suspense-fallback"]')?.textContent,
).toBe("loading");
expect(
container.querySelector('[data-testid="suspense-ready"]'),
).toBeNull();
fixtures.resolveSuspense();
await flushVue();
expect(
container.querySelector('[data-testid="suspense-fallback"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="suspense-ready"]')?.textContent,
).toBe("ready");
});
});

View File

@@ -22,6 +22,28 @@ function roleFor(entry: NativeViewEntry) {
return interactiveRole(entry, runtime);
}
function isUnderlay(
entry: NativeViewEntry,
nativeRuntime: NativeRouterRuntime,
) {
let presentedEntry = nativeRuntime.entries.value.find(
(candidate) => candidate.key === nativeRuntime.activeKey.value,
);
const seen = new Set<string>();
while (
presentedEntry?.presentation === "sheet" &&
presentedEntry.underlayKey &&
!seen.has(presentedEntry.underlayKey)
) {
if (presentedEntry.underlayKey === entry.key) return true;
seen.add(presentedEntry.underlayKey);
presentedEntry = nativeRuntime.entries.value.find(
(candidate) => candidate.key === presentedEntry?.underlayKey,
);
}
return false;
}
function layerStyleFor(entry: NativeViewEntry) {
const currentTransaction = transaction.value;
if (!currentTransaction) return undefined;
@@ -42,8 +64,10 @@ function interactiveRole(
nativeRuntime: NativeRouterRuntime,
): NativeViewRole {
const currentTransaction = nativeRuntime.transaction.value;
if (!currentTransaction)
if (!currentTransaction) {
if (isUnderlay(entry, nativeRuntime)) return "underlay";
return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive";
}
if (entry.key === currentTransaction.fromKey) return "from";
if (entry.key === currentTransaction.toKey) return "to";
return "inactive";
@@ -72,24 +96,29 @@ function interactiveRole(
:style="layerStyleFor(entry)"
:data-native-role="roleFor(entry)"
:data-native-route="entry.route.fullPath"
:data-native-view-presentation="entry.presentation"
:data-native-presentation="transaction?.presentation"
:data-native-direction="transaction?.direction"
:inert="roleFor(entry) === 'inactive' ? true : undefined"
:aria-hidden="roleFor(entry) === 'inactive' ? 'true' : undefined"
:inert="
roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay'
? true
: undefined
"
:aria-hidden="
roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay'
? 'true'
: undefined
"
>
<RouterView v-slot="{ Component, route }" :route="entry.route">
<NativeRouteScope :route="route" :entry-key="entry.key">
<slot
v-if="slots.default"
:Component="Component"
:route="route"
:entry="entry"
/>
<NativeRouteScope
v-else-if="Component"
:route="route"
:entry-key="entry.key"
>
<component :is="Component" />
<component v-else-if="Component" :is="Component" />
</NativeRouteScope>
</RouterView>
</section>

View File

@@ -0,0 +1,182 @@
import { createApp, defineComponent, h, nextTick } from "vue";
import {
createMemoryHistory,
createRouter,
type RouteRecordRaw,
} from "vue-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNativeRouter } from "../runtime";
import NativeRouterView from "./NativeRouterView.vue";
import NativeSheet from "./NativeSheet.vue";
import {
adjacentSheetBreakpoint,
nearestSheetBreakpoint,
normalizeSheetBreakpoints,
} from "./sheet";
const mountedApps: ReturnType<typeof createApp>[] = [];
beforeEach(() => {
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
vi.restoreAllMocks();
});
async function flushVue() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("sheet breakpoints", () => {
it("normalizes, finds, and moves between fractional breakpoints", () => {
expect(normalizeSheetBreakpoints([1, 0.5, 0.5, -1, 1.2, 0.25])).toEqual([
0.25, 0.5, 1,
]);
expect(nearestSheetBreakpoint([0.25, 0.5, 1], 0.62)).toBe(0.5);
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "up")).toBe(1);
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "down")).toBe(0.25);
});
});
describe("NativeSheet", () => {
it("retains a visible inert underlay and exposes keyboard snap points", async () => {
const BasePage = defineComponent({
name: "BasePage",
setup: () => () => h("main", { "data-testid": "base" }, "Base"),
});
const SheetPage = defineComponent({
name: "SheetPage",
setup: () => () =>
h(
NativeSheet,
{ breakpoints: [0.5, 1], initialBreakpoint: 0.5 },
{ default: () => h("p", "Sheet content") },
),
});
const routes: RouteRecordRaw[] = [
{ path: "/", component: BasePage },
{
path: "/sheet",
component: SheetPage,
meta: { native: { presentation: "sheet", parent: "/" } },
},
];
const router = createRouter({ history: createMemoryHistory(), routes });
await router.push("/");
await router.isReady();
const native = createNativeRouter({ router });
const Root = defineComponent({
setup: () => () => h(NativeRouterView),
});
const app = createApp(Root);
app.use(router);
app.use(native);
const container = document.createElement("div");
document.body.append(container);
app.mount(container);
mountedApps.push(app);
await flushVue();
expect(await native.present("/sheet", "sheet")).toBe(true);
await flushVue();
const active = container.querySelector('[data-native-role="active"]');
const underlay = container.querySelector('[data-native-role="underlay"]');
const surface = container.querySelector<HTMLElement>("[data-native-sheet]");
const handle = container.querySelector<HTMLElement>(".nvr-sheet__handle");
const root = container.querySelector<HTMLElement>(".nvr-sheet");
const body = container.querySelector<HTMLElement>(".nvr-sheet__body");
expect(active?.getAttribute("data-native-view-presentation")).toBe("sheet");
expect(active?.hasAttribute("data-native-sheet-surface")).toBe(true);
expect(underlay?.hasAttribute("inert")).toBe(true);
expect(underlay?.getAttribute("aria-hidden")).toBe("true");
expect(surface?.dataset.nativeSheetMode).toBe("breakpoints");
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
handle?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
if (!root || !body) throw new Error("Sheet body did not render");
Object.defineProperty(root, "clientHeight", {
configurable: true,
value: 1_000,
});
Object.defineProperties(body, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 600 },
});
handle?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
const dispatchContentPointer = (
type: "pointerdown" | "pointermove" | "pointerup",
pointerId: number,
clientY: number,
) =>
body.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
clientX: 20,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
}),
);
// Once content wins a gesture, reaching an edge later must not reinterpret
// all of the gesture's accumulated distance as sheet movement.
body.scrollTop = 100;
dispatchContentPointer("pointerdown", 2, 220);
dispatchContentPointer("pointermove", 2, 170);
body.scrollTop = 300;
dispatchContentPointer("pointermove", 2, 120);
dispatchContentPointer("pointerup", 2, 120);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
// Reversing a content-owned gesture at the other boundary keeps content
// ownership until release instead of moving both systems.
body.scrollTop = 100;
dispatchContentPointer("pointerdown", 3, 180);
dispatchContentPointer("pointermove", 3, 120);
body.scrollTop = 0;
dispatchContentPointer("pointermove", 3, 260);
dispatchContentPointer("pointerup", 3, 260);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
body.scrollTop = 300;
dispatchContentPointer("pointerdown", 4, 220);
dispatchContentPointer("pointermove", 4, 120);
dispatchContentPointer("pointerup", 4, 120);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
body.scrollTop = 0;
dispatchContentPointer("pointerdown", 5, 120);
dispatchContentPointer("pointermove", 5, 220);
dispatchContentPointer("pointerup", 5, 220);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
expect(await native.dismiss()).toBe(true);
await flushVue();
expect(router.currentRoute.value.path).toBe("/");
});
});

View File

@@ -0,0 +1,562 @@
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
watch,
type PropType,
} from "vue";
import { useNativeRouter } from "./lifecycle";
import {
adjacentSheetBreakpoint,
nearestSheetBreakpoint,
normalizeSheetBreakpoints,
} from "./sheet";
defineOptions({ name: "NativeSheet", inheritAttrs: false });
const props = defineProps({
/** Fractions of the available, safe-area-contained route height. */
breakpoints: {
type: Array as PropType<number[]>,
default: () => [],
},
/** Initial fraction. The nearest declared breakpoint is used. */
initialBreakpoint: Number,
/** Current fraction for v-model. */
modelValue: Number,
dismissible: { type: Boolean, default: true },
backdropDismiss: { type: Boolean, default: true },
showHandle: { type: Boolean, default: true },
ariaLabel: { type: String, default: "Sheet" },
});
const emit = defineEmits<{
"update:modelValue": [value: number];
"breakpoint-change": [value: number];
dismiss: [];
}>();
const attrs = useAttrs();
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
const surface = ref<HTMLElement | null>(null);
const handle = ref<HTMLElement | null>(null);
const body = ref<HTMLElement | null>(null);
const content = ref<HTMLElement | null>(null);
const height = ref<number>();
const activeBreakpoint = ref<number>();
const dragging = ref(false);
const ready = ref(false);
const dismissing = ref(false);
const normalizedBreakpoints = computed(() =>
normalizeSheetBreakpoints(props.breakpoints),
);
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
const heightStyle = computed(() =>
height.value === undefined ? undefined : `${height.value}px`,
);
const breakpointLabel = computed(() =>
activeBreakpoint.value === undefined
? "content"
: String(activeBreakpoint.value),
);
let resizeObserver: ResizeObserver | undefined;
let owningLayer: HTMLElement | null = null;
let pointerId = -1;
let touchId = -1;
let candidateX = 0;
let candidateY = 0;
let candidateTime = 0;
let startY = 0;
let startHeight = 0;
let lastY = 0;
let lastTime = 0;
let velocity = 0;
let moved = false;
let wheelDistance = 0;
let wheelDirection: "up" | "down" | undefined;
let wheelOwner: "content" | "sheet" | undefined;
let wheelLocked = false;
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;
function availableHeight() {
return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
}
function bodyPadding() {
if (!body.value) return 0;
const style = getComputedStyle(body.value);
return (
(Number.parseFloat(style.paddingTop) || 0) +
(Number.parseFloat(style.paddingBottom) || 0)
);
}
function naturalHeight() {
return Math.min(
availableHeight(),
Math.max(
1,
(handle.value?.offsetHeight ?? 0) +
(content.value?.scrollHeight ?? 0) +
bodyPadding(),
),
);
}
function requestedBreakpoint() {
const points = normalizedBreakpoints.value;
if (!points.length) return undefined;
return nearestSheetBreakpoint(
points,
props.modelValue ??
activeBreakpoint.value ??
props.initialBreakpoint ??
points[0]!,
);
}
function measure() {
const breakpoint = requestedBreakpoint();
activeBreakpoint.value = breakpoint;
height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
}
function setBreakpoint(breakpoint: number, notify = true) {
const nearest = nearestSheetBreakpoint(
normalizedBreakpoints.value,
breakpoint,
);
if (nearest === undefined) return measure();
activeBreakpoint.value = nearest;
height.value = availableHeight() * nearest;
if (notify) {
emit("update:modelValue", nearest);
emit("breakpoint-change", nearest);
}
}
async function dismissSheet() {
if (!props.dismissible || dismissing.value) return false;
dismissing.value = true;
emit("dismiss");
const dismissed = await runtime.dismiss();
dismissing.value = false;
if (!dismissed) measure();
return dismissed;
}
function beginDrag(clientY: number, timestamp: number) {
startY = lastY = clientY;
lastTime = timestamp;
startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
velocity = 0;
moved = false;
dragging.value = true;
}
function updateDrag(clientY: number, timestamp: number) {
const delta = clientY - startY;
moved ||= Math.abs(delta) > 3;
const minimum = usesBreakpoints.value
? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
: naturalHeight() * 0.55;
height.value = Math.max(
Math.min(72, availableHeight()),
Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
);
const elapsed = Math.max(8, timestamp - lastTime);
velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
lastY = clientY;
lastTime = timestamp;
}
async function finishDrag() {
dragging.value = false;
if (!moved) return measure();
const currentHeight = height.value ?? startHeight;
const currentFraction = currentHeight / availableHeight();
const points = normalizedBreakpoints.value;
const smallest = points[0];
if (!points.length) {
if (
props.dismissible &&
(currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
)
return void (await dismissSheet());
return measure();
}
if (
props.dismissible &&
smallest !== undefined &&
currentFraction < smallest * 0.72
)
return void (await dismissSheet());
const active = activeBreakpoint.value ?? smallest!;
if (Math.abs(velocity) > 0.65) {
const direction = velocity < 0 ? "up" : "down";
const adjacent = adjacentSheetBreakpoint(points, active, direction);
if (adjacent !== undefined) return setBreakpoint(adjacent);
if (direction === "down" && props.dismissible)
return void (await dismissSheet());
}
setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
}
function cancelDrag() {
dragging.value = false;
measure();
}
function pointerDown(event: PointerEvent) {
if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
event.preventDefault();
event.stopPropagation();
contentGestureOwner = "sheet";
pointerId = event.pointerId;
beginDrag(event.clientY, event.timeStamp);
handle.value?.setPointerCapture?.(pointerId);
}
function pointerMove(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY, event.timeStamp);
}
async function pointerUp(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
handle.value?.releasePointerCapture?.(pointerId);
pointerId = -1;
await finishDrag();
contentGestureOwner = undefined;
}
function pointerCancel(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
pointerId = -1;
cancelDrag();
contentGestureOwner = undefined;
}
function atTop() {
return (body.value?.scrollTop ?? 0) <= 1;
}
function atBottom() {
const element = body.value;
if (!element) return true;
return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
}
function canResizeFromContent(direction: "up" | "down") {
const points = normalizedBreakpoints.value;
const active = activeBreakpoint.value;
if (direction === "up")
return Boolean(
atBottom() &&
active !== undefined &&
adjacentSheetBreakpoint(points, active, "up") !== undefined,
);
return Boolean(
atTop() &&
(props.dismissible ||
(active !== undefined &&
adjacentSheetBreakpoint(points, active, "down") !== undefined)),
);
}
function contentGestureDirection(deltaY: number) {
return deltaY < 0 ? ("up" as const) : ("down" as const);
}
function shouldClaimContentGesture(deltaX: number, deltaY: number) {
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return false;
return canResizeFromContent(contentGestureDirection(deltaY));
}
function contentPointerDown(event: PointerEvent) {
if (
event.pointerType === "touch" ||
!event.isPrimary ||
event.button !== 0 ||
dismissing.value
)
return;
pointerId = event.pointerId;
contentGestureOwner = "pending";
candidateX = event.clientX;
candidateY = event.clientY;
candidateTime = event.timeStamp;
}
function contentPointerMove(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (contentGestureOwner === "pending") {
const deltaX = event.clientX - candidateX;
const deltaY = event.clientY - candidateY;
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return;
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
? "sheet"
: "content";
if (contentGestureOwner === "content") return;
beginDrag(candidateY, candidateTime);
body.value?.setPointerCapture?.(pointerId);
}
if (contentGestureOwner !== "sheet") return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY, event.timeStamp);
}
async function contentPointerUp(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
body.value?.releasePointerCapture?.(pointerId);
pointerId = -1;
if (dragging.value) await finishDrag();
contentGestureOwner = undefined;
}
function contentPointerCancel(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
pointerId = -1;
if (dragging.value) cancelDrag();
contentGestureOwner = undefined;
}
function trackedTouch(list: TouchList) {
return [...list].find((touch) => touch.identifier === touchId);
}
function contentTouchStart(event: TouchEvent) {
if (touchId !== -1 || dismissing.value) return;
const touch = event.changedTouches[0];
if (!touch) return;
touchId = touch.identifier;
contentGestureOwner = "pending";
candidateX = touch.clientX;
candidateY = touch.clientY;
candidateTime = event.timeStamp;
}
function contentTouchMove(event: TouchEvent) {
const touch = trackedTouch(event.touches);
if (!touch) return;
if (contentGestureOwner === "pending") {
const deltaX = touch.clientX - candidateX;
const deltaY = touch.clientY - candidateY;
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return;
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
? "sheet"
: "content";
if (contentGestureOwner === "content") return;
beginDrag(candidateY, candidateTime);
}
if (contentGestureOwner !== "sheet") return;
if (event.cancelable) event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientY, event.timeStamp);
}
async function contentTouchEnd(event: TouchEvent) {
if (!trackedTouch(event.changedTouches)) return;
touchId = -1;
if (dragging.value) await finishDrag();
contentGestureOwner = undefined;
}
function contentTouchCancel(event: TouchEvent) {
if (!trackedTouch(event.changedTouches)) return;
touchId = -1;
if (dragging.value) cancelDrag();
contentGestureOwner = undefined;
}
function resetWheelHandoff() {
if (wheelResetTimer) clearTimeout(wheelResetTimer);
wheelDistance = 0;
wheelDirection = undefined;
wheelOwner = undefined;
wheelLocked = false;
wheelResetTimer = undefined;
}
function contentWheel(event: WheelEvent) {
const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
if (!event.deltaY) return;
if (wheelResetTimer) clearTimeout(wheelResetTimer);
wheelResetTimer = setTimeout(resetWheelHandoff, 240);
if (!wheelOwner)
wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
if (wheelOwner === "content") return;
event.preventDefault();
event.stopPropagation();
if (wheelDirection !== direction) wheelDistance = 0;
wheelDirection = direction;
wheelDistance += Math.abs(event.deltaY);
if (!wheelLocked && wheelDistance >= 48) {
const active = activeBreakpoint.value;
const adjacent =
active === undefined
? undefined
: adjacentSheetBreakpoint(
normalizedBreakpoints.value,
active,
direction,
);
if (adjacent !== undefined) setBreakpoint(adjacent);
else if (direction === "down") void dismissSheet();
wheelLocked = true;
}
}
function keyDown(event: KeyboardEvent) {
const points = normalizedBreakpoints.value;
const current = activeBreakpoint.value;
if (event.key === "Escape") {
event.preventDefault();
void dismissSheet();
return;
}
if (!points.length || current === undefined) return;
const direction =
event.key === "ArrowUp"
? "up"
: event.key === "ArrowDown"
? "down"
: undefined;
const target =
event.key === "Home"
? points[0]
: event.key === "End"
? points.at(-1)
: direction
? adjacentSheetBreakpoint(points, current, direction)
: undefined;
if (target !== undefined) {
event.preventDefault();
setBreakpoint(target);
} else if (direction === "down" && props.dismissible) {
event.preventDefault();
void dismissSheet();
}
}
function surfaceKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || event.target === handle.value) return;
event.preventDefault();
void dismissSheet();
}
watch(
() => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
() => {
if (!dragging.value) void nextTick(measure);
},
{ deep: true },
);
onMounted(() => {
owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
if (!dragging.value) measure();
});
if (root.value) resizeObserver.observe(root.value);
if (content.value) resizeObserver.observe(content.value);
}
void nextTick(() => {
measure();
requestAnimationFrame(() => (ready.value = true));
});
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
if (wheelResetTimer) clearTimeout(wheelResetTimer);
if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
});
</script>
<template>
<div ref="root" v-bind="attrs" class="nvr-sheet">
<button
v-if="backdropDismiss && dismissible"
class="nvr-sheet__backdrop"
type="button"
aria-label="Close sheet"
@click="dismissSheet"
/>
<div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
<section
ref="surface"
class="nvr-sheet__surface"
:style="{ height: heightStyle }"
role="dialog"
aria-modal="true"
:aria-label="ariaLabel"
data-native-sheet
:data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
:data-native-sheet-breakpoint="breakpointLabel"
:data-native-sheet-dragging="String(dragging)"
:data-native-sheet-ready="String(ready)"
@keydown="surfaceKeyDown"
>
<div
v-if="showHandle"
ref="handle"
class="nvr-sheet__handle"
:role="usesBreakpoints ? 'slider' : undefined"
:tabindex="0"
aria-label="Resize or dismiss sheet"
aria-orientation="vertical"
:aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
:aria-valuemax="
usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
"
:aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="pointerCancel"
@keydown="keyDown"
>
<slot name="handle">
<span aria-hidden="true" />
</slot>
</div>
<div
ref="body"
class="nvr-sheet__body"
@pointerdown="contentPointerDown"
@pointermove="contentPointerMove"
@pointerup="contentPointerUp"
@pointercancel="contentPointerCancel"
@touchstart="contentTouchStart"
@touchmove="contentTouchMove"
@touchend="contentTouchEnd"
@touchcancel="contentTouchCancel"
@wheel="contentWheel"
>
<div ref="content" class="nvr-sheet__content"><slot /></div>
</div>
</section>
</div>
</template>

View File

@@ -3,7 +3,13 @@ export { default as NativeGestureLink } from "./NativeGestureLink.vue";
export { default as NativeLink } from "./NativeLink.vue";
export { default as NativeNavigator } from "./NativeNavigator.vue";
export { default as NativeRouterView } from "./NativeRouterView.vue";
export { default as NativeSheet } from "./NativeSheet.vue";
export { navigationOptionsFromElement } from "./gestures";
export {
adjacentSheetBreakpoint,
nearestSheetBreakpoint,
normalizeSheetBreakpoints,
} from "./sheet";
export {
onNativeViewActivate,
onNativeViewDeactivate,

View File

@@ -0,0 +1,28 @@
export function normalizeSheetBreakpoints(values: readonly number[]) {
return [...new Set(values.filter((value) => value > 0 && value <= 1))].sort(
(left, right) => left - right,
);
}
export function nearestSheetBreakpoint(
breakpoints: readonly number[],
value: number,
) {
return breakpoints.reduce<number | undefined>((nearest, candidate) => {
if (nearest === undefined) return candidate;
return Math.abs(candidate - value) < Math.abs(nearest - value)
? candidate
: nearest;
}, undefined);
}
export function adjacentSheetBreakpoint(
breakpoints: readonly number[],
current: number,
direction: "up" | "down",
) {
const normalized = normalizeSheetBreakpoints(breakpoints);
if (direction === "up")
return normalized.find((value) => value > current + 0.001);
return [...normalized].reverse().find((value) => value < current - 0.001);
}

View File

@@ -11,7 +11,7 @@ import { createNativeNavigationProfiler } from "./profiler";
const Page = defineComponent({ template: "<div>page</div>" });
async function harness(blockB: boolean | "redirect" = false) {
async function harness(blockB: boolean | "redirect" = false, maxInactive = 2) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -61,7 +61,7 @@ async function harness(blockB: boolean | "redirect" = false) {
);
await router.push("/a");
await router.isReady();
const native = createNativeRouter({ router, cache: { maxInactive: 2 } });
const native = createNativeRouter({ router, cache: { maxInactive } });
const app = createApp(Page);
app.use(router);
app.use(native);
@@ -433,8 +433,15 @@ describe("native router transactions", () => {
it("dismisses with the presented route animation regardless of the route below it", async () => {
const { native } = await harness();
await native.push("/b");
const underlayKey = native.activeKey.value;
await native.present("/modal", "sheet");
expect(
native.entries.value.find(
(entry) => entry.key === native.activeKey.value,
),
).toMatchObject({ presentation: "sheet", underlayKey });
await native.beginInteractive("dismiss");
expect(native.transaction.value).toMatchObject({
direction: "back",
@@ -443,6 +450,19 @@ describe("native router transactions", () => {
await native.cancelInteractive();
});
it("protects a visible sheet underlay even when inactive caching is disabled", async () => {
const { native } = await harness(false, 0);
await native.push("/b");
const underlayKey = native.activeKey.value;
await native.present("/modal", "sheet");
expect(
native.entries.value.find((entry) => entry.key === underlayKey),
).toMatchObject({ mounted: true, status: "inactive" });
await native.dismiss();
});
it("refuses to overlap a second transaction with an active gesture", async () => {
const { native } = await harness();
const first = await native.beginInteractive("push", "/b");

View File

@@ -9,6 +9,7 @@ import {
import {
isNavigationFailure,
loadRouteLocation,
routeLocationKey,
START_LOCATION,
type NavigationFailure,
type RouteLocationNormalizedLoaded,
@@ -32,6 +33,7 @@ import type {
} from "./types";
export const nativeRouterKey = Symbol("native-vue-router");
const nativeScopedRouteProperty = "__nativeVueRouterScopedRoute";
let entrySequence = 0;
@@ -55,6 +57,8 @@ function entryFor(
return {
key: `${route.fullPath}::${++entrySequence}`,
route,
presentation:
route.meta.native?.presentation ?? route.meta.native?.transition,
status,
mounted: true,
synthetic,
@@ -192,6 +196,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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 &&
@@ -379,6 +401,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
? this.siblingDirection(from.route, target.route)
: "forward");
if (isBack) {
from.presentation ??= presentation;
} else {
target.presentation = presentation;
target.underlayKey = presentation === "sheet" ? from.key : undefined;
}
const transaction: NativeTransaction = {
id: ++this.transactionSequence,
kind,
@@ -733,6 +762,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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;
@@ -750,6 +780,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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;
@@ -757,6 +789,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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();
@@ -857,6 +891,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
private enforceCache() {
const protectedUnderlayKeys = new Set<string>();
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) =>
@@ -866,6 +910,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
)
.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
@@ -875,7 +920,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
}
const retained = inactive.filter(
(entry) => entry.mounted && entry.route.meta.native?.cache !== "pin",
(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");

View File

@@ -1,6 +1,12 @@
:root {
--nvr-duration: 360ms;
--nvr-scrim: rgba(0, 0, 0, 0.32);
--nvr-safe-top: env(safe-area-inset-top, 0px);
--nvr-safe-right: env(safe-area-inset-right, 0px);
--nvr-safe-bottom: env(safe-area-inset-bottom, 0px);
--nvr-safe-left: env(safe-area-inset-left, 0px);
--nvr-sheet-top-gap: 8px;
--nvr-sheet-radius: 22px;
}
html,
@@ -60,6 +66,18 @@ body,
pointer-events: none;
}
.nvr-view--underlay {
z-index: 1;
visibility: visible;
pointer-events: none;
transform: scale(0.96);
border-radius: 18px;
}
.nvr-view--underlay::after {
opacity: 0.24;
}
.nvr-view--from,
.nvr-view--to {
visibility: visible;
@@ -72,6 +90,17 @@ body,
z-index: 2;
}
/* A committed sheet remains below the device status area. Retaining the
presentation on the entry keeps this geometry after the transaction ends. */
.nvr-view[data-native-view-presentation="sheet"] {
top: calc(var(--nvr-safe-top) + var(--nvr-sheet-top-gap));
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
}
.nvr-view[data-native-view-presentation="sheet"][data-native-sheet-surface] {
background: transparent;
}
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-view--from,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
@@ -152,9 +181,16 @@ body,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
z-index: 4;
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
}
.nvr-router-view[data-native-presentation="modal"] .nvr-view--to {
border-radius: calc((1 - var(--native-progress)) * 24px)
calc((1 - var(--native-progress)) * 24px) 0 0;
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
}
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
}
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
@@ -186,6 +222,81 @@ body,
touch-action: pan-x pinch-zoom;
}
.nvr-sheet {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
overflow: hidden;
}
.nvr-sheet__backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: var(--nvr-sheet-backdrop, var(--nvr-scrim));
}
.nvr-sheet__surface {
position: relative;
z-index: 1;
display: flex;
width: 100%;
min-height: min(72px, 100%);
max-height: 100%;
flex-direction: column;
overflow: hidden;
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
background: var(--nvr-sheet-background, var(--nvr-view-background, #fff));
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
}
.nvr-sheet__surface[data-native-sheet-ready="true"] {
transition: height 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nvr-sheet__surface[data-native-sheet-dragging="true"] {
transition: none;
}
.nvr-sheet__handle {
display: grid;
min-height: 32px;
flex: 0 0 32px;
place-items: center;
border: 0;
outline-offset: -3px;
cursor: ns-resize;
touch-action: none;
user-select: none;
}
.nvr-sheet__handle > span {
width: 38px;
height: 5px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 30%, transparent);
}
.nvr-sheet__body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
overscroll-behavior: contain;
padding-bottom: var(--nvr-safe-bottom);
touch-action: pan-y;
}
.nvr-sheet__content {
min-height: min-content;
}
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
.nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
@@ -213,8 +324,51 @@ body,
opacity: calc((1 - var(--native-progress)) * 0.24);
}
/* A NativeSheet animates its actual surface rather than translating its
transparent route-sized wrapper. This makes every sheet height enter from
the bottom immediately and keeps the backdrop continuous. */
.nvr-router-view[data-native-presentation="sheet"]
.nvr-view--to[data-native-sheet-surface] {
transform: none;
box-shadow: none;
}
.nvr-router-view[data-native-presentation="sheet"]
.nvr-view--to[data-native-sheet-surface]
.nvr-sheet__surface {
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
}
.nvr-router-view[data-native-presentation="sheet"]
.nvr-view--to[data-native-sheet-surface]
.nvr-sheet__backdrop {
opacity: var(--native-progress);
}
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--from[data-native-sheet-surface] {
transform: none;
box-shadow: none;
}
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--from[data-native-sheet-surface]
.nvr-sheet__surface {
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
}
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--from[data-native-sheet-surface]
.nvr-sheet__backdrop {
opacity: calc(1 - var(--native-progress));
}
@media (prefers-reduced-motion: reduce) {
.nvr-view {
will-change: auto;
}
.nvr-sheet__surface {
transition: none;
}
}

View File

@@ -19,7 +19,7 @@ export type NativeGestureKind =
"push" | "pop" | "sibling" | "present" | "dismiss";
export type NativeDirection = "forward" | "back" | "up" | "down";
export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
export type NativeViewRole = "active" | "inactive" | "from" | "to";
export type NativeViewRole = "active" | "inactive" | "underlay" | "from" | "to";
export type NativeCachePolicy = boolean | "pin";
export type NativeEvictionReason =
| "cache-disabled"
@@ -76,6 +76,10 @@ declare module "vue-router" {
export interface NativeViewEntry {
key: string;
route: RouteLocationNormalizedLoaded;
/** Presentation retained after commit so persistent surfaces such as sheets keep their layout. */
presentation?: NativePresentationName;
/** View kept visually beneath a committed, non-full-screen presentation. */
underlayKey?: string;
status: NativeViewStatus;
mounted: boolean;
synthetic: boolean;

View File

@@ -9,6 +9,6 @@
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,7 +1,9 @@
import { defineConfig } from "vite";
import { resolve } from "node:path";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
build: {
outDir: "dist",
emptyOutDir: true,

View File

@@ -0,0 +1,204 @@
---
name: integrate-native-vue-router
description: Integrate, migrate, configure, or debug Native Vue Router in new or existing Vue 3 applications. Use when adding @native-vue-router/core, converting a Vue Router app to native route surfaces, defining parent or sibling topology, adding gesture links and modal/sheet navigation, handling cached-view lifecycle, composing Vue built-ins with NativeRouterView, creating custom presentations, or wiring Capacitor and Electron adapters.
---
# Integrate Native Vue Router
Implement Native Vue Router as a visual transaction layer around Vue Router.
Keep Vue Router authoritative for committed URLs, history, guards, redirects, and
route matching.
Read [references/integration-reference.md](references/integration-reference.md)
before changing a project. Treat it as the API baseline for version `0.1.x`. If
the installed package version or local source differs, inspect that version's
`package.json`, exported declarations, and source before editing.
## Choose the adoption path
Determine whether the target is:
- A new Vue application: establish the native route shell and topology while
creating the router.
- An existing Vue Router application: preserve its history mode, routes,
guards, redirects, nested outlets, state, and deep links while migrating the
visual root and selected navigation calls.
- A host integration: add the core first, then add Capacitor or Electron as an
adapter at the application boundary.
- A targeted enhancement: adopt imperative animated navigation first and defer
predictive gestures or sibling paging.
Do not widen the task into a framework upgrade without user authorization. If
the project is below Vue 3.5 or Vue Router 5, report the compatibility gap and
the exact upgrade it requires before changing dependencies.
## Inspect the target
Before editing, identify:
1. Package manager, workspace layout, Vue version, and Vue Router version.
2. The router creation file, history implementation, route records, guards,
redirects, scroll behavior, and lazy components.
3. The app entry and plugin installation order.
4. Every root and nested `RouterView`, plus wrappers such as `KeepAlive`,
`Transition`, and `Suspense`.
5. Calls to `router.push`, `router.replace`, `router.back`, `RouterLink`, tab
controls, modal routing, and bespoke swipe handlers.
6. Route-local side effects in setup/mount hooks, including analytics,
subscriptions, polling, media, and mutations.
7. Deep-linkable child routes, ordered peer routes, and routes whose local state
can or cannot be evicted.
8. Horizontal gesture owners such as carousels, maps, editors, and canvases.
9. Browser/PWA, Capacitor, Electron, SSR, and accessibility requirements.
Summarize the route topology before implementation when it is non-trivial.
## Install and bootstrap
Add `@native-vue-router/core` and its stylesheet. Add preset or platform packages
only when needed. Match the project's package manager and formatting style.
Create exactly one native runtime for one authoritative root route surface:
1. Create the Vue Router normally.
2. Pass that router to `createNativeRouter`.
3. Call `app.use(router)` before `app.use(nativeRouter)`.
4. Wait for `router.isReady()` when the application already does so or relies on
deterministic initial rendering.
5. Replace the root visual `RouterView` with `NativeRouterView`.
6. Wrap it in `NativeNavigator` only when predictive Back or sibling paging is
required.
Retain nested `RouterView` components inside route components. Do not add nested
independent native runtimes without an explicit ownership design.
## Model route topology
Add the smallest route metadata needed for the requested behavior:
- Use `presentation` for a route's default visual treatment.
- Add `parent` to directly addressable child or presented routes so a cold start
has a predictive Back destination.
- Add numeric `siblingOrder` to peer routes whose direction must be stable.
- Choose `siblingHistory: "replace"` for tabs and other peers that should not
grow Back history; use `"push"` only when Back should revisit peer selection.
- Use `cache: false` for routes that must be destroyed when inactive and
`cache: "pin"` only for deliberately retained views.
- Use `gesture: false` to disable navigator gesture handling for a route.
Preserve params, query, and hash in dynamic `parent` functions whenever they are
part of the logical parent location. Do not infer parentage from the mounted
cache or route registration order.
`navigator` and `siblingGroup` are reserved labels in `0.1.x`; do not claim that
they automatically create navigator ownership. Pass sibling locations to
`NativeNavigator` explicitly.
## Migrate navigation intentionally
Use native runtime methods for navigation that needs preview-driven motion:
- `push` or `NativeLink` for forward stack navigation.
- `replace` for no-growth replacement.
- `sibling` for ordered peers.
- `pop` for Back.
- `present` and `dismiss` for modal or sheet routes.
- `NativeSheet` for safe-area-contained, content-height, or snapping sheet
surfaces.
- `NativeGestureLink` for component-originated horizontal dragging.
- `NativeDismissGesture` for downward dismissal.
Leave raw Vue Router navigation in place when it is an intentional redirect,
non-animated control flow, or external integration. The runtime reconciles such
navigation, but it cannot preview it before commit.
Preserve standard link semantics. Prefer `NativeLink` when a true anchor is
needed. If using `NativeGestureLink`, select a semantic `as` element and retain
keyboard activation and accessible naming.
## Make preview mounts safe
Assume a destination can run setup and mount before its route guard approves the
navigation, and can then unmount without becoming current.
Move committed-screen side effects from unconditional setup/mount code to
`useNativeViewActiveEffect`, `onNativeViewActivate`, or explicit user actions.
Use `useNativeViewVisibleEffect` only for work needed while the route is active
or participating in a transition. Keep durable data in a store or persistence
layer because the bounded native cache may evict component instances.
Do not replace the native multi-route cache with a single Vue `KeepAlive`.
`KeepAlive`, `Transition`, `Teleport`, and `Suspense` may still be composed
inside the `NativeRouterView` slot. Gate teleported overlays on native view
visibility because teleported DOM is outside the inactive layer's `inert` and
`aria-hidden` boundary.
For partial sheets, use `NativeSheet` rather than styling a route component with
an arbitrary viewport height. Use no breakpoints for content height, or
fractional breakpoints for snap points. Keep `presentation: "sheet"` in route
metadata so direct entries retain safe-area and underlay behavior.
Preserve the built-in scroll body unless replacing its gesture arbitration:
normal content scrolling owns interior positions, while top/down and bottom/up
overscroll started at a boundary hands off to sheet resizing. Keep one owner for
the complete physical gesture; never reinterpret accumulated content-scroll
distance as sheet movement after an edge is reached or direction reverses.
Use scoped `useRoute()` or Options API `$route` inside route trees. During a
preview, do not substitute `router.currentRoute`: it intentionally remains the
committed source location until navigation succeeds.
## Add gesture ownership safely
Wrap only the intended route surface in `NativeNavigator`. Give the container a
definite height and import core CSS.
Mark nested horizontal interaction regions with
`data-native-gesture="ignore"`. Confirm vertical scrolling, controls, text
selection, RTL behavior, reduced motion, keyboard navigation, focus, and screen
reader isolation after adding gestures.
Do not promise deterministic browser-edge ownership. Recommend Capacitor when a
product requires native-level suppression of host Back gestures.
## Add host adapters at the boundary
For Capacitor, install the adapter in `createNativeRouter` and configure hardware
Back, haptics, deep-link mapping, background cache trimming, and root exit. Do
not assume Universal Links or App Links are configured by JavaScript alone.
For Electron, call `disableElectronHistoryGestures(app.commandLine)` before
`app.whenReady()` in the main process. Expose only the renderer callbacks needed
by `createElectronRendererAdapter` through a secure preload. Preserve hash or
custom-protocol history behavior used by packaged applications.
Keep host detection and host APIs out of route components unless the product
experience genuinely differs.
## Verify the integration
Run the target project's formatter, type checker, unit tests, build, and relevant
end-to-end tests. Add tests or manual verification for the changed behavior:
- Direct entry and reload on deep child URLs.
- Params, query, hash, redirects, and nested route injection.
- Both routes remain live during a held gesture while the URL stays unchanged.
- Gesture cancellation preserves the source URL and state.
- Accepted and rejected guards settle correctly.
- Browser Back/Forward and cold-start parent Back.
- Sibling direction and replace/push history behavior.
- Cached state, eviction, unload, and active/visible effects.
- Vue built-ins, provide/inject, Options API `$route`, and lifecycle hooks used
by the application.
- Teleported overlay cleanup and inactive-route focus isolation.
- Gesture conflicts, RTL, reduced motion, keyboard behavior, and target hosts.
Use `runtime.entries`, `activeKey`, `transaction`, `canGoBack`, `cacheStats`, and
`onDiagnostic` for focused diagnostics. Use the profiler only when measuring
frame behavior; it is opt-in and should be stopped/disposed after capture.
## Report the result
State which routes and navigation paths became native-aware, which raw Vue Router
paths remain intentionally unchanged, what lifecycle work moved, and what was
verified. Call out unresolved host limitations, missing parent topology, or
dependency incompatibility explicitly.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Integrate Native Vue Router"
short_description: "Add native navigation to Vue Router apps"
default_prompt: "Use $integrate-native-vue-router to integrate Native Vue Router into this Vue project."

View File

@@ -0,0 +1,521 @@
# Native Vue Router integration reference
Use this reference for `@native-vue-router/*` version `0.1.x`. Inspect installed
declarations or local source when integrating another version.
## Support and ownership
- Requires Vue `^3.5.0` and Vue Router `^5.0.0`.
- Targets client-side DOM navigation. SSR/live-stack hydration is incomplete.
- Vue Router owns committed routes, URLs, history, guards, redirects, and lazy
route matching.
- Native Vue Router owns preview surfaces, mounted-view caching, gesture
progress, presentations, and transaction state.
- Preview trees receive scoped Vue Router route injection. `useRoute()` and
Options API `$route` identify that surface; `router.currentRoute` identifies
the globally committed route.
- The URL does not change during a forward preview. Vue Router navigation and
guards run when the gesture or imperative transaction commits.
## Packages and styles
```bash
npm install vue@^3.5 vue-router@^5 @native-vue-router/core
```
Optional packages:
```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
```
Import styles explicitly:
```ts
import "@native-vue-router/core/style.css";
import "@native-vue-router/preset-native/style.css"; // when used
```
Give the route shell a definite height. The core `.nvr-navigator` and
`.nvr-router-view` elements use `height: 100%`.
## Bootstrap template
```ts
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createNativeRouter } from "@native-vue-router/core";
import App from "./App.vue";
import "@native-vue-router/core/style.css";
const router = createRouter({
history: createWebHistory(),
routes,
});
const nativeRouter = createNativeRouter({
router,
cache: { maxInactive: 4 },
// edgeWidth: 28,
// platform,
// presentations: [customPresentation],
});
const app = createApp(App);
app.use(router);
app.use(nativeRouter);
await router.isReady();
app.mount("#app");
```
```vue
<script setup lang="ts">
import { NativeNavigator, NativeRouterView } from "@native-vue-router/core";
const siblings = ["/inbox", "/stories", "/profile"];
</script>
<template>
<NativeNavigator :siblings="siblings" :edge-width="28">
<NativeRouterView />
</NativeNavigator>
</template>
```
Install Vue Router before the native plugin. Retain ordinary nested
`RouterView`s within route components.
## Route metadata
```ts
interface NativeRouteOptions {
navigator?: string;
presentation?:
"push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | "none" | string;
transition?: string;
parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
siblingGroup?: string;
siblingOrder?: number;
siblingHistory?: "push" | "replace";
cache?: boolean | "pin";
gesture?: boolean | "edge" | "full";
}
```
- Prefer `presentation`; `transition` is a compatibility alias.
- `parent` supplies a synthetic predictive Back destination if no warm native
history predecessor exists.
- `siblingOrder` determines direction for programmatic sibling navigation.
- `siblingHistory: "replace"` is the usual tab behavior.
- `cache: false` prevents inactive retention. `cache: "pin"` avoids ordinary LRU
trimming and is excluded from default `trimCache()`.
- `gesture: false` is enforced by `NativeNavigator`. In `0.1.x`, finer
`edge`/`full` policy is mainly structural.
- `navigator` and `siblingGroup` are reserved; pass siblings to the navigator
explicitly.
Example with dynamic parent state:
```ts
{
path: "/chat/:id/details",
name: "chat-details",
component: () => import("./ChatDetailsView.vue"),
meta: {
native: {
presentation: "push",
parent: (route) => ({
name: "chat",
params: { id: route.params.id },
query: route.query,
hash: route.hash,
}),
gesture: "edge",
},
},
}
```
## Core components
### `NativeRouterView`
Renders every mounted native entry as a sibling layer. Its default slot exposes:
```ts
{
Component: Component | undefined;
route: RouteLocationNormalizedLoaded;
entry: NativeViewEntry;
}
```
Without a slot, it renders the matched route component. Inactive layers are
hidden, `inert`, and `aria-hidden`.
### `NativeNavigator`
Props:
- `siblings: RouteLocationRaw[]` (default `[]`)
- `edgeWidth: number` (default `28`)
Owns leading-edge predictive Back and optional full-surface paging between the
listed siblings. The list is visual order. Route `siblingOrder` also makes
programmatic direction deterministic.
### `NativeLink`
Props:
- `to: RouteLocationRaw` (required)
- `replace: boolean`
- `presentation: NativePresentationName`
Renders an anchor with a resolved `href`. Primary unmodified clicks use native
`push` or `replace`; other attributes pass through.
### `NativeGestureLink`
Props:
- `to: RouteLocationRaw` (required)
- `presentation` (default `"reveal"`)
- `replace: boolean`
- `direction: "left" | "right" | "any"` (default `"any"`)
- `as: string` (default `"div"`)
Owns horizontal component-originated navigation. A click uses native `push`.
Choose semantic markup and add accessible keyboard behavior as needed.
### `NativeDismissGesture`
Prop `as` defaults to `div`. Owns downward vertical dismissal for a modal or
sheet surface.
### `NativeSheet`
Use inside a route with `meta.native.presentation: "sheet"`. The sheet route is
contained below `--nvr-safe-top` plus `--nvr-sheet-top-gap`, and its previous
route remains visible but inert as an underlay.
Props:
- `breakpoints: number[]` (default `[]`): normalized fractions in `(0, 1]`.
Empty means content height capped at available height.
- `initialBreakpoint?: number`: starts at the nearest declared fraction.
- `modelValue?: number`: current fraction for `v-model`.
- `dismissible: boolean` (default `true`).
- `backdropDismiss: boolean` (default `true`).
- `showHandle: boolean` (default `true`).
- `ariaLabel: string` (default `"Sheet"`).
Events are `update:modelValue`, `breakpoint-change`, and `dismiss`. The named
`handle` slot replaces only the handle visual. The built-in handle supports
pointer dragging, Escape, Arrow Up/Down, Home, and End.
The body keeps native scrolling while it has content in the requested
direction. At the top, downward overscroll shrinks or dismisses the sheet. At
the bottom, upward overscroll expands it. The handoff supports touch,
mouse/pen dragging, and thresholded wheel/trackpad input. A snapping
`NativeSheet` animates its own measured surface, while the route-sized wrapper
stays fixed and the previous route retains its underlay scale across commit.
Ownership locks after the gesture's initial directional intent. If content owns
the gesture, reaching an edge or reversing does not transfer that same gesture
to the sheet; release and start at the edge to resize. Never implement a
mid-gesture reinterpretation using distance accumulated while content was
scrolling.
```vue
<script setup lang="ts">
import { ref } from "vue";
import { NativeSheet } from "@native-vue-router/core";
const point = ref(0.55);
</script>
<template>
<NativeSheet
v-model="point"
:breakpoints="[0.3, 0.55, 1]"
:initial-breakpoint="0.55"
aria-label="Filters"
>
<FilterForm />
</NativeSheet>
</template>
```
Theme with `--nvr-sheet-background`, `--nvr-sheet-backdrop`,
`--nvr-sheet-radius`, and `--nvr-sheet-top-gap`. Prefer `NativeSheet` to
`NativeDismissGesture` for partial or snapping route sheets.
### Gesture exclusions
The recognizers ignore ordinary form/editable controls and elements within:
```html
<div data-native-gesture="ignore">...</div>
```
Use this for maps, carousels, canvases, code editors, and custom horizontal
controls.
## Runtime API
Create/inject:
- `createNativeRouter(options): NativeRouterRuntime`
- `useNativeRouter(): NativeRouterRuntime`
- Options API: `this.$nativeRouter`
Reactive readonly state:
- `router`
- `entries`
- `activeKey`
- `transaction`
- `canGoBack`
- `cacheStats`
Navigation:
```ts
push(to, options?): Promise<boolean>
replace(to, options?): Promise<boolean>
sibling(to, options?): Promise<boolean>
pop(): Promise<boolean>
present(to, presentation = "modal"): Promise<boolean>
dismiss(): Promise<boolean>
preload(to): Promise<RouteLocationNormalizedLoaded>
```
`NativeNavigationOptions` supports:
```ts
{
presentation?: NativePresentationName;
replace?: boolean;
direction?: "forward" | "back" | "up" | "down";
sourceRect?: {
top: number;
left: number;
width: number;
height: number;
viewportWidth: number;
viewportHeight: number;
};
}
```
Interactive driver:
```ts
beginInteractive(kind, to?, options?): Promise<number | null>
updateInteractive(progress, velocity?): void
finishInteractive(forceCommit?): Promise<boolean>
cancelInteractive(): Promise<void>
```
Kinds are `push`, `pop`, `sibling`, `present`, and `dismiss`. Progress is clamped
to `0...1`. Velocity is normalized route progress per second. The default commit
rule is progress at least `0.36`, or progress at least `0.08` with velocity at
least `1.1`.
Cache/diagnostics/extension:
```ts
unload(to): number
trimCache({ includePinned?, reason? }?): void
onDiagnostic(listener): () => void
registerPresentation(definition): void
presentationFor(name): NativePresentationDefinition | undefined
dispose(): void
```
Call `dispose()` if the owning application lifecycle tears the runtime down
without a page unload.
## Native view lifecycle
Available APIs:
```ts
useNativeViewLifecycle();
onNativeViewActivate(hook);
onNativeViewDeactivate(hook);
onNativeViewShow(hook);
onNativeViewHide(hook);
onNativeViewEvict(hook);
useNativeViewActiveEffect(effect);
useNativeViewVisibleEffect(effect);
```
Lifecycle state:
- `route`: scoped route for the view.
- `status`: `active`, `inactive`, `preview`, or `evicted`.
- `role`: `active`, `inactive`, `underlay`, `from`, or `to`.
- `isActive`: authoritative committed route.
- `isVisible`: active, one side of a live transition, or a visible sheet
underlay. Underlays remain inert.
- `isPreview`: destination not yet committed.
- `isCached`: mounted inactive route.
- `evictionReason`: `cache-disabled`, `cache-limit`,
`navigation-rejected`, `popped`, `manual`, `trimmed`, or `memory-pressure`.
Effects may return cleanup functions. Active effects suit polling,
subscriptions, media, and committed-screen analytics. Visible effects suit work
needed while the surface participates in a transition.
Do not treat Vue component mount as route commitment. A preview can mount before
guards run and unmount after rejection or gesture cancellation.
## Vue compatibility
Within a native route surface:
- Vue Composition and Options API lifecycle hooks retain normal component
semantics.
- App/plugin/ancestor `provide` and `inject` work normally.
- `useRoute`, `useRouter`, and Options API `$route` are supported.
- Params, query, hash, metadata, and matched records are scoped to each preview
or committed surface.
- Nested `RouterView` is supported.
- `KeepAlive`, `Transition`, `Teleport`, and `Suspense` may be used inside the
`NativeRouterView` slot.
Important distinctions:
- Native route caching is separate from `KeepAlive`. A cached native route stays
mounted, so native inactivity alone does not trigger Vue `onDeactivated`.
- Native presentations animate route layers; a Vue `Transition` handles changes
within one layer.
- Teleported DOM escapes the route layer's `inert`/`aria-hidden` isolation. Gate
it with `isVisible` and close it when the owning view hides where appropriate.
- Async route components are loaded before preview. A `Suspense` inside the route
can still show fallback UI for async descendants.
Slot composition example:
```vue
<NativeRouterView v-slot="{ Component, route }">
<Suspense>
<Transition name="content" mode="out-in">
<KeepAlive :max="3">
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</Transition>
<template #fallback><RouteSkeleton /></template>
</Suspense>
</NativeRouterView>
```
## Custom presentation
```ts
import { definePresentation } from "@native-vue-router/core";
const scaleFade = definePresentation({
name: "scale-fade",
axis: "x",
layerStyle({ role, progress, direction, sourceRect }) {
return role === "to"
? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
: { opacity: 1 - progress * 0.25 };
},
});
```
Register through `createNativeRouter({ presentations: [...] })` or
`runtime.registerPresentation()`. Keep layer styles compositor-friendly and do
not perform application or history mutations in a style function.
## Preset-native API
- `NativeBackButton` prop: `label` (default `"Back"`). Calls `native.pop()`.
- `NativeTabBar` prop: required `items: NativeTabItem[]`. It uses replace-style
native sibling navigation.
- `NativeTabItem`: `{ label, to, icon?, activeWhen? }`.
- `detectNativePlatform()`: `"ios" | "android" | "desktop"`.
- `nativeMotionTokens`: edge widths and commit thresholds for those three
platform labels. Core physics are not route-configurable from these tokens in
`0.1.x`.
## Capacitor adapter
```ts
createCapacitorAdapter({
exitAtRoot?: boolean; // default true behavior
haptics?: boolean; // default enabled on native
trimCacheOnPause?: boolean; // default true
deepLinkPath?: (url: URL) => string;
})
```
It handles Android hardware Back, App URL open/launch URLs, interactive
cancellation on pause, optional inactive-cache trimming, root exit, and haptics.
Native link association/configuration remains an application responsibility.
## Electron adapter
Main process, before `app.whenReady()`:
```ts
disableElectronHistoryGestures(app.commandLine);
```
Renderer:
```ts
createNativeRouter({
router,
platform: createElectronRendererAdapter(),
});
```
The preload may expose:
```ts
window.nativeVueHost = {
onBack(callback): () => void,
onForward?(callback): () => void,
onMemoryPressure?(callback): () => void,
};
```
Use a secure context bridge; do not enable Node integration merely for the
adapter. Packaged `file:` apps commonly require hash history.
## Verification matrix
At minimum verify:
| Area | Checks |
| ----------- | --------------------------------------------------------------------------------- |
| Boot | Initial route renders after `router.isReady`; CSS and height are correct |
| Route state | Params, query, hash, `useRoute`, `$route`, nested routes, provide/inject |
| Preview | Source and destination coexist; URL stays on source; cancellation removes preview |
| Guards | Acceptance, rejection, and redirect reconcile to Vue Router's result |
| Back | Warm Back, browser Back/Forward, direct-entry parent, no unrelated cached target |
| Siblings | Direction follows order; chosen push/replace history behavior is correct |
| Cache | Local warm state, `cache: false`, pin, LRU, unload, trim, eviction cleanup |
| Lifecycle | Active/visible effects stop and resume at the correct boundaries |
| Vue | KeepAlive, Transition, Teleport, Suspense, component hook ordering |
| Input | Edge Back, component drag, vertical scroll, ignored regions, rapid interruption |
| A11y | Inactive focus isolation, semantic controls, keyboard access, reduced motion |
| Hosts | PWA limits, Electron commands/history, Capacitor Back/deep links/pause |
## Known boundaries
- One runtime coordinates one authoritative visual transaction at a time.
- Preview setup can run before navigation approval.
- The mounted cache is bounded; route-local state can be lost after eviction.
- Cold-start predictive Back requires explicit parent topology.
- Browser content cannot guarantee ownership of operating-system/browser edge
gestures.
- Independent nested navigation controllers and SSR stack hydration need an
explicit design beyond the current `0.1.x` implementation.

809
usage.md Normal file
View File

@@ -0,0 +1,809 @@
# 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.

View File

@@ -1,6 +1,10 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";
export default defineConfig({
// Vitest 3 carries Vite 6 types while this workspace builds with Vite 8.
// The plugin is runtime-compatible; remove this cast when Vitest is upgraded.
plugins: [vue() as never],
test: {
environment: "happy-dom",
include: ["packages/**/*.test.ts"],