Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,3 +27,5 @@ apps/demo/dev-dist
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
docker-compose.yml
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
44
apps/demo/src/compatibility-lab.ts
Normal file
44
apps/demo/src/compatibility-lab.ts
Normal 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);
|
||||
}
|
||||
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal file
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal 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>
|
||||
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal file
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal 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>
|
||||
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal file
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal 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>
|
||||
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal file
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal 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>
|
||||
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal file
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal 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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
<header class="sheet-header">
|
||||
<button type="button" @click="native.dismiss()">Cancel</button>
|
||||
<h1>New message</h1>
|
||||
<span />
|
||||
</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)">
|
||||
<AppAvatar :person="person" />
|
||||
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</NativeDismissGesture>
|
||||
<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>
|
||||
<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)"
|
||||
>
|
||||
<AppAvatar :person="person" />
|
||||
<div class="conversation-copy">
|
||||
<strong>{{ person.name }}</strong>
|
||||
<p>{{ person.handle }}</p>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</NativeSheet>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal file
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal 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>
|
||||
427
apps/demo/src/views/VueCompatibilityView.vue
Normal file
427
apps/demo/src/views/VueCompatibilityView.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
38
experiments/router-guard-history/README.md
Normal file
38
experiments/router-guard-history/README.md
Normal 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.
|
||||
12
experiments/router-guard-history/index.html
Normal file
12
experiments/router-guard-history/index.html
Normal 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>
|
||||
18
experiments/router-guard-history/package.json
Normal file
18
experiments/router-guard-history/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
138
experiments/router-guard-history/src/App.vue
Normal file
138
experiments/router-guard-history/src/App.vue
Normal 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>
|
||||
78
experiments/router-guard-history/src/experiment.ts
Normal file
78
experiments/router-guard-history/src/experiment.ts
Normal 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";
|
||||
}
|
||||
13
experiments/router-guard-history/src/main.ts
Normal file
13
experiments/router-guard-history/src/main.ts
Normal 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);
|
||||
});
|
||||
71
experiments/router-guard-history/src/router.ts
Normal file
71
experiments/router-guard-history/src/router.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
256
experiments/router-guard-history/src/style.css
Normal file
256
experiments/router-guard-history/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
7
experiments/router-guard-history/tsconfig.json
Normal file
7
experiments/router-guard-history/tsconfig.json
Normal 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"]
|
||||
}
|
||||
6
experiments/router-guard-history/vite.config.ts
Normal file
6
experiments/router-guard-history/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
748
packages/core/src/components/NativeRouterView.test.ts
Normal file
748
packages/core/src/components/NativeRouterView.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
<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" />
|
||||
<NativeRouteScope :route="route" :entry-key="entry.key">
|
||||
<slot
|
||||
v-if="slots.default"
|
||||
:Component="Component"
|
||||
:route="route"
|
||||
:entry="entry"
|
||||
/>
|
||||
<component v-else-if="Component" :is="Component" />
|
||||
</NativeRouteScope>
|
||||
</RouterView>
|
||||
</section>
|
||||
|
||||
182
packages/core/src/components/NativeSheet.test.ts
Normal file
182
packages/core/src/components/NativeSheet.test.ts
Normal 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("/");
|
||||
});
|
||||
});
|
||||
562
packages/core/src/components/NativeSheet.vue
Normal file
562
packages/core/src/components/NativeSheet.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
28
packages/core/src/components/sheet.ts
Normal file
28
packages/core/src/components/sheet.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
204
skills/integrate-native-vue-router/SKILL.md
Normal file
204
skills/integrate-native-vue-router/SKILL.md
Normal 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.
|
||||
4
skills/integrate-native-vue-router/agents/openai.yaml
Normal file
4
skills/integrate-native-vue-router/agents/openai.yaml
Normal 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."
|
||||
@@ -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
809
usage.md
Normal 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.
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user