Add prettier. Format.

This commit is contained in:
2026-07-22 02:12:05 +00:00
parent d28b09dc21
commit 18baa96848
45 changed files with 4520 additions and 2613 deletions

View File

@@ -48,31 +48,35 @@ An installed web app cannot access `WKWebView.allowsBackForwardNavigationGesture
## Minimal integration ## Minimal integration
```ts ```ts
import { createApp } from 'vue' import { createApp } from "vue";
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import { createNativeRouter } from '@native-vue-router/core' import { createNativeRouter } from "@native-vue-router/core";
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', component: Home }, { path: "/", component: Home },
{ {
path: '/chat/:id', path: "/chat/:id",
component: Chat, component: Chat,
meta: { meta: {
native: { presentation: 'push', parent: '/', gesture: 'edge' }, native: { presentation: "push", parent: "/", gesture: "edge" },
}, },
}, },
], ],
}) });
const nativeRouter = createNativeRouter({ router }) const nativeRouter = createNativeRouter({ router });
createApp(App).use(router).use(nativeRouter).mount('#app') createApp(App).use(router).use(nativeRouter).mount("#app");
``` ```
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { NativeGestureLink, NativeNavigator, NativeRouterView } from '@native-vue-router/core' import {
NativeGestureLink,
NativeNavigator,
NativeRouterView,
} from "@native-vue-router/core";
</script> </script>
<template> <template>
@@ -100,16 +104,16 @@ The Navigation Lab contains an opt-in profiler. Tap **Start profiling**, leave t
The core API is also available directly: The core API is also available directly:
```ts ```ts
import { createNativeNavigationProfiler } from '@native-vue-router/core' import { createNativeNavigationProfiler } from "@native-vue-router/core";
const profiler = createNativeNavigationProfiler(nativeRouter, { const profiler = createNativeNavigationProfiler(nativeRouter, {
metadata: { build: import.meta.env.VITE_BUILD_ID }, metadata: { build: import.meta.env.VITE_BUILD_ID },
}) });
profiler.start() profiler.start();
// Reproduce the navigation issue. // Reproduce the navigation issue.
const report = profiler.stop() const report = profiler.stop();
const json = profiler.toJSON(report) const json = profiler.toJSON(report);
``` ```
No rAF loop or browser performance observer runs before `start()`, and `stop()` removes them. Reports contain frame intervals, refresh-rate estimates, per-navigation timing, cold-mount preparation, route loading, cache eviction, visibility changes, and browser-supported Long Task/layout-shift/resource timing. Route params, query values, and application state are omitted. No rAF loop or browser performance observer runs before `start()`, and `stop()` removes them. Reports contain frame intervals, refresh-rate estimates, per-navigation timing, cold-mount preparation, route loading, cache eviction, visibility changes, and browser-supported Long Task/layout-shift/resource timing. Route params, query values, and application state are omitted.

View File

@@ -1,21 +1,21 @@
import type { CapacitorConfig } from '@capacitor/cli' import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'dev.nativevuerouter.messenger', appId: "dev.nativevuerouter.messenger",
appName: 'Native Vue Messenger', appName: "Native Vue Messenger",
webDir: '../demo/dist', webDir: "../demo/dist",
backgroundColor: '#0b0d12', backgroundColor: "#0b0d12",
plugins: { plugins: {
App: { disableBackButtonHandler: true }, App: { disableBackButtonHandler: true },
SplashScreen: { SplashScreen: {
launchAutoHide: true, launchAutoHide: true,
backgroundColor: '#0b0d12', backgroundColor: "#0b0d12",
androidScaleType: 'CENTER_CROP', androidScaleType: "CENTER_CROP",
}, },
StatusBar: { style: 'DARK', backgroundColor: '#0b0d12' }, StatusBar: { style: "DARK", backgroundColor: "#0b0d12" },
}, },
android: { backgroundColor: '#0b0d12' }, android: { backgroundColor: "#0b0d12" },
ios: { backgroundColor: '#0b0d12', contentInset: 'never' }, ios: { backgroundColor: "#0b0d12", contentInset: "never" },
} };
export default config export default config;

View File

@@ -1,14 +1,14 @@
{ {
"images" : [ "images": [
{ {
"filename" : "AppIcon-512@2x.png", "filename": "AppIcon-512@2x.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }

View File

@@ -1,23 +1,23 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "universal", "idiom": "universal",
"filename" : "splash-2732x2732-2.png", "filename": "splash-2732x2732-2.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"idiom" : "universal", "idiom": "universal",
"filename" : "splash-2732x2732-1.png", "filename": "splash-2732x2732-1.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"idiom" : "universal", "idiom": "universal",
"filename" : "splash-2732x2732.png", "filename": "splash-2732x2732.png",
"scale" : "3x" "scale": "3x"
} }
], ],
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }

View File

@@ -1,400 +1,542 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from "@playwright/test";
async function captureTransitions(page: Page) { async function captureTransitions(page: Page) {
await page.evaluate(() => { await page.evaluate(() => {
const state = window as typeof window & { const state = window as typeof window & {
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }> __nvrEvents?: Array<{
__nvrObserver?: MutationObserver direction: string | null;
} presentation: string | null;
state.__nvrObserver?.disconnect() }>;
state.__nvrEvents = [] __nvrObserver?: MutationObserver;
const view = document.querySelector('.nvr-router-view') };
if (!view) throw new Error('Native router view did not render') state.__nvrObserver?.disconnect();
state.__nvrEvents = [];
const view = document.querySelector(".nvr-router-view");
if (!view) throw new Error("Native router view did not render");
state.__nvrObserver = new MutationObserver(() => { state.__nvrObserver = new MutationObserver(() => {
if (view.classList.contains('nvr-router-view--interactive')) { if (view.classList.contains("nvr-router-view--interactive")) {
state.__nvrEvents?.push({ state.__nvrEvents?.push({
direction: view.getAttribute('data-native-direction'), direction: view.getAttribute("data-native-direction"),
presentation: view.getAttribute('data-native-presentation'), presentation: view.getAttribute("data-native-presentation"),
}) });
} }
}) });
state.__nvrObserver.observe(view, { attributes: true }) state.__nvrObserver.observe(view, { attributes: true });
}) });
} }
async function recordedTransitions(page: Page) { async function recordedTransitions(page: Page) {
return await page.evaluate(() => (window as typeof window & { return await page.evaluate(
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }> () =>
}).__nvrEvents ?? []) (
window as typeof window & {
__nvrEvents?: Array<{
direction: string | null;
presentation: string | null;
}>;
}
).__nvrEvents ?? [],
);
} }
async function waitForTransition(page: Page) { async function waitForTransition(page: Page) {
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/) await expect(page.locator(".nvr-router-view")).not.toHaveClass(
/nvr-router-view--interactive/,
);
} }
async function flickToNextTab(page: Page, leaveSlowSpring = false) { async function flickToNextTab(page: Page, leaveSlowSpring = false) {
// Start on the route header, outside conversation-owned drag targets. // Start on the route header, outside conversation-owned drag targets.
const surface = await page.locator('.nvr-router-view').boundingBox() const surface = await page.locator(".nvr-router-view").boundingBox();
const header = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox() const header = await page
if (!surface || !header) throw new Error('Active route header did not render') .locator(
const y = header.y + header.height * 0.5 '[data-native-role="active"] .app-header, [data-native-role="to"] .app-header',
await page.mouse.move(surface.x + surface.width * 0.72, y) )
await page.mouse.down() .last()
.boundingBox();
if (!surface || !header)
throw new Error("Active route header did not render");
const y = header.y + header.height * 0.5;
await page.mouse.move(surface.x + surface.width * 0.72, y);
await page.mouse.down();
if (leaveSlowSpring) { if (leaveSlowSpring) {
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 }) await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 });
await page.waitForTimeout(90) await page.waitForTimeout(90);
} }
await page.mouse.move(surface.x + surface.width * 0.27, y) await page.mouse.move(surface.x + surface.width * 0.27, y);
await page.mouse.up() await page.mouse.up();
} }
test('navigates a conversation and returns through the native runtime', async ({ page }) => { test("navigates a conversation and returns through the native runtime", async ({
await page.goto('/inbox') page,
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() }) => {
await page.getByText('Maya Chen').last().click() await page.goto("/inbox");
await expect(page).toHaveURL(/\/chat\/maya$/) await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible() await page.getByText("Maya Chen").last().click();
await page.getByRole('button', { name: 'Back' }).click() await expect(page).toHaveURL(/\/chat\/maya$/);
await expect(page).toHaveURL(/\/inbox$/) await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
await waitForTransition(page) await page.getByRole("button", { name: "Back" }).click();
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() await expect(page).toHaveURL(/\/inbox$/);
}) await waitForTransition(page);
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test('switches sibling routes without growing the primary history flow', async ({ page }) => { test("switches sibling routes without growing the primary history flow", async ({
await page.goto('/inbox') page,
await page.getByRole('link', { name: /Stories/ }).click() }) => {
await expect(page).toHaveURL(/\/stories$/) await page.goto("/inbox");
await waitForTransition(page) await page.getByRole("link", { name: /Stories/ }).click();
await expect(page.getByRole('heading', { name: 'Stories' })).toBeVisible() await expect(page).toHaveURL(/\/stories$/);
await page.getByRole('link', { name: /You/ }).click() await waitForTransition(page);
await expect(page).toHaveURL(/\/profile$/) await expect(page.getByRole("heading", { name: "Stories" })).toBeVisible();
await waitForTransition(page) await page.getByRole("link", { name: /You/ }).click();
await expect(page.getByRole('heading', { name: 'You' })).toBeVisible() await expect(page).toHaveURL(/\/profile$/);
}) await waitForTransition(page);
await expect(page.getByRole("heading", { name: "You" })).toBeVisible();
});
test('uses route order for tab direction and does not animate the active tab', async ({ page }) => { test("uses route order for tab direction and does not animate the active tab", async ({
await page.goto('/inbox') page,
await captureTransitions(page) }) => {
await page.getByRole('link', { name: /Stories/ }).click() await page.goto("/inbox");
await expect(page).toHaveURL(/\/stories$/) await captureTransitions(page);
await waitForTransition(page) await page.getByRole("link", { name: /Stories/ }).click();
expect(await recordedTransitions(page)).toContainEqual({ direction: 'forward', presentation: 'slide' }) await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
expect(await recordedTransitions(page)).toContainEqual({
direction: "forward",
presentation: "slide",
});
await captureTransitions(page) await captureTransitions(page);
await page.getByRole('link', { name: /Inbox/ }).click() await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/) await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page) await waitForTransition(page);
expect(await recordedTransitions(page)).toContainEqual({ direction: 'back', presentation: 'slide' }) expect(await recordedTransitions(page)).toContainEqual({
direction: "back",
presentation: "slide",
});
await captureTransitions(page) await captureTransitions(page);
await page.getByRole('link', { name: /Inbox/ }).click() await page.getByRole("link", { name: /Inbox/ }).click();
await page.waitForTimeout(100) await page.waitForTimeout(100);
expect(await recordedTransitions(page)).toEqual([]) expect(await recordedTransitions(page)).toEqual([]);
await expect(page).toHaveURL(/\/inbox$/) await expect(page).toHaveURL(/\/inbox$/);
}) });
test('interrupts an active tab animation when another tab is tapped', async ({ page }) => { test("interrupts an active tab animation when another tab is tapped", async ({
await page.goto('/inbox') page,
const routerView = page.locator('.nvr-router-view') }) => {
await page.getByRole('link', { name: /Stories/ }).click() await page.goto("/inbox");
await expect(routerView).toHaveClass(/nvr-router-view--interactive/) const routerView = page.locator(".nvr-router-view");
const firstTransaction = await routerView.getAttribute('data-native-transaction') await page.getByRole("link", { name: /Stories/ }).click();
expect(firstTransaction).not.toBeNull() await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
const firstTransaction = await routerView.getAttribute(
"data-native-transaction",
);
expect(firstTransaction).not.toBeNull();
await page.getByRole('link', { name: /You/ }).click() await page.getByRole("link", { name: /You/ }).click();
await expect(routerView).not.toHaveAttribute('data-native-transaction', firstTransaction!, { timeout: 250 }) await expect(routerView).not.toHaveAttribute(
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/stories') "data-native-transaction",
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/profile') firstTransaction!,
await expect(page).toHaveURL(/\/profile$/) { timeout: 250 },
}) );
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
"data-native-route",
"/stories",
);
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
"data-native-route",
"/profile",
);
await expect(page).toHaveURL(/\/profile$/);
});
test('interrupts a settling push animation with an edge-back gesture', async ({ page }) => { test("interrupts a settling push animation with an edge-back gesture", async ({
await page.goto('/inbox') page,
await page.getByText('Maya Chen').last().click() }) => {
await expect(page).toHaveURL(/\/chat\/maya$/) await page.goto("/inbox");
const routerView = page.locator('.nvr-router-view') await page.getByText("Maya Chen").last().click();
await expect(routerView).toHaveClass(/nvr-router-view--interactive/) await expect(page).toHaveURL(/\/chat\/maya$/);
const pushTransaction = await routerView.getAttribute('data-native-transaction') const routerView = page.locator(".nvr-router-view");
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
const pushTransaction = await routerView.getAttribute(
"data-native-transaction",
);
const frame = await page.locator('.app-frame').boundingBox() const frame = await page.locator(".app-frame").boundingBox();
if (!frame) throw new Error('App frame did not render') if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5) await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await page.mouse.down() await page.mouse.down();
await page.mouse.move(frame.x + frame.width * 0.34, frame.y + frame.height * 0.5, { steps: 16 }) await page.mouse.move(
frame.x + frame.width * 0.34,
frame.y + frame.height * 0.5,
{ steps: 16 },
);
await expect(routerView).not.toHaveAttribute('data-native-transaction', pushTransaction!, { timeout: 250 }) await expect(routerView).not.toHaveAttribute(
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/chat/maya') "data-native-transaction",
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/inbox') pushTransaction!,
await page.mouse.up() { timeout: 250 },
}) );
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
"data-native-route",
"/chat/maya",
);
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
"data-native-route",
"/inbox",
);
await page.mouse.up();
});
test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({ page }) => { test("moves sibling screens edge-to-edge at one-to-one drag progress", async ({
await page.goto('/stories') page,
const routerView = page.locator('.nvr-router-view') }) => {
const frame = await routerView.boundingBox() await page.goto("/stories");
if (!frame) throw new Error('Native router view did not render') const routerView = page.locator(".nvr-router-view");
const frame = await routerView.boundingBox();
if (!frame) throw new Error("Native router view did not render");
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.45) await page.mouse.move(
await page.mouse.down() frame.x + frame.width * 0.55,
await page.mouse.move(frame.x + frame.width * 0.8, frame.y + frame.height * 0.45, { steps: 18 }) frame.y + frame.height * 0.45,
await expect(routerView).toHaveAttribute('data-native-presentation', 'slide') );
await expect(routerView).toHaveAttribute('data-native-direction', 'back') await page.mouse.down();
await page.mouse.move(
frame.x + frame.width * 0.8,
frame.y + frame.height * 0.45,
{ steps: 18 },
);
await expect(routerView).toHaveAttribute("data-native-presentation", "slide");
await expect(routerView).toHaveAttribute("data-native-direction", "back");
const from = await page.locator('[data-native-role="from"]').boundingBox() const from = await page.locator('[data-native-role="from"]').boundingBox();
const to = await page.locator('[data-native-role="to"]').boundingBox() const to = await page.locator('[data-native-role="to"]').boundingBox();
if (!from || !to) throw new Error('Both sibling pages must be live during a drag') if (!from || !to)
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3) throw new Error("Both sibling pages must be live during a drag");
await page.mouse.up() expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3);
}) await page.mouse.up();
});
test('accepts a second fast tab flick while the first spring is still settling', async ({ page }) => { test("accepts a second fast tab flick while the first spring is still settling", async ({
await page.goto('/inbox') page,
const routerView = page.locator('.nvr-router-view') }) => {
await page.goto("/inbox");
const routerView = page.locator(".nvr-router-view");
// Commit by distance with a deliberately slow final sample, leaving enough // Commit by distance with a deliberately slow final sample, leaving enough
// baseline spring for the second fast gesture to interrupt deterministically. // baseline spring for the second fast gesture to interrupt deterministically.
await flickToNextTab(page, true) await flickToNextTab(page, true);
await expect(page).toHaveURL(/\/stories$/) await expect(page).toHaveURL(/\/stories$/);
await expect(routerView).toHaveClass(/nvr-router-view--interactive/) await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
await flickToNextTab(page) await flickToNextTab(page);
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
// A stale pointer-up cleanup used to leave an orphaned transaction here, // A stale pointer-up cleanup used to leave an orphaned transaction here,
// permanently blocking both subsequent swipes and imperative tab links. // permanently blocking both subsequent swipes and imperative tab links.
await page.getByRole('link', { name: /Inbox/ }).click() await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/) await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page) await waitForTransition(page);
await expect(routerView).not.toHaveAttribute('data-native-transaction') await expect(routerView).not.toHaveAttribute("data-native-transaction");
}) });
test('renders a suspended pushed sibling and evicts it after backing out', async ({ page }) => { test("renders a suspended pushed sibling and evicts it after backing out", async ({
await page.goto('/profile') page,
await page.getByRole('link', { name: /Runtime stress lab/ }).click() }) => {
await page.goto("/profile");
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page.getByTestId('async-data-loading')).toBeVisible() await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/) await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible() await expect(
await expect(page.getByRole('link', { name: /You/ })).toHaveAttribute('aria-current', 'page') page.getByRole("navigation", { name: "Primary navigation" }),
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 }) ).toBeVisible();
await expect(page.getByRole("link", { name: /You/ })).toHaveAttribute(
"aria-current",
"page",
);
await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000,
});
const lab = page.getByTestId('runtime-lab-view') const lab = page.getByTestId("runtime-lab-view");
const firstMountId = await lab.getAttribute('data-mount-id') const firstMountId = await lab.getAttribute("data-mount-id");
await page.getByRole('button', { name: 'Back' }).click() await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
// Popping a pushed route removes it after the exit animation. Keeping the // Popping a pushed route removes it after the exit animation. Keeping the
// descriptor allows browser-forward navigation without retaining its DOM. // descriptor allows browser-forward navigation without retaining its DOM.
await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0) await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
// browser forward exists only because this sibling opted into push history. // browser forward exists only because this sibling opted into push history.
await page.evaluate(() => history.forward()) await page.evaluate(() => history.forward());
await expect(page).toHaveURL(/\/profile\/runtime-lab$/) await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(page.getByTestId('async-data-loading')).toBeVisible() await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 }) await expect(page.getByTestId("async-data-ready")).toBeVisible({
await expect(page.getByTestId('runtime-lab-view')).not.toHaveAttribute('data-mount-id', firstMountId!) timeout: 2_000,
}) });
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
"data-mount-id",
firstMountId!,
);
});
test('clicking the root tab from a pushed sibling collapses its back history', async ({ page }) => { test("clicking the root tab from a pushed sibling collapses its back history", async ({
await page.goto('/profile') page,
await page.getByRole('link', { name: /Runtime stress lab/ }).click() }) => {
await expect(page).toHaveURL(/\/profile\/runtime-lab$/) await page.goto("/profile");
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await page.getByRole('link', { name: /You/ }).click() await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
await expect(page.locator('.nvr-navigator')).toHaveAttribute('data-native-can-go-back', 'false') await expect(page.locator(".nvr-navigator")).toHaveAttribute(
await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0) "data-native-can-go-back",
}) "false",
);
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
});
test('lazily caches a visited sibling and pauses its active work while hidden', async ({ page }) => { test("lazily caches a visited sibling and pauses its active work while hidden", async ({
await page.goto('/stories') page,
const stories = page.getByTestId('stories-view') }) => {
const mountId = await stories.getAttribute('data-mount-id') await page.goto("/stories");
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(1) const stories = page.getByTestId("stories-view");
const mountId = await stories.getAttribute("data-mount-id");
await expect
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
.toBeGreaterThan(1);
await page.getByRole('link', { name: /You/ }).click() await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
await expect(stories).toHaveCount(1) await expect(stories).toHaveCount(1);
const hiddenTicks = Number(await stories.getAttribute('data-active-ticks')) const hiddenTicks = Number(await stories.getAttribute("data-active-ticks"));
await page.waitForTimeout(600) await page.waitForTimeout(600);
await expect(stories).toHaveAttribute('data-active-ticks', String(hiddenTicks)) await expect(stories).toHaveAttribute(
"data-active-ticks",
String(hiddenTicks),
);
await page.getByRole('link', { name: /Stories/ }).click() await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/) await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page) await waitForTransition(page);
await expect(stories).toHaveAttribute('data-mount-id', mountId!) await expect(stories).toHaveAttribute("data-mount-id", mountId!);
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(hiddenTicks) await expect
}) .poll(async () => Number(await stories.getAttribute("data-active-ticks")))
.toBeGreaterThan(hiddenTicks);
});
test('evicts a cached sibling when its dynamic entry guard rejects it', async ({ page }) => { test("evicts a cached sibling when its dynamic entry guard rejects it", async ({
await page.goto('/stories') page,
const stories = page.getByTestId('stories-view') }) => {
const firstMountId = await stories.getAttribute('data-mount-id') await page.goto("/stories");
const stories = page.getByTestId("stories-view");
const firstMountId = await stories.getAttribute("data-mount-id");
await page.getByRole('link', { name: /You/ }).click() await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
await expect(stories).toHaveCount(1) await expect(stories).toHaveCount(1);
const guardToggle = page.getByRole('button', { name: 'Block Stories re-entry' }) const guardToggle = page.getByRole("button", {
await guardToggle.click() name: "Block Stories re-entry",
await expect(guardToggle).toHaveAttribute('aria-pressed', 'true') });
await page.getByRole('link', { name: /Stories/ }).click() await guardToggle.click();
await expect(guardToggle).toHaveAttribute("aria-pressed", "true");
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByTestId('story-guard-status')).toHaveText('blocked') await expect(page.getByTestId("story-guard-status")).toHaveText("blocked");
await waitForTransition(page) await waitForTransition(page);
await expect(page.getByTestId('stories-view')).toHaveCount(0) await expect(page.getByTestId("stories-view")).toHaveCount(0);
await guardToggle.click() await guardToggle.click();
await page.getByRole('link', { name: /Stories/ }).click() await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/) await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page) await waitForTransition(page);
await expect(page.getByTestId('stories-view')).not.toHaveAttribute('data-mount-id', firstMountId!) await expect(page.getByTestId("stories-view")).not.toHaveAttribute(
}) "data-mount-id",
firstMountId!,
);
});
test('manually unloads an inactive route through the public API demo', async ({ page }) => { test("manually unloads an inactive route through the public API demo", async ({
await page.goto('/stories') page,
await page.getByRole('link', { name: /You/ }).click() }) => {
await expect(page).toHaveURL(/\/profile$/) await page.goto("/stories");
await waitForTransition(page) await page.getByRole("link", { name: /You/ }).click();
await expect(page.getByTestId('stories-view')).toHaveCount(1) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(page.getByTestId("stories-view")).toHaveCount(1);
await page.getByRole('link', { name: /Navigation lab/ }).click() await page.getByRole("link", { name: /Navigation lab/ }).click();
await expect(page).toHaveURL(/\/settings$/) await expect(page).toHaveURL(/\/settings$/);
await waitForTransition(page) await waitForTransition(page);
await page.getByTestId('unload-stories').click() await page.getByTestId("unload-stories").click();
await expect(page.getByTestId('unload-stories')).toHaveText('Unloaded 1 Stories view') await expect(page.getByTestId("unload-stories")).toHaveText(
await expect(page.getByTestId('stories-view')).toHaveCount(0) "Unloaded 1 Stories view",
}) );
await expect(page.getByTestId("stories-view")).toHaveCount(0);
});
test('records a navigation frame profile across route changes', async ({ page }) => { test("records a navigation frame profile across route changes", async ({
await page.goto('/settings') page,
await page.getByTestId('profile-start').click() }) => {
await expect(page.locator('.profiler-badge')).toBeVisible() await page.goto("/settings");
await page.getByTestId("profile-start").click();
await expect(page.locator(".profiler-badge")).toBeVisible();
await page.getByRole('button', { name: 'Back' }).click() await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
await page.getByRole('link', { name: /Runtime stress lab/ }).click() await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/) await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await page.getByRole('link', { name: /You/ }).click() await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/) await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page) await waitForTransition(page);
await page.getByRole('link', { name: /Navigation lab/ }).click() await page.getByRole("link", { name: /Navigation lab/ }).click();
await expect(page).toHaveURL(/\/settings$/) await expect(page).toHaveURL(/\/settings$/);
await waitForTransition(page) await waitForTransition(page);
await page.getByTestId('profile-stop').click() await page.getByTestId("profile-stop").click();
await expect(page.locator('.profiler-badge')).toHaveCount(0) await expect(page.locator(".profiler-badge")).toHaveCount(0);
await expect(page.getByTestId('profile-status')).toContainText('navigations') await expect(page.getByTestId("profile-status")).toContainText("navigations");
await expect(page.getByTestId('profile-export')).toBeEnabled() await expect(page.getByTestId("profile-export")).toBeEnabled();
}) });
test('opens and dismisses the compose sheet', async ({ page }) => { test("opens and dismisses the compose sheet", async ({ page }) => {
await page.goto('/inbox') await page.goto("/inbox");
await page.getByRole('button', { name: 'Compose' }).click() await page.getByRole("button", { name: "Compose" }).click();
await expect(page).toHaveURL(/\/compose$/) await expect(page).toHaveURL(/\/compose$/);
await expect(page.getByRole('heading', { name: 'New message' })).toBeVisible() await expect(
await page.getByRole('button', { name: 'Cancel' }).click() page.getByRole("heading", { name: "New message" }),
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() ).toBeVisible();
}) await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test('keeps the target live during a held component drag', async ({ page }) => { test("keeps the target live during a held component drag", async ({ page }) => {
await page.goto('/inbox') await page.goto("/inbox");
const row = page.locator('.conversation-row').first() const row = page.locator(".conversation-row").first();
const box = await row.boundingBox() const box = await row.boundingBox();
if (!box) throw new Error('Conversation row did not render') if (!box) throw new Error("Conversation row did not render");
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2) await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
await page.mouse.down() await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, { steps: 12 }) await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, {
await expect(page.locator('[data-native-role="to"]')).toBeVisible() steps: 12,
await page.mouse.up() });
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible() await expect(page.locator('[data-native-role="to"]')).toBeVisible();
}) await page.mouse.up();
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
});
test('holds an edge-back preview without committing the URL', async ({ page }) => { test("holds an edge-back preview without committing the URL", async ({
await page.goto('/inbox') page,
await page.getByText('Maya Chen').last().click() }) => {
await expect(page).toHaveURL(/\/chat\/maya$/) await page.goto("/inbox");
const frame = await page.locator('.app-frame').boundingBox() await page.getByText("Maya Chen").last().click();
if (!frame) throw new Error('App frame did not render') await expect(page).toHaveURL(/\/chat\/maya$/);
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5) const frame = await page.locator(".app-frame").boundingBox();
await page.mouse.down() if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.5, { steps: 14 }) await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await expect(page.locator('[data-native-role="to"]')).toBeVisible() await page.mouse.down();
await expect(page).toHaveURL(/\/chat\/maya$/) await page.mouse.move(
await page.mouse.up() frame.x + frame.width * 0.55,
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() frame.y + frame.height * 0.5,
}) { steps: 14 },
);
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
await expect(page).toHaveURL(/\/chat\/maya$/);
await page.mouse.up();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test('never previews a stale conversation after a cancelled back gesture', async ({ page }) => { test("never previews a stale conversation after a cancelled back gesture", async ({
await page.goto('/inbox') page,
await page.getByText('Maya Chen').last().click() }) => {
await page.getByRole('button', { name: 'Back' }).click() await page.goto("/inbox");
await expect(page).toHaveURL(/\/inbox$/) await page.getByText("Maya Chen").last().click();
await waitForTransition(page) await page.getByRole("button", { name: "Back" }).click();
await page.getByText('Noah Williams').last().click() await expect(page).toHaveURL(/\/inbox$/);
await expect(page).toHaveURL(/\/chat\/noah$/) await waitForTransition(page);
await waitForTransition(page) await page.getByText("Noah Williams").last().click();
await expect(page).toHaveURL(/\/chat\/noah$/);
await waitForTransition(page);
const frame = await page.locator('.app-frame').boundingBox() const frame = await page.locator(".app-frame").boundingBox();
if (!frame) throw new Error('App frame did not render') if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5) await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await page.mouse.down() await page.mouse.down();
await page.mouse.move(frame.x + frame.width * 0.055, frame.y + frame.height * 0.5, { steps: 16 }) await page.mouse.move(
await page.mouse.up() frame.x + frame.width * 0.055,
await waitForTransition(page) frame.y + frame.height * 0.5,
await expect(page).toHaveURL(/\/chat\/noah$/) { steps: 16 },
);
await page.mouse.up();
await waitForTransition(page);
await expect(page).toHaveURL(/\/chat\/noah$/);
await page.getByRole('button', { name: 'Back' }).click() await page.getByRole("button", { name: "Back" }).click();
const backTarget = page.locator('[data-native-role="to"]') const backTarget = page.locator('[data-native-role="to"]');
await expect(backTarget.getByRole('heading', { name: 'Messages' })).toBeVisible() await expect(
await expect(backTarget.getByRole('heading', { name: 'Maya Chen' })).toHaveCount(0) backTarget.getByRole("heading", { name: "Messages" }),
await waitForTransition(page) ).toBeVisible();
await expect(page).toHaveURL(/\/inbox$/) await expect(
}) backTarget.getByRole("heading", { name: "Maya Chen" }),
).toHaveCount(0);
await waitForTransition(page);
await expect(page).toHaveURL(/\/inbox$/);
});
test('always opens compose as a vertical sheet after prior navigation', async ({ page }) => { test("always opens compose as a vertical sheet after prior navigation", async ({
await page.goto('/inbox') page,
await page.getByText('Maya Chen').last().click() }) => {
await page.getByRole('button', { name: 'Back' }).click() await page.goto("/inbox");
await expect(page).toHaveURL(/\/inbox$/) await page.getByText("Maya Chen").last().click();
await waitForTransition(page) await page.getByRole("button", { name: "Back" }).click();
await page.getByRole('link', { name: /Stories/ }).click() await expect(page).toHaveURL(/\/inbox$/);
await expect(page).toHaveURL(/\/stories$/) await waitForTransition(page);
await waitForTransition(page) await page.getByRole("link", { name: /Stories/ }).click();
await page.getByRole('link', { name: /Inbox/ }).click() await expect(page).toHaveURL(/\/stories$/);
await expect(page).toHaveURL(/\/inbox$/) await waitForTransition(page);
await waitForTransition(page) await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await page.getByRole('button', { name: 'Compose' }).click() await page.getByRole("button", { name: "Compose" }).click();
const routerView = page.locator('.nvr-router-view') const routerView = page.locator(".nvr-router-view");
await expect(routerView).toHaveAttribute('data-native-presentation', 'sheet') await expect(routerView).toHaveAttribute("data-native-presentation", "sheet");
await expect(routerView).toHaveAttribute('data-native-direction', 'up') await expect(routerView).toHaveAttribute("data-native-direction", "up");
const frame = await routerView.boundingBox() const frame = await routerView.boundingBox();
const sheet = await page.locator('[data-native-role="to"]').boundingBox() const sheet = await page.locator('[data-native-role="to"]').boundingBox();
if (!frame || !sheet) throw new Error('Sheet transition did not render') if (!frame || !sheet) throw new Error("Sheet transition did not render");
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3) expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3);
await waitForTransition(page) await waitForTransition(page);
await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole("button", { name: "Cancel" }).click();
}) });
test('drags a sheet down to dismiss it', async ({ page }) => { test("drags a sheet down to dismiss it", async ({ page }) => {
await page.goto('/inbox') await page.goto("/inbox");
await page.getByRole('button', { name: 'Compose' }).click() await page.getByRole("button", { name: "Compose" }).click();
const sheet = await page.locator('.sheet-screen').boundingBox() const sheet = await page.locator(".sheet-screen").boundingBox();
if (!sheet) throw new Error('Sheet did not render') if (!sheet) throw new Error("Sheet did not render");
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12) await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
await page.mouse.down() await page.mouse.down();
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + sheet.height * 0.55, { steps: 14 }) await page.mouse.move(
await page.mouse.up() sheet.x + sheet.width / 2,
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible() sheet.y + sheet.height * 0.55,
}) { steps: 14 },
);
await page.mouse.up();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});

View File

@@ -1,92 +1,157 @@
import { expect, test } from '@playwright/test' import { expect, test } from "@playwright/test";
const iphoneUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1' const iphoneUserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1";
async function dispatchTouchStart(page: import('@playwright/test').Page, clientX: number) { async function dispatchTouchStart(
return await page.locator('.nvr-navigator').evaluate((element, x) => { page: import("@playwright/test").Page,
const event = new Event('touchstart', { bubbles: true, cancelable: true }) clientX: number,
Object.defineProperty(event, 'touches', { ) {
return await page.locator(".nvr-navigator").evaluate((element, x) => {
const event = new Event("touchstart", { bubbles: true, cancelable: true });
Object.defineProperty(event, "touches", {
value: [{ identifier: 7, clientX: x, clientY: 240 }], value: [{ identifier: 7, clientX: x, clientY: 240 }],
}) });
return { dispatched: element.dispatchEvent(event), prevented: event.defaultPrevented } return {
}, clientX) dispatched: element.dispatchEvent(event),
prevented: event.defaultPrevented,
};
}, clientX);
} }
test('ships an installable standalone manifest and iOS metadata', async ({ page, request }) => { test("ships an installable standalone manifest and iOS metadata", async ({
await page.goto('/inbox') page,
await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes') request,
await expect(page.locator('meta[name="apple-mobile-web-app-status-bar-style"]')).toHaveAttribute('content', 'black-translucent') }) => {
const touchIconPath = await page.locator('link[rel="apple-touch-icon"]').evaluate((link) => await page.goto("/inbox");
new URL(link.getAttribute('href') ?? '', document.baseURI).pathname, await expect(
) page.locator('meta[name="apple-mobile-web-app-capable"]'),
expect(touchIconPath).toBe('/apple-touch-icon.png') ).toHaveAttribute("content", "yes");
await expect(
page.locator('meta[name="apple-mobile-web-app-status-bar-style"]'),
).toHaveAttribute("content", "black-translucent");
const touchIconPath = await page
.locator('link[rel="apple-touch-icon"]')
.evaluate(
(link) =>
new URL(link.getAttribute("href") ?? "", document.baseURI).pathname,
);
expect(touchIconPath).toBe("/apple-touch-icon.png");
const manifestResponse = await request.get('/manifest.webmanifest') const manifestResponse = await request.get("/manifest.webmanifest");
expect(manifestResponse.ok()).toBe(true) expect(manifestResponse.ok()).toBe(true);
const manifest = await manifestResponse.json() const manifest = await manifestResponse.json();
expect(manifest).toMatchObject({ id: '/', scope: '/', start_url: '/', display: 'standalone' }) expect(manifest).toMatchObject({
expect(manifest.icons).toEqual(expect.arrayContaining([ id: "/",
expect.objectContaining({ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }), scope: "/",
expect.objectContaining({ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' }), start_url: "/",
])) display: "standalone",
expect((await request.get('/apple-touch-icon.png')).headers()['content-type']).toContain('image/png') });
const workerResponse = await request.get('/sw.js') expect(manifest.icons).toEqual(
expect(workerResponse.ok()).toBe(true) expect.arrayContaining([
expect(workerResponse.headers()['cache-control']).toContain('no-cache') expect.objectContaining({
const worker = await workerResponse.text() src: "/pwa-192.png",
expect(worker).toContain('self.skipWaiting()') sizes: "192x192",
expect(worker).toContain('clientsClaim()') type: "image/png",
}) }),
expect.objectContaining({
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
}),
]),
);
expect(
(await request.get("/apple-touch-icon.png")).headers()["content-type"],
).toContain("image/png");
const workerResponse = await request.get("/sw.js");
expect(workerResponse.ok()).toBe(true);
expect(workerResponse.headers()["cache-control"]).toContain("no-cache");
const worker = await workerResponse.text();
expect(worker).toContain("self.skipWaiting()");
expect(worker).toContain("clientsClaim()");
});
test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => { test("does not interfere with Safari edge touches before Home Screen installation", async ({
page,
}) => {
await page.addInitScript((userAgent) => { await page.addInitScript((userAgent) => {
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) Object.defineProperty(navigator, "userAgent", {
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false }) configurable: true,
}, iphoneUserAgent) value: userAgent,
await page.goto('/chat/maya') });
await expect(page.locator('html')).toHaveAttribute('data-pwa-display-mode', 'browser') Object.defineProperty(navigator, "standalone", {
await expect(page.locator('html')).toHaveAttribute('data-pwa-edge-guard', 'inactive') configurable: true,
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false) value: false,
}) });
}, iphoneUserAgent);
await page.goto("/chat/maya");
await expect(page.locator("html")).toHaveAttribute(
"data-pwa-display-mode",
"browser",
);
await expect(page.locator("html")).toHaveAttribute(
"data-pwa-edge-guard",
"inactive",
);
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false);
});
test('reserves only the leading edge in an installed iOS PWA', async ({ page }) => { test("reserves only the leading edge in an installed iOS PWA", async ({
page,
}) => {
await page.addInitScript((userAgent) => { await page.addInitScript((userAgent) => {
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) Object.defineProperty(navigator, "userAgent", {
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) configurable: true,
}, iphoneUserAgent) value: userAgent,
await page.goto('/chat/maya') });
const root = page.locator('html') Object.defineProperty(navigator, "standalone", {
await expect(root).toHaveAttribute('data-pwa-platform', 'ios') configurable: true,
await expect(root).toHaveAttribute('data-pwa-display-mode', 'standalone') value: true,
await expect(root).toHaveAttribute('data-pwa-edge-guard', 'active') });
}, iphoneUserAgent);
await page.goto("/chat/maya");
const root = page.locator("html");
await expect(root).toHaveAttribute("data-pwa-platform", "ios");
await expect(root).toHaveAttribute("data-pwa-display-mode", "standalone");
await expect(root).toHaveAttribute("data-pwa-edge-guard", "active");
expect((await dispatchTouchStart(page, 80)).prevented).toBe(false) expect((await dispatchTouchStart(page, 80)).prevented).toBe(false);
expect((await dispatchTouchStart(page, 1)).prevented).toBe(true) expect((await dispatchTouchStart(page, 1)).prevented).toBe(true);
await expect(root).toHaveAttribute('data-pwa-edge-claims', '1') await expect(root).toHaveAttribute("data-pwa-edge-claims", "1");
}) });
test('registers and activates the offline service worker', async ({ page }) => { test("registers and activates the offline service worker", async ({ page }) => {
await page.goto('/inbox') await page.goto("/inbox");
const workerUrl = await page.evaluate(async () => { const workerUrl = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready const registration = await navigator.serviceWorker.ready;
return registration.active?.scriptURL ?? '' return registration.active?.scriptURL ?? "";
}) });
expect(workerUrl).toMatch(/\/sw\.js$/) expect(workerUrl).toMatch(/\/sw\.js$/);
await expect(page.locator('html')).not.toHaveAttribute('data-pwa-update-checks', '0') await expect(page.locator("html")).not.toHaveAttribute(
}) "data-pwa-update-checks",
"0",
);
});
test('precaches lazily split routes for offline navigation', async ({ page }) => { test("precaches lazily split routes for offline navigation", async ({
await page.goto('/inbox') page,
await page.evaluate(async () => { await navigator.serviceWorker.ready }) }) => {
await page.goto("/inbox");
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
const cachedUrls = await page.evaluate(async () => { const cachedUrls = await page.evaluate(async () => {
const urls: string[] = [] const urls: string[] = [];
for (const name of await caches.keys()) { for (const name of await caches.keys()) {
const cache = await caches.open(name) const cache = await caches.open(name);
urls.push(...(await cache.keys()).map((request) => request.url)) urls.push(...(await cache.keys()).map((request) => request.url));
} }
return urls return urls;
}) });
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true) expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true);
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true) expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true);
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(true) expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(
}) true,
);
});

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const requestedAt = Date.now() const requestedAt = Date.now()
await new Promise((resolve) => window.setTimeout(resolve, 1_000)) await new Promise((resolve) => window.setTimeout(resolve, 3_000))
const resolutionTime = Date.now() - requestedAt const resolutionTime = Date.now() - requestedAt
</script> </script>

View File

@@ -1,103 +1,207 @@
import { computed, reactive, readonly, ref } from 'vue' import { computed, reactive, readonly, ref } from "vue";
export interface Person { export interface Person {
id: string id: string;
name: string name: string;
handle: string handle: string;
color: string color: string;
online: boolean online: boolean;
bio: string bio: string;
} }
export interface Message { export interface Message {
id: string id: string;
personId: string personId: string;
body: string body: string;
sentAt: number sentAt: number;
mine: boolean mine: boolean;
status: 'sent' | 'delivered' | 'failed' status: "sent" | "delivered" | "failed";
} }
export interface Conversation { export interface Conversation {
id: string id: string;
personId: string personId: string;
unread: number unread: number;
pinned?: boolean pinned?: boolean;
messages: Message[] messages: Message[];
} }
const people: Person[] = [ const people: Person[] = [
{ id: 'maya', name: 'Maya Chen', handle: '@mayac', color: '#ff7a8a', online: true, bio: 'Product designer · Sydney to everywhere.' }, {
{ id: 'noah', name: 'Noah Williams', handle: '@noahw', color: '#4f9cff', online: true, bio: 'Film, tiny cameras, and very long walks.' }, id: "maya",
{ id: 'sofia', name: 'Sofia Rossi', handle: '@sofiar', color: '#9d67ff', online: false, bio: 'Making typefaces and better pasta.' }, name: "Maya Chen",
{ id: 'liam', name: 'Liam Park', handle: '@liamp', color: '#35c7a0', online: true, bio: 'Engineer. Climber. Questionable DJ.' }, handle: "@mayac",
{ id: 'amara', name: 'Amara Okafor', handle: '@amarao', color: '#f4ad42', online: false, bio: 'Architecture and cities after dark.' }, color: "#ff7a8a",
] online: true,
bio: "Product designer · Sydney to everywhere.",
},
{
id: "noah",
name: "Noah Williams",
handle: "@noahw",
color: "#4f9cff",
online: true,
bio: "Film, tiny cameras, and very long walks.",
},
{
id: "sofia",
name: "Sofia Rossi",
handle: "@sofiar",
color: "#9d67ff",
online: false,
bio: "Making typefaces and better pasta.",
},
{
id: "liam",
name: "Liam Park",
handle: "@liamp",
color: "#35c7a0",
online: true,
bio: "Engineer. Climber. Questionable DJ.",
},
{
id: "amara",
name: "Amara Okafor",
handle: "@amarao",
color: "#f4ad42",
online: false,
bio: "Architecture and cities after dark.",
},
];
const timestamp = Date.now() const timestamp = Date.now();
const seedConversations: Conversation[] = [ const seedConversations: Conversation[] = [
{ {
id: 'maya', personId: 'maya', unread: 2, pinned: true, id: "maya",
personId: "maya",
unread: 2,
pinned: true,
messages: [ messages: [
{ id: 'm1', personId: 'maya', body: 'That transition feels ridiculously smooth ✨', sentAt: timestamp - 840_000, mine: false, status: 'delivered' }, {
{ id: 'm2', personId: 'maya', body: 'Try holding it halfway, then let go slowly.', sentAt: timestamp - 780_000, mine: false, status: 'delivered' }, id: "m1",
personId: "maya",
body: "That transition feels ridiculously smooth ✨",
sentAt: timestamp - 840_000,
mine: false,
status: "delivered",
},
{
id: "m2",
personId: "maya",
body: "Try holding it halfway, then let go slowly.",
sentAt: timestamp - 780_000,
mine: false,
status: "delivered",
},
], ],
}, },
{ {
id: 'noah', personId: 'noah', unread: 0, id: "noah",
personId: "noah",
unread: 0,
messages: [ messages: [
{ id: 'n1', personId: 'noah', body: 'Uploaded the photos from yesterday.', sentAt: timestamp - 4_200_000, mine: false, status: 'delivered' }, {
{ id: 'n2', personId: 'noah', body: 'The grain is perfect.', sentAt: timestamp - 4_000_000, mine: true, status: 'delivered' }, id: "n1",
personId: "noah",
body: "Uploaded the photos from yesterday.",
sentAt: timestamp - 4_200_000,
mine: false,
status: "delivered",
},
{
id: "n2",
personId: "noah",
body: "The grain is perfect.",
sentAt: timestamp - 4_000_000,
mine: true,
status: "delivered",
},
], ],
}, },
{ {
id: 'sofia', personId: 'sofia', unread: 1, id: "sofia",
messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }], personId: "sofia",
unread: 1,
messages: [
{
id: "s1",
personId: "sofia",
body: "Coffee at the new place tomorrow?",
sentAt: timestamp - 18_000_000,
mine: false,
status: "delivered",
},
],
}, },
{ {
id: 'liam', personId: 'liam', unread: 0, id: "liam",
messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }], personId: "liam",
unread: 0,
messages: [
{
id: "l1",
personId: "liam",
body: "The build is green. Ship it.",
sentAt: timestamp - 86_000_000,
mine: false,
status: "delivered",
},
],
}, },
{ {
id: 'amara', personId: 'amara', unread: 0, id: "amara",
messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }], personId: "amara",
unread: 0,
messages: [
{
id: "a1",
personId: "amara",
body: "This city never really goes quiet.",
sentAt: timestamp - 172_000_000,
mine: false,
status: "delivered",
},
],
}, },
] ];
const conversations = ref<Conversation[]>(structuredClone(seedConversations)) const conversations = ref<Conversation[]>(structuredClone(seedConversations));
const ready = ref(false) const ready = ref(false);
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine);
const settings = reactive({ simulatedLatency: 180, simulateFailures: false }) const settings = reactive({ simulatedLatency: 180, simulateFailures: false });
function openDatabase() { function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => { return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('native-vue-messenger', 1) const request = indexedDB.open("native-vue-messenger", 1);
request.onupgradeneeded = () => request.result.createObjectStore('state') request.onupgradeneeded = () => request.result.createObjectStore("state");
request.onsuccess = () => resolve(request.result) request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error);
}) });
} }
async function readStored() { async function readStored() {
const database = await openDatabase() const database = await openDatabase();
return await new Promise<Conversation[] | undefined>((resolve, reject) => { return await new Promise<Conversation[] | undefined>((resolve, reject) => {
const transaction = database.transaction('state', 'readonly') const transaction = database.transaction("state", "readonly");
const request = transaction.objectStore('state').get('conversations') const request = transaction.objectStore("state").get("conversations");
request.onsuccess = () => resolve(request.result as Conversation[] | undefined) request.onsuccess = () =>
request.onerror = () => reject(request.error) resolve(request.result as Conversation[] | undefined);
}).finally(() => database.close()) request.onerror = () => reject(request.error);
}).finally(() => database.close());
} }
async function persist() { async function persist() {
try { try {
const database = await openDatabase() const database = await openDatabase();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const transaction = database.transaction('state', 'readwrite') const transaction = database.transaction("state", "readwrite");
transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations') transaction
transaction.oncomplete = () => resolve() .objectStore("state")
transaction.onerror = () => reject(transaction.error) .put(structuredClone(conversations.value), "conversations");
}) transaction.oncomplete = () => resolve();
database.close() transaction.onerror = () => reject(transaction.error);
});
database.close();
} catch { } catch {
// Private browsing and locked-down webviews can reject IndexedDB. // Private browsing and locked-down webviews can reject IndexedDB.
} }
@@ -105,48 +209,58 @@ async function persist() {
async function initialize() { async function initialize() {
try { try {
const stored = await readStored() const stored = await readStored();
if (stored?.length) conversations.value = stored if (stored?.length) conversations.value = stored;
else await persist() else await persist();
} finally { } finally {
ready.value = true ready.value = true;
} }
} }
window.addEventListener('online', () => { offline.value = false }) window.addEventListener("online", () => {
window.addEventListener('offline', () => { offline.value = true }) offline.value = false;
void initialize() });
window.addEventListener("offline", () => {
offline.value = true;
});
void initialize();
export function useDemoStore() { export function useDemoStore() {
const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id)) const conversationFor = (id: string) =>
const personFor = (id: string) => people.find((person) => person.id === id) computed(() =>
conversations.value.find((conversation) => conversation.id === id),
);
const personFor = (id: string) => people.find((person) => person.id === id);
const markRead = (id: string) => { const markRead = (id: string) => {
const conversation = conversations.value.find((item) => item.id === id) const conversation = conversations.value.find((item) => item.id === id);
if (conversation) conversation.unread = 0 if (conversation) conversation.unread = 0;
void persist() void persist();
} };
const sendMessage = async (id: string, body: string) => { const sendMessage = async (id: string, body: string) => {
const conversation = conversations.value.find((item) => item.id === id) const conversation = conversations.value.find((item) => item.id === id);
if (!conversation || !body.trim()) return false if (!conversation || !body.trim()) return false;
const message: Message = { const message: Message = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
personId: id, personId: id,
body: body.trim(), body: body.trim(),
sentAt: Date.now(), sentAt: Date.now(),
mine: true, mine: true,
status: 'sent', status: "sent",
} };
conversation.messages.push(message) conversation.messages.push(message);
await persist() await persist();
await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency)) await new Promise((resolve) =>
message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered' window.setTimeout(resolve, settings.simulatedLatency),
await persist() );
return message.status === 'delivered' message.status =
} settings.simulateFailures || offline.value ? "failed" : "delivered";
await persist();
return message.status === "delivered";
};
const reset = async () => { const reset = async () => {
conversations.value = structuredClone(seedConversations) conversations.value = structuredClone(seedConversations);
await persist() await persist();
} };
return { return {
people: readonly(ref(people)), people: readonly(ref(people)),
conversations: readonly(conversations), conversations: readonly(conversations),
@@ -158,14 +272,14 @@ export function useDemoStore() {
markRead, markRead,
sendMessage, sendMessage,
reset, reset,
} };
} }
export function relativeTime(value: number) { export function relativeTime(value: number) {
const minutes = Math.floor((Date.now() - value) / 60_000) const minutes = Math.floor((Date.now() - value) / 60_000);
if (minutes < 1) return 'now' if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m` if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h` if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d` return `${Math.floor(hours / 24)}d`;
} }

View File

@@ -1,50 +1,50 @@
import { readonly, reactive } from 'vue' import { readonly, reactive } from "vue";
function createGuardState() { function createGuardState() {
return reactive({ return reactive({
blockEntry: false, blockEntry: false,
checks: 0, checks: 0,
status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked', status: "idle" as "idle" | "checking" | "allowed" | "blocked",
}) });
} }
const storyState = createGuardState() const storyState = createGuardState();
const labState = createGuardState() const labState = createGuardState();
export const storyEntryGuard = readonly(storyState) export const storyEntryGuard = readonly(storyState);
export const runtimeLabGuard = readonly(labState) export const runtimeLabGuard = readonly(labState);
export function setStoryEntryBlocked(blocked: boolean) { export function setStoryEntryBlocked(blocked: boolean) {
storyState.blockEntry = blocked storyState.blockEntry = blocked;
if (storyState.status !== 'checking') storyState.status = 'idle' if (storyState.status !== "checking") storyState.status = "idle";
} }
async function evaluate(state: ReturnType<typeof createGuardState>) { async function evaluate(state: ReturnType<typeof createGuardState>) {
state.checks += 1 state.checks += 1;
state.status = 'checking' state.status = "checking";
await new Promise((resolve) => window.setTimeout(resolve, 320)) await new Promise((resolve) => window.setTimeout(resolve, 320));
const allowed = !state.blockEntry const allowed = !state.blockEntry;
state.status = allowed ? 'allowed' : 'blocked' state.status = allowed ? "allowed" : "blocked";
return allowed return allowed;
} }
/** Dynamic guard used to reject a sibling that may already be cached. */ /** Dynamic guard used to reject a sibling that may already be cached. */
export function evaluateStoryEntry() { export function evaluateStoryEntry() {
storyState.checks += 1 storyState.checks += 1;
if (!storyState.blockEntry) { if (!storyState.blockEntry) {
storyState.status = 'allowed' storyState.status = "allowed";
return true return true;
} }
storyState.status = 'checking' storyState.status = "checking";
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
window.setTimeout(() => { window.setTimeout(() => {
storyState.status = 'blocked' storyState.status = "blocked";
resolve(false) resolve(false);
}, 320) }, 320);
}) });
} }
/** Always-allowing asynchronous guard for the deeper stress-lab route. */ /** Always-allowing asynchronous guard for the deeper stress-lab route. */
export function evaluateRuntimeLabEntry() { export function evaluateRuntimeLabEntry() {
return evaluate(labState) return evaluate(labState);
} }

View File

@@ -1,29 +1,32 @@
import { createApp } from 'vue' import { createApp } from "vue";
import { createNativeRouter } from '@native-vue-router/core' import { createNativeRouter } from "@native-vue-router/core";
import { createCapacitorAdapter } from '@native-vue-router/capacitor' import { createCapacitorAdapter } from "@native-vue-router/capacitor";
import { createElectronRendererAdapter } from '@native-vue-router/electron' import { createElectronRendererAdapter } from "@native-vue-router/electron";
import App from './App.vue' import App from "./App.vue";
import { createPwaAdapter } from './pwa' import { createPwaAdapter } from "./pwa";
import { router } from './router' import { router } from "./router";
import './style.css' import "./style.css";
const isElectron = navigator.userAgent.toLowerCase().includes('electron') const isElectron = navigator.userAgent.toLowerCase().includes("electron");
const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true }) const capacitorPlatform = createCapacitorAdapter({
haptics: true,
exitAtRoot: true,
});
const platform = isElectron const platform = isElectron
? createElectronRendererAdapter() ? createElectronRendererAdapter()
: capacitorPlatform.name !== 'capacitor-web' : capacitorPlatform.name !== "capacitor-web"
? capacitorPlatform ? capacitorPlatform
: createPwaAdapter() : createPwaAdapter();
const nativeRouter = createNativeRouter({ const nativeRouter = createNativeRouter({
router, router,
cache: { maxInactive: 4 }, cache: { maxInactive: 4 },
platform, platform,
}) });
const app = createApp(App) const app = createApp(App);
app.use(router) app.use(router);
app.use(nativeRouter) app.use(nativeRouter);
await router.isReady() await router.isReady();
app.mount('#app') app.mount("#app");

View File

@@ -1,66 +1,74 @@
import { ref } from 'vue' import { ref } from "vue";
import { import {
createNativeNavigationProfiler, createNativeNavigationProfiler,
type NativeNavigationProfiler, type NativeNavigationProfiler,
type NativeProfilerReport, type NativeProfilerReport,
type NativeRouterRuntime, type NativeRouterRuntime,
} from '@native-vue-router/core' } from "@native-vue-router/core";
import { pwaBuildId } from './pwa' import { pwaBuildId } from "./pwa";
let profiler: NativeNavigationProfiler | undefined let profiler: NativeNavigationProfiler | undefined;
export const profilerRecording = ref(false) export const profilerRecording = ref(false);
export const profilerHasCapture = ref(false) export const profilerHasCapture = ref(false);
function instance(runtime: NativeRouterRuntime) { function instance(runtime: NativeRouterRuntime) {
profiler ??= createNativeNavigationProfiler(runtime, { profiler ??= createNativeNavigationProfiler(runtime, {
metadata: { app: 'nvr-messenger-demo', build: pwaBuildId }, metadata: { app: "nvr-messenger-demo", build: pwaBuildId },
}) });
return profiler return profiler;
} }
export function startDemoProfile(runtime: NativeRouterRuntime) { export function startDemoProfile(runtime: NativeRouterRuntime) {
const active = instance(runtime) const active = instance(runtime);
active.start() active.start();
profilerRecording.value = true profilerRecording.value = true;
profilerHasCapture.value = true profilerHasCapture.value = true;
} }
export function stopDemoProfile(runtime: NativeRouterRuntime) { export function stopDemoProfile(runtime: NativeRouterRuntime) {
const report = instance(runtime).stop() const report = instance(runtime).stop();
profilerRecording.value = false profilerRecording.value = false;
return report return report;
} }
export function snapshotDemoProfile(runtime: NativeRouterRuntime) { export function snapshotDemoProfile(runtime: NativeRouterRuntime) {
return instance(runtime).snapshot() return instance(runtime).snapshot();
} }
export async function shareDemoProfile(runtime: NativeRouterRuntime, report?: NativeProfilerReport) { export async function shareDemoProfile(
const active = instance(runtime) runtime: NativeRouterRuntime,
const current = report ?? (profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot()) report?: NativeProfilerReport,
const json = active.toJSON(current) ) {
const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') const active = instance(runtime);
const filename = `native-vue-router-profile-${stamp}.json` const current =
const file = new File([json], filename, { type: 'application/json' }) report ??
(profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot());
const json = active.toJSON(current);
const stamp = new Date()
.toISOString()
.replaceAll(":", "-")
.replaceAll(".", "-");
const filename = `native-vue-router-profile-${stamp}.json`;
const file = new File([json], filename, { type: "application/json" });
const shareNavigator = navigator as Navigator & { const shareNavigator = navigator as Navigator & {
canShare?: (data: ShareData) => boolean canShare?: (data: ShareData) => boolean;
share?: (data: ShareData) => Promise<void> share?: (data: ShareData) => Promise<void>;
} };
if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) { if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) {
await shareNavigator.share({ await shareNavigator.share({
title: 'Native Vue Router performance profile', title: "Native Vue Router performance profile",
text: 'Frame pacing and navigation diagnostics. Route params and query values are omitted.', text: "Frame pacing and navigation diagnostics. Route params and query values are omitted.",
files: [file], files: [file],
}) });
return { report: current, method: 'shared' as const } return { report: current, method: "shared" as const };
} }
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file);
const link = document.createElement('a') const link = document.createElement("a");
link.href = url link.href = url;
link.download = filename link.download = filename;
link.click() link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000) window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
return { report: current, method: 'downloaded' as const } return { report: current, method: "downloaded" as const };
} }

View File

@@ -1,62 +1,77 @@
import { reactive, readonly } from 'vue' import { reactive, readonly } from "vue";
import type { NativePlatformAdapter } from '@native-vue-router/core' import type { NativePlatformAdapter } from "@native-vue-router/core";
interface StandaloneNavigator extends Navigator { interface StandaloneNavigator extends Navigator {
standalone?: boolean standalone?: boolean;
} }
type ServiceWorkerState = 'unsupported' | 'installing' | 'ready' type ServiceWorkerState = "unsupported" | "installing" | "ready";
export type PwaUpdateState = 'idle' | 'checking' | 'current' | 'ready' | 'error' export type PwaUpdateState =
"idle" | "checking" | "current" | "ready" | "error";
declare const __NVR_BUILD_ID__: string declare const __NVR_BUILD_ID__: string;
export const pwaBuildId = __NVR_BUILD_ID__ export const pwaBuildId = __NVR_BUILD_ID__;
const state = reactive({ const state = reactive({
ios: false, ios: false,
standalone: false, standalone: false,
edgeGuard: false, edgeGuard: false,
edgeClaims: 0, edgeClaims: 0,
serviceWorker: 'installing' as ServiceWorkerState, serviceWorker: "installing" as ServiceWorkerState,
updateState: 'idle' as PwaUpdateState, updateState: "idle" as PwaUpdateState,
updateChecks: 0, updateChecks: 0,
}) });
export const pwaEnvironment = readonly(state) export const pwaEnvironment = readonly(state);
export function recordPwaUpdateState(updateState: PwaUpdateState, checked = false) { export function recordPwaUpdateState(
state.updateState = updateState updateState: PwaUpdateState,
if (checked) state.updateChecks += 1 checked = false,
document.documentElement.dataset.pwaUpdateState = state.updateState ) {
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks) state.updateState = updateState;
if (checked) state.updateChecks += 1;
document.documentElement.dataset.pwaUpdateState = state.updateState;
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
} }
export function isIOSWebKit() { export function isIOSWebKit() {
const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number } const navigatorWithTouch = navigator as Navigator & {
return /iPad|iPhone|iPod/.test(navigator.userAgent) maxTouchPoints?: number;
|| (/Macintosh/.test(navigator.userAgent) && (navigatorWithTouch.maxTouchPoints ?? 0) > 1) };
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) &&
(navigatorWithTouch.maxTouchPoints ?? 0) > 1)
);
} }
export function isStandalonePwa() { export function isStandalonePwa() {
return Boolean((navigator as StandaloneNavigator).standalone) return (
|| window.matchMedia('(display-mode: standalone)').matches Boolean((navigator as StandaloneNavigator).standalone) ||
|| window.matchMedia('(display-mode: fullscreen)').matches window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches
);
} }
function updateEnvironment() { function updateEnvironment() {
state.ios = isIOSWebKit() state.ios = isIOSWebKit();
state.standalone = isStandalonePwa() state.standalone = isStandalonePwa();
state.edgeGuard = state.ios && state.standalone state.edgeGuard = state.ios && state.standalone;
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other' document.documentElement.dataset.pwaPlatform = state.ios ? "ios" : "other";
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser' document.documentElement.dataset.pwaDisplayMode = state.standalone
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive' ? "standalone"
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims) : "browser";
document.documentElement.dataset.pwaUpdateState = state.updateState document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks) ? "active"
: "inactive";
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims);
document.documentElement.dataset.pwaUpdateState = state.updateState;
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
} }
export interface PwaAdapterOptions { export interface PwaAdapterOptions {
edgeWidth?: number edgeWidth?: number;
} }
/** /**
@@ -66,65 +81,104 @@ export interface PwaAdapterOptions {
* Safari does not expose WKWebView's native gesture switch to web content, * Safari does not expose WKWebView's native gesture switch to web content,
* so this is deliberately scoped to standalone mode and reinforced by CSS. * so this is deliberately scoped to standalone mode and reinforced by CSS.
*/ */
export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatformAdapter { export function createPwaAdapter(
const edgeWidth = options.edgeWidth ?? 32 options: PwaAdapterOptions = {},
): NativePlatformAdapter {
const edgeWidth = options.edgeWidth ?? 32;
return { return {
name: 'pwa', name: "pwa",
install() { install() {
updateEnvironment() updateEnvironment();
const displayMode = window.matchMedia('(display-mode: standalone)') const displayMode = window.matchMedia("(display-mode: standalone)");
const update = () => updateEnvironment() const update = () => updateEnvironment();
displayMode.addEventListener('change', update) displayMode.addEventListener("change", update);
document.addEventListener('visibilitychange', update) document.addEventListener("visibilitychange", update);
if (!('serviceWorker' in navigator)) state.serviceWorker = 'unsupported' if (!("serviceWorker" in navigator)) state.serviceWorker = "unsupported";
else { else {
state.serviceWorker = navigator.serviceWorker.controller ? 'ready' : 'installing' state.serviceWorker = navigator.serviceWorker.controller
void navigator.serviceWorker.ready.then(() => { state.serviceWorker = 'ready' }) ? "ready"
navigator.serviceWorker.addEventListener('controllerchange', updateServiceWorkerState) : "installing";
void navigator.serviceWorker.ready.then(() => {
state.serviceWorker = "ready";
});
navigator.serviceWorker.addEventListener(
"controllerchange",
updateServiceWorkerState,
);
} }
let reservedTouch: number | null = null let reservedTouch: number | null = null;
const touchAtLeadingEdge = (event: TouchEvent) => { const touchAtLeadingEdge = (event: TouchEvent) => {
if (!state.edgeGuard || event.touches.length !== 1) return undefined if (!state.edgeGuard || event.touches.length !== 1) return undefined;
if (!(event.target instanceof Element) || !event.target.closest('.nvr-navigator')) return undefined if (
const touch = event.touches[0] !(event.target instanceof Element) ||
const rtl = getComputedStyle(document.documentElement).direction === 'rtl' !event.target.closest(".nvr-navigator")
const atEdge = rtl ? window.innerWidth - touch.clientX <= edgeWidth : touch.clientX <= edgeWidth )
return atEdge ? touch : undefined return undefined;
} const touch = event.touches[0];
const rtl =
getComputedStyle(document.documentElement).direction === "rtl";
const atEdge = rtl
? window.innerWidth - touch.clientX <= edgeWidth
: touch.clientX <= edgeWidth;
return atEdge ? touch : undefined;
};
const reserveEdge = (event: TouchEvent) => { const reserveEdge = (event: TouchEvent) => {
const touch = touchAtLeadingEdge(event) const touch = touchAtLeadingEdge(event);
if (!touch) return if (!touch) return;
reservedTouch = touch.identifier reservedTouch = touch.identifier;
state.edgeClaims += 1 state.edgeClaims += 1;
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims) document.documentElement.dataset.pwaEdgeClaims = String(
event.preventDefault() state.edgeClaims,
} );
event.preventDefault();
};
const holdEdge = (event: TouchEvent) => { const holdEdge = (event: TouchEvent) => {
if (reservedTouch === null) return if (reservedTouch === null) return;
if ([...event.touches].some((touch) => touch.identifier === reservedTouch)) event.preventDefault() if (
} [...event.touches].some((touch) => touch.identifier === reservedTouch)
const releaseEdge = () => { reservedTouch = null } )
event.preventDefault();
};
const releaseEdge = () => {
reservedTouch = null;
};
document.addEventListener('touchstart', reserveEdge, { capture: true, passive: false }) document.addEventListener("touchstart", reserveEdge, {
document.addEventListener('touchmove', holdEdge, { capture: true, passive: false }) capture: true,
document.addEventListener('touchend', releaseEdge, { capture: true, passive: true }) passive: false,
document.addEventListener('touchcancel', releaseEdge, { capture: true, passive: true }) });
document.addEventListener("touchmove", holdEdge, {
capture: true,
passive: false,
});
document.addEventListener("touchend", releaseEdge, {
capture: true,
passive: true,
});
document.addEventListener("touchcancel", releaseEdge, {
capture: true,
passive: true,
});
return () => { return () => {
displayMode.removeEventListener('change', update) displayMode.removeEventListener("change", update);
document.removeEventListener('visibilitychange', update) document.removeEventListener("visibilitychange", update);
if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('controllerchange', updateServiceWorkerState) if ("serviceWorker" in navigator)
document.removeEventListener('touchstart', reserveEdge, true) navigator.serviceWorker.removeEventListener(
document.removeEventListener('touchmove', holdEdge, true) "controllerchange",
document.removeEventListener('touchend', releaseEdge, true) updateServiceWorkerState,
document.removeEventListener('touchcancel', releaseEdge, true) );
} document.removeEventListener("touchstart", reserveEdge, true);
document.removeEventListener("touchmove", holdEdge, true);
document.removeEventListener("touchend", releaseEdge, true);
document.removeEventListener("touchcancel", releaseEdge, true);
};
}, },
} };
} }
function updateServiceWorkerState() { function updateServiceWorkerState() {
state.serviceWorker = 'ready' state.serviceWorker = "ready";
} }

View File

@@ -1,46 +1,116 @@
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router' import {
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from './guard-state' createRouter,
createWebHashHistory,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from "./guard-state";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/inbox' }, { path: "/", redirect: "/inbox" },
{ {
path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'), path: "/inbox",
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } }, name: "inbox",
component: () => import("./views/InboxView.vue"),
meta: {
tab: true,
native: {
siblingGroup: "primary",
siblingOrder: 0,
siblingHistory: "replace",
gesture: "full",
},
},
}, },
{ {
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'), path: "/stories",
name: "stories",
component: () => import("./views/StoriesView.vue"),
beforeEnter: evaluateStoryEntry, beforeEnter: evaluateStoryEntry,
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } }, meta: {
tab: true,
native: {
siblingGroup: "primary",
siblingOrder: 1,
siblingHistory: "replace",
gesture: "full",
},
},
}, },
{ {
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'), path: "/profile",
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } }, name: "profile",
component: () => import("./views/ProfileView.vue"),
meta: {
tab: true,
native: {
siblingGroup: "primary",
siblingOrder: 2,
siblingHistory: "replace",
gesture: "full",
},
},
}, },
{ {
path: '/profile/runtime-lab', name: 'runtime-lab', component: () => import('./views/RuntimeLabView.vue'), path: "/profile/runtime-lab",
name: "runtime-lab",
component: () => import("./views/RuntimeLabView.vue"),
beforeEnter: evaluateRuntimeLabEntry, beforeEnter: evaluateRuntimeLabEntry,
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 3, siblingHistory: 'push', presentation: 'slide', parent: '/profile', gesture: 'full' } }, meta: {
tab: true,
native: {
siblingGroup: "primary",
siblingOrder: 3,
siblingHistory: "push",
presentation: "slide",
parent: "/profile",
gesture: "full",
},
},
}, },
{ {
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'), path: "/chat/:id",
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } }, 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/ContactView.vue'), path: "/chat/:id/details",
meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } }, name: "chat-details",
component: () => import("./views/ContactView.vue"),
meta: {
native: {
presentation: "push",
parent: (route) => `/chat/${String(route.params.id)}`,
gesture: "edge",
},
},
}, },
{ {
path: '/compose', name: 'compose', component: () => import('./views/ComposeView.vue'), path: "/compose",
meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } }, name: "compose",
component: () => import("./views/ComposeView.vue"),
meta: {
native: { presentation: "sheet", parent: "/inbox", gesture: "full" },
},
}, },
{ {
path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'), path: "/settings",
meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } }, name: "settings",
component: () => import("./views/SettingsView.vue"),
meta: {
native: { presentation: "push", parent: "/profile", gesture: "edge" },
},
}, },
] ];
export const router = createRouter({ export const router = createRouter({
history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(), history:
window.location.protocol === "file:"
? createWebHashHistory()
: createWebHistory(),
routes, routes,
scrollBehavior: () => ({ top: 0 }), scrollBehavior: () => ({ top: 0 }),
}) });

File diff suppressed because it is too large Load Diff

View File

@@ -26,14 +26,15 @@ Component gesture owners stop propagation before a containing navigator can clai
```ts ```ts
interface NativeRouteOptions { interface NativeRouteOptions {
navigator?: string navigator?: string;
presentation?: 'push' | 'reveal' | 'slide' | 'fade' | 'modal' | 'sheet' | string presentation?:
parent?: RouteLocationRaw | ((route) => RouteLocationRaw) "push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | string;
siblingGroup?: string parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
siblingOrder?: number siblingGroup?: string;
siblingHistory?: 'push' | 'replace' siblingOrder?: number;
cache?: boolean | 'pin' siblingHistory?: "push" | "replace";
gesture?: boolean | 'edge' | 'full' cache?: boolean | "pin";
gesture?: boolean | "edge" | "full";
} }
``` ```
@@ -42,15 +43,17 @@ interface NativeRouteOptions {
## Custom presentations ## Custom presentations
```ts ```ts
nativeRouter.registerPresentation(definePresentation({ nativeRouter.registerPresentation(
name: 'scale-fade', definePresentation({
axis: 'x', name: "scale-fade",
layerStyle({ role, progress }) { axis: "x",
return role === 'to' layerStyle({ role, progress }) {
? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` } return role === "to"
: { opacity: 1 - progress * 0.4 } ? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
}, : { opacity: 1 - progress * 0.4 };
})) },
}),
);
``` ```
Applications can call `beginInteractive()`, `updateInteractive()`, and `finishInteractive()` to drive the same transaction engine from a bespoke recognizer. Applications can call `beginInteractive()`, `updateInteractive()`, and `finishInteractive()` to drive the same transaction engine from a bespoke recognizer.
@@ -72,16 +75,16 @@ import {
onNativeViewEvict, onNativeViewEvict,
useNativeViewActiveEffect, useNativeViewActiveEffect,
useNativeViewLifecycle, useNativeViewLifecycle,
} from '@native-vue-router/core' } from "@native-vue-router/core";
const view = useNativeViewLifecycle() const view = useNativeViewLifecycle();
useNativeViewActiveEffect(() => { useNativeViewActiveEffect(() => {
const timer = startPolling() const timer = startPolling();
return () => stopPolling(timer) return () => stopPolling(timer);
}) });
onNativeViewEvict((reason) => saveDraft(view.route.value, reason)) 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. 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.

View File

@@ -193,12 +193,12 @@ Deployment infrastructure must still avoid long-lived caching for `sw.js` and th
## Summary of deliberate trade-offs ## Summary of deliberate trade-offs
| Decision | Benefit | Cost | | Decision | Benefit | Cost |
| --- | --- | --- | | --------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| Keep Vue Router authoritative | Guards, URLs, redirects, and ecosystem compatibility | Reconciliation complexity | | Keep Vue Router authoritative | Guards, URLs, redirects, and ecosystem compatibility | Reconciliation complexity |
| Render a preview before commit | Truly interactive and cancellable navigation | Two live component trees and preview side effects | | Render a preview before commit | Truly interactive and cancellable navigation | Two live component trees and preview side effects |
| Maintain separate history and view ledgers | Correct back semantics plus tab caching | More state and invariants | | Maintain separate history and view ledgers | Correct back semantics plus tab caching | More state and invariants |
| Interrupt springs but finish semantic commits | No animation cooldown without corrupting history | Guard/history latency can remain | | Interrupt springs but finish semantic commits | No animation cooldown without corrupting history | Guard/history latency can remain |
| Require explicit route topology | Deterministic parent and sibling behavior | More route metadata | | Require explicit route topology | Deterministic parent and sibling behavior | More route metadata |
| Bound mounted views | Predictable DOM and memory use | Component-local state can be evicted | | Bound mounted views | Predictable DOM and memory use | Component-local state can be evicted |
| Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts | | Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts |

View File

@@ -19,9 +19,9 @@ Vue Router remains the authority for semantic navigation: route matching, URLs,
That separation produces two ledgers: That separation produces two ledgers:
| Ledger | Owns | Source of truth for | | Ledger | Owns | Source of truth for |
| --- | --- | --- | | ------------------- | --------------------------------------------------- | ------------------------------------------------- |
| Vue Router | Current committed route and browser history | What URL the application is actually on | | Vue Router | Current committed route and browser history | What URL the application is actually on |
| Native view runtime | Active, inactive, preview, and evicted view entries | What route surfaces can be rendered during motion | | Native view runtime | Active, inactive, preview, and evicted view entries | What route surfaces can be rendered during motion |
The ledgers agree at rest. During a gesture they intentionally diverge: Vue Router still reports the committed `from` route while the native runtime also renders an uncommitted `to` route. The ledgers agree at rest. During a gesture they intentionally diverge: Vue Router still reports the committed `from` route while the native runtime also renders an uncommitted `to` route.

View File

@@ -54,13 +54,13 @@ Only the active route, previously visited inactive routes, and a transaction pre
During an interaction, animation work concerns two surfaces regardless of total route count: During an interaction, animation work concerns two surfaces regardless of total route count:
| Resource | Growth behavior | | Resource | Growth behavior |
| --- | --- | | -------------------------------- | ------------------------------------- |
| Animated surfaces | Constant: `from` and `to` | | Animated surfaces | Constant: `from` and `to` |
| Mounted inactive component trees | Bounded by `maxInactive` | | Mounted inactive component trees | Bounded by `maxInactive` |
| Route descriptors/history keys | Grows with navigation history | | Route descriptors/history keys | Grows with navigation history |
| Per-frame transaction state | Constant | | Per-frame transaction state | Constant |
| Lazy route code | Loaded on first preview or navigation | | Lazy route code | Loaded on first preview or navigation |
The current implementation uses linear searches through view entries for some reconciliation operations. This is appropriate for ordinary application histories and a small mounted cache. If the runtime is used for sessions with thousands of unique committed entries, a key/path index and descriptor compaction would be a sensible evolution without changing the public transaction model. The current implementation uses linear searches through view entries for some reconciliation operations. This is appropriate for ordinary application histories and a small mounted cache. If the runtime is used for sessions with thousands of unique committed entries, a key/path index and descriptor compaction would be a sensible evolution without changing the public transaction model.
@@ -122,15 +122,17 @@ Ordered peers are passed to `NativeNavigator`. Their routes use `siblingOrder` t
Presentation definitions receive only the data needed to derive layer styles: Presentation definitions receive only the data needed to derive layer styles:
```ts ```ts
nativeRouter.registerPresentation(definePresentation({ nativeRouter.registerPresentation(
name: 'scale-fade', definePresentation({
axis: 'x', name: "scale-fade",
layerStyle({ role, progress }) { axis: "x",
return role === 'to' layerStyle({ role, progress }) {
? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` } return role === "to"
: { opacity: 1 - progress * 0.25 } ? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
}, : { opacity: 1 - progress * 0.25 };
})) },
}),
);
``` ```
A presentation does not decide history or commit. Keeping motion separate from navigation semantics makes new visual styles safer to add. A presentation does not decide history or commit. Keeping motion separate from navigation semantics makes new visual styles safer to add.

49
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"electron": "^43.1.1", "electron": "^43.1.1",
"happy-dom": "^20.10.6", "happy-dom": "^20.10.6",
"prettier": "^3.9.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1", "vite": "^8.1.1",
"vitest": "^3.2.4", "vitest": "^3.2.4",
@@ -1836,6 +1837,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1852,6 +1854,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1868,6 +1871,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1884,6 +1888,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1900,6 +1905,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1916,6 +1922,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1932,6 +1939,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1948,6 +1956,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1964,6 +1973,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1980,6 +1990,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1996,6 +2007,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2012,6 +2024,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2028,6 +2041,7 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2044,6 +2058,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2060,6 +2075,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2076,6 +2092,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2092,6 +2109,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2108,6 +2126,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2124,6 +2143,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2140,6 +2160,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2156,6 +2177,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2172,6 +2194,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2188,6 +2211,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2204,6 +2228,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2220,6 +2245,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2236,6 +2262,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5084,9 +5111,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6786,6 +6813,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",

View File

@@ -16,7 +16,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"preview": "vite preview", "preview": "vite preview",
"electron": "npm run build && electron apps/electron/main.mjs", "electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync" "cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\""
}, },
"dependencies": { "dependencies": {
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
@@ -46,6 +47,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"electron": "^43.1.1", "electron": "^43.1.1",
"happy-dom": "^20.10.6", "happy-dom": "^20.10.6",
"prettier": "^3.9.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1", "vite": "^8.1.1",
"vitest": "^3.2.4", "vitest": "^3.2.4",

View File

@@ -3,9 +3,18 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"files": ["dist"], "files": [
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" }, "dist"
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, ],
"scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": { "peerDependencies": {
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.0.0", "@capacitor/core": "^8.0.0",

View File

@@ -1,50 +1,66 @@
import { App } from '@capacitor/app' import { App } from "@capacitor/app";
import { Capacitor } from '@capacitor/core' import { Capacitor } from "@capacitor/core";
import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Haptics, ImpactStyle } from "@capacitor/haptics";
import type { NativePlatformAdapter, NativeRouterRuntime } from '@native-vue-router/core' import type {
NativePlatformAdapter,
NativeRouterRuntime,
} from "@native-vue-router/core";
export interface CapacitorAdapterOptions { export interface CapacitorAdapterOptions {
exitAtRoot?: boolean exitAtRoot?: boolean;
haptics?: boolean haptics?: boolean;
/** Release inactive component trees when the native app backgrounds. Defaults to true. */ /** Release inactive component trees when the native app backgrounds. Defaults to true. */
trimCacheOnPause?: boolean trimCacheOnPause?: boolean;
deepLinkPath?: (url: URL) => string deepLinkPath?: (url: URL) => string;
} }
export function createCapacitorAdapter(options: CapacitorAdapterOptions = {}): NativePlatformAdapter { export function createCapacitorAdapter(
const enabled = Capacitor.isNativePlatform() options: CapacitorAdapterOptions = {},
): NativePlatformAdapter {
const enabled = Capacitor.isNativePlatform();
return { return {
name: enabled ? `capacitor-${Capacitor.getPlatform()}` : 'capacitor-web', name: enabled ? `capacitor-${Capacitor.getPlatform()}` : "capacitor-web",
async haptic(event) { async haptic(event) {
if (!enabled || options.haptics === false) return if (!enabled || options.haptics === false) return;
if (event === 'selection') await Haptics.selectionChanged() if (event === "selection") await Haptics.selectionChanged();
else await Haptics.impact({ style: event === 'commit' ? ImpactStyle.Light : ImpactStyle.Medium }) else
await Haptics.impact({
style: event === "commit" ? ImpactStyle.Light : ImpactStyle.Medium,
});
}, },
async install(runtime: NativeRouterRuntime) { async install(runtime: NativeRouterRuntime) {
if (!enabled) return if (!enabled) return;
const handles = await Promise.all([ const handles = await Promise.all([
App.addListener('backButton', async () => { App.addListener("backButton", async () => {
if (runtime.transaction.value) return await runtime.cancelInteractive() if (runtime.transaction.value)
if (runtime.canGoBack.value) return void await runtime.pop() return await runtime.cancelInteractive();
if (options.exitAtRoot !== false) await App.exitApp() if (runtime.canGoBack.value) return void (await runtime.pop());
if (options.exitAtRoot !== false) await App.exitApp();
}), }),
App.addListener('appUrlOpen', ({ url }) => { App.addListener("appUrlOpen", ({ url }) => {
const parsed = new URL(url) const parsed = new URL(url);
const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}` const path =
void runtime.push(path || '/') options.deepLinkPath?.(parsed) ??
`${parsed.pathname}${parsed.search}${parsed.hash}`;
void runtime.push(path || "/");
}), }),
App.addListener('pause', () => { App.addListener("pause", () => {
void runtime.cancelInteractive() void runtime.cancelInteractive();
if (options.trimCacheOnPause !== false) runtime.trimCache({ reason: 'memory-pressure' }) if (options.trimCacheOnPause !== false)
runtime.trimCache({ reason: "memory-pressure" });
}), }),
]) ]);
const launch = await App.getLaunchUrl() const launch = await App.getLaunchUrl();
if (launch?.url) { if (launch?.url) {
const parsed = new URL(launch.url) const parsed = new URL(launch.url);
const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}` const path =
if (path) void runtime.replace(path, { presentation: 'none' }) options.deepLinkPath?.(parsed) ??
`${parsed.pathname}${parsed.search}${parsed.hash}`;
if (path) void runtime.replace(path, { presentation: "none" });
} }
return () => { void Promise.all(handles.map((handle) => handle.remove())) } return () => {
void Promise.all(handles.map((handle) => handle.remove()));
};
}, },
} };
} }

View File

@@ -1,10 +1,22 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { resolve } from 'node:path' import { resolve } from "node:path";
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: 'dist', emptyOutDir: true, outDir: "dist",
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' }, emptyOutDir: true,
rollupOptions: { external: ['@capacitor/app', '@capacitor/core', '@capacitor/haptics', '@native-vue-router/core'] }, lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
},
rollupOptions: {
external: [
"@capacitor/app",
"@capacitor/core",
"@capacitor/haptics",
"@native-vue-router/core",
],
},
}, },
}) });

View File

@@ -3,11 +3,20 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"files": ["dist"], "files": [
"sideEffects": ["./dist/style.css"], "dist"
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" }, ],
"sideEffects": [
"./dist/style.css"
],
"scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"exports": { "exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css"
}, },
"peerDependencies": { "peerDependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
export * from './types' export * from "./types";
export * from './runtime' export * from "./runtime";
export * from './components' export * from "./components";
export * from './profiler' export * from "./profiler";
import './style.css' import "./style.css";
declare module '@vue/runtime-core' { declare module "@vue/runtime-core" {
interface ComponentCustomProperties { interface ComponentCustomProperties {
$nativeRouter: import('./types').NativeRouterRuntime $nativeRouter: import("./types").NativeRouterRuntime;
} }
} }

View File

@@ -1,111 +1,117 @@
import { readonly, ref } from 'vue' import { readonly, ref } from "vue";
import type { NativeDiagnosticEvent, NativeRouterRuntime } from './types' import type { NativeDiagnosticEvent, NativeRouterRuntime } from "./types";
export interface NativeProfilerOptions { export interface NativeProfilerOptions {
/** Extra non-sensitive identifiers such as an application build ID. */ /** Extra non-sensitive identifiers such as an application build ID. */
metadata?: Record<string, string | number | boolean> metadata?: Record<string, string | number | boolean>;
/** Maximum rAF samples retained. Defaults to 30,000 (about eight minutes at 60 Hz). */ /** Maximum rAF samples retained. Defaults to 30,000 (about eight minutes at 60 Hz). */
maxFrames?: number maxFrames?: number;
} }
export interface NativeProfilerFrame { export interface NativeProfilerFrame {
at: number at: number;
delta: number delta: number;
route: string route: string;
transactionId?: number transactionId?: number;
phase?: string phase?: string;
progress?: number progress?: number;
} }
export interface NativeProfilerPerformanceEntry { export interface NativeProfilerPerformanceEntry {
type: 'longtask' | 'layout-shift' | 'resource' | 'event' type: "longtask" | "layout-shift" | "resource" | "event";
at: number at: number;
duration: number duration: number;
name?: string name?: string;
value?: number value?: number;
size?: number size?: number;
hadRecentInput?: boolean hadRecentInput?: boolean;
} }
export interface NativeProfilerTransactionSummary { export interface NativeProfilerTransactionSummary {
id: number id: number;
route?: string route?: string;
kind?: string kind?: string;
cold?: boolean cold?: boolean;
outcome?: string outcome?: string;
duration?: number duration?: number;
frames: number frames: number;
slowFrames: number slowFrames: number;
p95FrameMs?: number p95FrameMs?: number;
maxFrameMs?: number maxFrameMs?: number;
} }
export interface NativeProfilerReport { export interface NativeProfilerReport {
schema: 'native-vue-router-profile@1' schema: "native-vue-router-profile@1";
startedAt: string startedAt: string;
duration: number duration: number;
environment: { environment: {
userAgent: string userAgent: string;
viewport: { width: number; height: number; devicePixelRatio: number } viewport: { width: number; height: number; devicePixelRatio: number };
displayMode: 'standalone' | 'browser' displayMode: "standalone" | "browser";
visibility: DocumentVisibilityState visibility: DocumentVisibilityState;
hardwareConcurrency?: number hardwareConcurrency?: number;
deviceMemory?: number deviceMemory?: number;
} };
metadata: Record<string, string | number | boolean> metadata: Record<string, string | number | boolean>;
summary: { summary: {
frames: number frames: number;
estimatedRefreshMs: number estimatedRefreshMs: number;
estimatedRefreshHz: number estimatedRefreshHz: number;
averageFps: number averageFps: number;
p95FrameMs: number p95FrameMs: number;
maxFrameMs: number maxFrameMs: number;
framesOver20ms: number framesOver20ms: number;
framesOver34ms: number framesOver34ms: number;
framesOver50ms: number framesOver50ms: number;
droppedFrames: number droppedFrames: number;
longTasks: number longTasks: number;
cumulativeLayoutShift: number cumulativeLayoutShift: number;
} };
transactions: NativeProfilerTransactionSummary[] transactions: NativeProfilerTransactionSummary[];
events: NativeDiagnosticEvent[] events: NativeDiagnosticEvent[];
frames: NativeProfilerFrame[] frames: NativeProfilerFrame[];
performanceEntries: NativeProfilerPerformanceEntry[] performanceEntries: NativeProfilerPerformanceEntry[];
visibility: Array<{ at: number; state: DocumentVisibilityState }> visibility: Array<{ at: number; state: DocumentVisibilityState }>;
} }
export interface NativeNavigationProfiler { export interface NativeNavigationProfiler {
readonly recording: Readonly<{ value: boolean }> readonly recording: Readonly<{ value: boolean }>;
start(): void start(): void;
stop(): NativeProfilerReport stop(): NativeProfilerReport;
snapshot(): NativeProfilerReport snapshot(): NativeProfilerReport;
clear(): void clear(): void;
toJSON(report?: NativeProfilerReport): string toJSON(report?: NativeProfilerReport): string;
dispose(): void dispose(): void;
} }
function round(value: number, digits = 2) { function round(value: number, digits = 2) {
const scale = 10 ** digits const scale = 10 ** digits;
return Math.round(value * scale) / scale return Math.round(value * scale) / scale;
} }
function percentile(values: number[], position: number) { function percentile(values: number[], position: number) {
if (!values.length) return 0 if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b) const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))] ?? 0 return (
sorted[
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))
] ?? 0
);
} }
function routeLabel(runtime: NativeRouterRuntime) { function routeLabel(runtime: NativeRouterRuntime) {
const route = runtime.router.currentRoute.value const route = runtime.router.currentRoute.value;
return route.name != null ? String(route.name) : route.matched.at(-1)?.path ?? route.path return route.name != null
? String(route.name)
: (route.matched.at(-1)?.path ?? route.path);
} }
function resourceName(value: string) { function resourceName(value: string) {
try { try {
const url = new URL(value, window.location.href) const url = new URL(value, window.location.href);
return url.pathname.split('/').at(-1) || url.pathname return url.pathname.split("/").at(-1) || url.pathname;
} catch { } catch {
return value.split('/').at(-1)?.split('?')[0] ?? 'resource' return value.split("/").at(-1)?.split("?")[0] ?? "resource";
} }
} }
@@ -117,37 +123,39 @@ export function createNativeNavigationProfiler(
runtime: NativeRouterRuntime, runtime: NativeRouterRuntime,
options: NativeProfilerOptions = {}, options: NativeProfilerOptions = {},
): NativeNavigationProfiler { ): NativeNavigationProfiler {
const mutableRecording = ref(false) const mutableRecording = ref(false);
const recording = readonly(mutableRecording) const recording = readonly(mutableRecording);
const maxFrames = Math.max(300, options.maxFrames ?? 30_000) const maxFrames = Math.max(300, options.maxFrames ?? 30_000);
let startedAt = 0 let startedAt = 0;
let endedAt = 0 let endedAt = 0;
let startedAtIso = '' let startedAtIso = "";
let previousFrame: number | undefined let previousFrame: number | undefined;
let animationFrame = 0 let animationFrame = 0;
let frames: NativeProfilerFrame[] = [] let frames: NativeProfilerFrame[] = [];
let events: NativeDiagnosticEvent[] = [] let events: NativeDiagnosticEvent[] = [];
let performanceEntries: NativeProfilerPerformanceEntry[] = [] let performanceEntries: NativeProfilerPerformanceEntry[] = [];
let visibility: Array<{ at: number; state: DocumentVisibilityState }> = [] let visibility: Array<{ at: number; state: DocumentVisibilityState }> = [];
let observers: PerformanceObserver[] = [] let observers: PerformanceObserver[] = [];
const relative = (timestamp: number) => round(Math.max(0, timestamp - startedAt), 3) const relative = (timestamp: number) =>
round(Math.max(0, timestamp - startedAt), 3);
const removeDiagnostic = runtime.onDiagnostic((event) => { const removeDiagnostic = runtime.onDiagnostic((event) => {
if (!mutableRecording.value) return if (!mutableRecording.value) return;
const details = event.type === 'transaction-start' && typeof document !== 'undefined' const details =
? { event.type === "transaction-start" && typeof document !== "undefined"
...event.details, ? {
mountedViews: runtime.cacheStats.value.mounted, ...event.details,
} mountedViews: runtime.cacheStats.value.mounted,
: event.details }
events.push({ ...event, timestamp: relative(event.timestamp), details }) : event.details;
}) events.push({ ...event, timestamp: relative(event.timestamp), details });
});
const sampleFrame = (timestamp: number) => { const sampleFrame = (timestamp: number) => {
if (!mutableRecording.value) return if (!mutableRecording.value) return;
if (previousFrame !== undefined && frames.length < maxFrames) { if (previousFrame !== undefined && frames.length < maxFrames) {
const transaction = runtime.transaction.value const transaction = runtime.transaction.value;
frames.push({ frames.push({
at: relative(timestamp), at: relative(timestamp),
delta: round(timestamp - previousFrame, 3), delta: round(timestamp - previousFrame, 3),
@@ -155,98 +163,123 @@ export function createNativeNavigationProfiler(
transactionId: transaction?.id, transactionId: transaction?.id,
phase: transaction?.phase, phase: transaction?.phase,
progress: transaction ? round(transaction.progress, 4) : undefined, progress: transaction ? round(transaction.progress, 4) : undefined,
}) });
} }
previousFrame = timestamp previousFrame = timestamp;
animationFrame = window.requestAnimationFrame(sampleFrame) animationFrame = window.requestAnimationFrame(sampleFrame);
} };
const observe = (type: NativeProfilerPerformanceEntry['type']) => { const observe = (type: NativeProfilerPerformanceEntry["type"]) => {
if (typeof PerformanceObserver === 'undefined') return if (typeof PerformanceObserver === "undefined") return;
if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return;
try { try {
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
if (!mutableRecording.value) return if (!mutableRecording.value) return;
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
const extra = entry as PerformanceEntry & { const extra = entry as PerformanceEntry & {
value?: number value?: number;
hadRecentInput?: boolean hadRecentInput?: boolean;
transferSize?: number transferSize?: number;
interactionId?: number interactionId?: number;
} };
performanceEntries.push({ performanceEntries.push({
type, type,
at: relative(entry.startTime), at: relative(entry.startTime),
duration: round(entry.duration, 3), duration: round(entry.duration, 3),
name: type === 'resource' name:
? resourceName(entry.name) type === "resource"
: type === 'event' ? resourceName(entry.name)
? entry.name : type === "event"
: undefined, ? entry.name
: undefined,
value: extra.value, value: extra.value,
size: extra.transferSize, size: extra.transferSize,
hadRecentInput: extra.hadRecentInput, hadRecentInput: extra.hadRecentInput,
}) });
} }
}) });
observer.observe(type === 'event' observer.observe(
? { type, buffered: false, durationThreshold: 16 } as PerformanceObserverInit type === "event"
: { type, buffered: false } as PerformanceObserverInit) ? ({
observers.push(observer) type,
buffered: false,
durationThreshold: 16,
} as PerformanceObserverInit)
: ({ type, buffered: false } as PerformanceObserverInit),
);
observers.push(observer);
} catch { } catch {
// Performance entry support differs between Safari, Chromium, and hosts. // Performance entry support differs between Safari, Chromium, and hosts.
} }
} };
const onVisibility = () => { const onVisibility = () => {
if (mutableRecording.value) visibility.push({ at: relative(performance.now()), state: document.visibilityState }) if (mutableRecording.value)
} visibility.push({
at: relative(performance.now()),
state: document.visibilityState,
});
};
const stopSampling = () => { const stopSampling = () => {
if (animationFrame) window.cancelAnimationFrame(animationFrame) if (animationFrame) window.cancelAnimationFrame(animationFrame);
animationFrame = 0 animationFrame = 0;
for (const observer of observers) observer.disconnect() for (const observer of observers) observer.disconnect();
observers = [] observers = [];
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener("visibilitychange", onVisibility);
} };
const transactionSummaries = (baseline: number) => { const transactionSummaries = (baseline: number) => {
const starts = new Map<number, NativeDiagnosticEvent>() const starts = new Map<number, NativeDiagnosticEvent>();
const ends = new Map<number, NativeDiagnosticEvent>() const ends = new Map<number, NativeDiagnosticEvent>();
for (const event of events) { for (const event of events) {
if (event.transactionId == null) continue if (event.transactionId == null) continue;
if (event.type === 'transaction-start') starts.set(event.transactionId, event) if (event.type === "transaction-start")
if (event.type === 'transaction-end') ends.set(event.transactionId, event) starts.set(event.transactionId, event);
if (event.type === "transaction-end")
ends.set(event.transactionId, event);
} }
return [...starts].map(([id, start]) => { return [...starts].map(([id, start]) => {
const end = ends.get(id) const end = ends.get(id);
const samples = frames.filter((frame) => frame.transactionId === id).map((frame) => frame.delta) const samples = frames
.filter((frame) => frame.transactionId === id)
.map((frame) => frame.delta);
return { return {
id, id,
route: start.route, route: start.route,
kind: String(start.details?.kind ?? ''), kind: String(start.details?.kind ?? ""),
cold: Boolean(start.details?.cold), cold: Boolean(start.details?.cold),
outcome: end?.details?.outcome ? String(end.details.outcome) : undefined, outcome: end?.details?.outcome
? String(end.details.outcome)
: undefined,
duration: end ? round(end.timestamp - start.timestamp, 3) : undefined, duration: end ? round(end.timestamp - start.timestamp, 3) : undefined,
frames: samples.length, frames: samples.length,
slowFrames: samples.filter((delta) => delta > baseline * 1.5).length, slowFrames: samples.filter((delta) => delta > baseline * 1.5).length,
p95FrameMs: samples.length ? round(percentile(samples, .95), 3) : undefined, p95FrameMs: samples.length
? round(percentile(samples, 0.95), 3)
: undefined,
maxFrameMs: samples.length ? round(Math.max(...samples), 3) : undefined, maxFrameMs: samples.length ? round(Math.max(...samples), 3) : undefined,
} };
}) });
} };
const snapshot = (): NativeProfilerReport => { const snapshot = (): NativeProfilerReport => {
const duration = startedAt ? (mutableRecording.value ? performance.now() : endedAt || performance.now()) - startedAt : 0 const duration = startedAt
const deltas = frames.map((frame) => frame.delta).filter((delta) => delta > 0 && delta < 250) ? (mutableRecording.value
const baseline = Math.max(4, percentile(deltas, .1) || 16.667) ? performance.now()
const totalFrameTime = deltas.reduce((sum, value) => sum + value, 0) : endedAt || performance.now()) - startedAt
: 0;
const deltas = frames
.map((frame) => frame.delta)
.filter((delta) => delta > 0 && delta < 250);
const baseline = Math.max(4, percentile(deltas, 0.1) || 16.667);
const totalFrameTime = deltas.reduce((sum, value) => sum + value, 0);
const shifts = performanceEntries const shifts = performanceEntries
.filter((entry) => entry.type === 'layout-shift' && !entry.hadRecentInput) .filter((entry) => entry.type === "layout-shift" && !entry.hadRecentInput)
.reduce((sum, entry) => sum + (entry.value ?? 0), 0) .reduce((sum, entry) => sum + (entry.value ?? 0), 0);
const nav = navigator as Navigator & { deviceMemory?: number } const nav = navigator as Navigator & { deviceMemory?: number };
return { return {
schema: 'native-vue-router-profile@1', schema: "native-vue-router-profile@1",
startedAt: startedAtIso || new Date().toISOString(), startedAt: startedAtIso || new Date().toISOString(),
duration: round(duration, 3), duration: round(duration, 3),
environment: { environment: {
@@ -256,7 +289,9 @@ export function createNativeNavigationProfiler(
height: window.innerHeight, height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio, devicePixelRatio: window.devicePixelRatio,
}, },
displayMode: window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser', displayMode: window.matchMedia("(display-mode: standalone)").matches
? "standalone"
: "browser",
visibility: document.visibilityState, visibility: document.visibilityState,
hardwareConcurrency: navigator.hardwareConcurrency, hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: nav.deviceMemory, deviceMemory: nav.deviceMemory,
@@ -266,14 +301,19 @@ export function createNativeNavigationProfiler(
frames: frames.length, frames: frames.length,
estimatedRefreshMs: round(baseline, 3), estimatedRefreshMs: round(baseline, 3),
estimatedRefreshHz: round(1000 / baseline, 1), estimatedRefreshHz: round(1000 / baseline, 1),
averageFps: round(totalFrameTime ? deltas.length * 1000 / totalFrameTime : 0, 1), averageFps: round(
p95FrameMs: round(percentile(deltas, .95), 3), totalFrameTime ? (deltas.length * 1000) / totalFrameTime : 0,
1,
),
p95FrameMs: round(percentile(deltas, 0.95), 3),
maxFrameMs: round(deltas.length ? Math.max(...deltas) : 0, 3), maxFrameMs: round(deltas.length ? Math.max(...deltas) : 0, 3),
framesOver20ms: deltas.filter((delta) => delta > 20).length, framesOver20ms: deltas.filter((delta) => delta > 20).length,
framesOver34ms: deltas.filter((delta) => delta > 34).length, framesOver34ms: deltas.filter((delta) => delta > 34).length,
framesOver50ms: deltas.filter((delta) => delta > 50).length, framesOver50ms: deltas.filter((delta) => delta > 50).length,
droppedFrames: deltas.filter((delta) => delta > baseline * 1.5).length, droppedFrames: deltas.filter((delta) => delta > baseline * 1.5).length,
longTasks: performanceEntries.filter((entry) => entry.type === 'longtask').length, longTasks: performanceEntries.filter(
(entry) => entry.type === "longtask",
).length,
cumulativeLayoutShift: round(shifts, 5), cumulativeLayoutShift: round(shifts, 5),
}, },
transactions: transactionSummaries(baseline), transactions: transactionSummaries(baseline),
@@ -281,40 +321,40 @@ export function createNativeNavigationProfiler(
frames: [...frames], frames: [...frames],
performanceEntries: [...performanceEntries], performanceEntries: [...performanceEntries],
visibility: [...visibility], visibility: [...visibility],
} };
} };
const clear = () => { const clear = () => {
frames = [] frames = [];
events = [] events = [];
performanceEntries = [] performanceEntries = [];
visibility = [] visibility = [];
previousFrame = undefined previousFrame = undefined;
endedAt = 0 endedAt = 0;
} };
const start = () => { const start = () => {
if (mutableRecording.value) return if (mutableRecording.value) return;
clear() clear();
startedAt = performance.now() startedAt = performance.now();
endedAt = 0 endedAt = 0;
startedAtIso = new Date().toISOString() startedAtIso = new Date().toISOString();
mutableRecording.value = true mutableRecording.value = true;
visibility.push({ at: 0, state: document.visibilityState }) visibility.push({ at: 0, state: document.visibilityState });
document.addEventListener('visibilitychange', onVisibility) document.addEventListener("visibilitychange", onVisibility);
observe('longtask') observe("longtask");
observe('layout-shift') observe("layout-shift");
observe('resource') observe("resource");
observe('event') observe("event");
animationFrame = window.requestAnimationFrame(sampleFrame) animationFrame = window.requestAnimationFrame(sampleFrame);
} };
const stop = () => { const stop = () => {
endedAt = performance.now() endedAt = performance.now();
mutableRecording.value = false mutableRecording.value = false;
stopSampling() stopSampling();
return snapshot() return snapshot();
} };
return { return {
recording, recording,
@@ -324,9 +364,9 @@ export function createNativeNavigationProfiler(
clear, clear,
toJSON: (report = snapshot()) => JSON.stringify(report, null, 2), toJSON: (report = snapshot()) => JSON.stringify(report, null, 2),
dispose() { dispose() {
mutableRecording.value = false mutableRecording.value = false;
stopSampling() stopSampling();
removeDiagnostic() removeDiagnostic();
}, },
} };
} }

View File

@@ -1,372 +1,481 @@
import { createApp, defineComponent, nextTick } from 'vue' import { createApp, defineComponent, nextTick } from "vue";
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from "vue-router";
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
createNativeRouter, createNativeRouter,
definePresentation, definePresentation,
shouldCommitGesture, shouldCommitGesture,
springTimeScaleForVelocity, springTimeScaleForVelocity,
} from './runtime' } from "./runtime";
import { createNativeNavigationProfiler } from './profiler' import { createNativeNavigationProfiler } from "./profiler";
const Page = defineComponent({ template: '<div>page</div>' }) const Page = defineComponent({ template: "<div>page</div>" });
async function harness(blockB: boolean | 'redirect' = false) { async function harness(blockB: boolean | "redirect" = false) {
const router = createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/a', component: Page }, { path: "/a", component: Page },
{ path: '/b', component: Page, meta: { native: { parent: '/a' } } }, { path: "/b", component: Page, meta: { native: { parent: "/a" } } },
{ path: '/c', component: Page, meta: { native: { parent: '/a' } } }, { path: "/c", component: Page, meta: { native: { parent: "/a" } } },
{ path: '/modal', component: Page, meta: { native: { presentation: 'sheet', parent: '/a' } } }, {
{ path: '/left', component: Page, meta: { native: { siblingOrder: 0, siblingHistory: 'replace' } } }, path: "/modal",
{ path: '/middle', component: Page, meta: { native: { siblingOrder: 1, siblingHistory: 'replace' } } }, component: Page,
{ path: '/right', component: Page, meta: { native: { siblingOrder: 2, siblingHistory: 'replace' } } }, meta: { native: { presentation: "sheet", parent: "/a" } },
{ path: '/no-cache', component: Page, meta: { native: { cache: false, parent: '/a' } } }, },
{ path: '/pinned', component: Page, meta: { native: { cache: 'pin', parent: '/a' } } }, {
{ path: '/item/:id', component: Page, meta: { native: { parent: '/a' } } }, path: "/left",
component: Page,
meta: { native: { siblingOrder: 0, siblingHistory: "replace" } },
},
{
path: "/middle",
component: Page,
meta: { native: { siblingOrder: 1, siblingHistory: "replace" } },
},
{
path: "/right",
component: Page,
meta: { native: { siblingOrder: 2, siblingHistory: "replace" } },
},
{
path: "/no-cache",
component: Page,
meta: { native: { cache: false, parent: "/a" } },
},
{
path: "/pinned",
component: Page,
meta: { native: { cache: "pin", parent: "/a" } },
},
{
path: "/item/:id",
component: Page,
meta: { native: { parent: "/a" } },
},
], ],
}) });
if (blockB) router.beforeEach((to) => to.path === '/b' ? (blockB === 'redirect' ? '/modal' : false) : undefined) if (blockB)
await router.push('/a') router.beforeEach((to) =>
await router.isReady() to.path === "/b" ? (blockB === "redirect" ? "/modal" : false) : undefined,
const native = createNativeRouter({ router, cache: { maxInactive: 2 } }) );
const app = createApp(Page) await router.push("/a");
app.use(router) await router.isReady();
app.use(native) const native = createNativeRouter({ router, cache: { maxInactive: 2 } });
await nextTick() const app = createApp(Page);
return { router, native } app.use(router);
app.use(native);
await nextTick();
return { router, native };
} }
beforeEach(() => { beforeEach(() => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList) vi.spyOn(window, "matchMedia").mockReturnValue({
}) matches: true,
} as MediaQueryList);
});
describe('gesture decisions', () => { describe("gesture decisions", () => {
it('uses progress or a deliberate velocity to commit', () => { it("uses progress or a deliberate velocity to commit", () => {
expect(shouldCommitGesture(0.4, 0)).toBe(true) expect(shouldCommitGesture(0.4, 0)).toBe(true);
expect(shouldCommitGesture(0.12, 1.4)).toBe(true) expect(shouldCommitGesture(0.12, 1.4)).toBe(true);
expect(shouldCommitGesture(0.04, 4)).toBe(false) expect(shouldCommitGesture(0.04, 4)).toBe(false);
expect(shouldCommitGesture(0.2, 0.4)).toBe(false) expect(shouldCommitGesture(0.2, 0.4)).toBe(false);
}) });
it('settles a fast flick more quickly without unbounded spring steps', () => { it("settles a fast flick more quickly without unbounded spring steps", () => {
expect(springTimeScaleForVelocity(0)).toBe(1) expect(springTimeScaleForVelocity(0)).toBe(1);
expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6) expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6);
expect(springTimeScaleForVelocity(8)).toBe(3) expect(springTimeScaleForVelocity(8)).toBe(3);
expect(springTimeScaleForVelocity(-20)).toBe(3) expect(springTimeScaleForVelocity(-20)).toBe(3);
}) });
}) });
describe('native router transactions', () => { describe("native router transactions", () => {
it('exports opt-in frame diagnostics without route params or query values', async () => { it("exports opt-in frame diagnostics without route params or query values", async () => {
const { native } = await harness() const { native } = await harness();
const profiler = createNativeNavigationProfiler(native, { metadata: { build: 'test' } }) const profiler = createNativeNavigationProfiler(native, {
profiler.start() metadata: { build: "test" },
});
profiler.start();
await native.push('/item/private-id?token=secret') await native.push("/item/private-id?token=secret");
const report = profiler.stop() const report = profiler.stop();
expect(report.schema).toBe('native-vue-router-profile@1') expect(report.schema).toBe("native-vue-router-profile@1");
expect(report.metadata).toEqual({ build: 'test' }) expect(report.metadata).toEqual({ build: "test" });
expect(report.events.map((event) => event.type)).toEqual(expect.arrayContaining([ expect(report.events.map((event) => event.type)).toEqual(
'route-load-start', expect.arrayContaining([
'route-load-end', "route-load-start",
'transaction-start', "route-load-end",
'transaction-end', "transaction-start",
])) "transaction-end",
expect(report.transactions).toMatchObject([{ route: '/item/:id', cold: true, outcome: 'committed' }]) ]),
expect(profiler.toJSON(report)).not.toContain('private-id') );
expect(profiler.toJSON(report)).not.toContain('secret') expect(report.transactions).toMatchObject([
profiler.dispose() { route: "/item/:id", cold: true, outcome: "committed" },
}) ]);
expect(profiler.toJSON(report)).not.toContain("private-id");
expect(profiler.toJSON(report)).not.toContain("secret");
profiler.dispose();
});
it('preloads a target without changing URL history', async () => { it("preloads a target without changing URL history", async () => {
const { router, native } = await harness() const { router, native } = await harness();
const id = await native.beginInteractive('push', '/b') const id = await native.beginInteractive("push", "/b");
expect(id).not.toBeNull() expect(id).not.toBeNull();
expect(router.currentRoute.value.path).toBe('/a') expect(router.currentRoute.value.path).toBe("/a");
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(true) expect(
await native.cancelInteractive() native.entries.value.some((entry) => entry.status === "preview"),
expect(router.currentRoute.value.path).toBe('/a') ).toBe(true);
expect(native.transaction.value).toBeNull() await native.cancelInteractive();
}) expect(router.currentRoute.value.path).toBe("/a");
expect(native.transaction.value).toBeNull();
});
it('commits a loaded preview through Vue Router', async () => { it("commits a loaded preview through Vue Router", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.beginInteractive('push', '/b') await native.beginInteractive("push", "/b");
native.updateInteractive(0.55, 0.1) native.updateInteractive(0.55, 0.1);
expect(await native.finishInteractive()).toBe(true) expect(await native.finishInteractive()).toBe(true);
expect(router.currentRoute.value.path).toBe('/b') expect(router.currentRoute.value.path).toBe("/b");
expect(native.entries.value.filter((entry) => entry.status === 'active')).toHaveLength(1) expect(
}) native.entries.value.filter((entry) => entry.status === "active"),
).toHaveLength(1);
});
it('snaps back and removes the preview when a guard rejects commit', async () => { it("snaps back and removes the preview when a guard rejects commit", async () => {
const { router, native } = await harness(true) const { router, native } = await harness(true);
await native.beginInteractive('push', '/b') await native.beginInteractive("push", "/b");
native.updateInteractive(0.8, 0) native.updateInteractive(0.8, 0);
expect(await native.finishInteractive()).toBe(false) expect(await native.finishInteractive()).toBe(false);
expect(router.currentRoute.value.path).toBe('/a') expect(router.currentRoute.value.path).toBe("/a");
expect(native.entries.value.some((entry) => entry.route.path === '/b')).toBe(false) expect(
}) native.entries.value.some((entry) => entry.route.path === "/b"),
).toBe(false);
});
it('uses declared parents for cold-start predictive back', async () => { it("uses declared parents for cold-start predictive back", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.replace('/b', { presentation: 'none' }) await native.replace("/b", { presentation: "none" });
const transaction = await native.beginInteractive('pop') const transaction = await native.beginInteractive("pop");
expect(transaction).not.toBeNull() expect(transaction).not.toBeNull();
expect(native.transaction.value?.direction).toBe('back') expect(native.transaction.value?.direction).toBe("back");
await native.cancelInteractive() await native.cancelInteractive();
expect(router.currentRoute.value.path).toBe('/b') expect(router.currentRoute.value.path).toBe("/b");
}) });
it('discards the stale preview when Vue Router redirects a commit', async () => { it("discards the stale preview when Vue Router redirects a commit", async () => {
const { router, native } = await harness('redirect') const { router, native } = await harness("redirect");
await native.beginInteractive('push', '/b') await native.beginInteractive("push", "/b");
expect(await native.finishInteractive(true)).toBe(true) expect(await native.finishInteractive(true)).toBe(true);
expect(router.currentRoute.value.path).toBe('/modal') expect(router.currentRoute.value.path).toBe("/modal");
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(false) expect(
}) native.entries.value.some((entry) => entry.status === "preview"),
).toBe(false);
});
it('registers application-defined presentations', async () => { it("registers application-defined presentations", async () => {
const { native } = await harness() const { native } = await harness();
const presentation = definePresentation({ name: 'flip', axis: 'x', layerStyle: () => ({ opacity: 0.5 }) }) const presentation = definePresentation({
native.registerPresentation(presentation) name: "flip",
expect(native.presentationFor('flip')).toBe(presentation) axis: "x",
}) layerStyle: () => ({ opacity: 0.5 }),
});
native.registerPresentation(presentation);
expect(native.presentationFor("flip")).toBe(presentation);
});
it('treats navigation to the active route as a strict no-op', async () => { it("treats navigation to the active route as a strict no-op", async () => {
const { router, native } = await harness() const { router, native } = await harness();
const entries = [...native.entries.value] const entries = [...native.entries.value];
expect(await native.push('/a')).toBe(false) expect(await native.push("/a")).toBe(false);
expect(await native.replace('/a')).toBe(false) expect(await native.replace("/a")).toBe(false);
expect(router.currentRoute.value.path).toBe('/a') expect(router.currentRoute.value.path).toBe("/a");
expect(native.transaction.value).toBeNull() expect(native.transaction.value).toBeNull();
expect(native.entries.value).toEqual(entries) expect(native.entries.value).toEqual(entries);
}) });
it('derives sibling direction from route order and uses adjacent-page motion', async () => { it("derives sibling direction from route order and uses adjacent-page motion", async () => {
const { native } = await harness() const { native } = await harness();
await native.replace('/middle', { presentation: 'none' }) await native.replace("/middle", { presentation: "none" });
await native.beginInteractive('sibling', '/left', { replace: true }) await native.beginInteractive("sibling", "/left", { replace: true });
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'slide' }) expect(native.transaction.value).toMatchObject({
await native.cancelInteractive() direction: "back",
presentation: "slide",
});
await native.cancelInteractive();
await native.beginInteractive('sibling', '/right', { replace: true }) await native.beginInteractive("sibling", "/right", { replace: true });
expect(native.transaction.value).toMatchObject({ direction: 'forward', presentation: 'slide' }) expect(native.transaction.value).toMatchObject({
await native.cancelInteractive() direction: "forward",
}) presentation: "slide",
});
await native.cancelInteractive();
});
it('keeps replaced sibling views cached but out of the back stack', async () => { it("keeps replaced sibling views cached but out of the back stack", async () => {
const { native } = await harness() const { native } = await harness();
await native.replace('/left', { presentation: 'none' }) await native.replace("/left", { presentation: "none" });
await native.sibling('/middle', { replace: true }) await native.sibling("/middle", { replace: true });
await native.push('/c') await native.push("/c");
await native.beginInteractive('pop') await native.beginInteractive("pop");
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey) const target = native.entries.value.find(
expect(target?.route.path).toBe('/middle') (entry) => entry.key === native.transaction.value?.toKey,
expect(target?.route.path).not.toBe('/left') );
await native.cancelInteractive() expect(target?.route.path).toBe("/middle");
}) expect(target?.route.path).not.toBe("/left");
await native.cancelInteractive();
});
it('creates sibling views lazily and retains visited replace-style siblings', async () => { it("creates sibling views lazily and retains visited replace-style siblings", async () => {
const { native } = await harness() const { native } = await harness();
expect(native.entries.value.map((entry) => entry.route.path)).toEqual(['/a']) expect(native.entries.value.map((entry) => entry.route.path)).toEqual([
"/a",
]);
await native.replace('/left', { presentation: 'none' }) await native.replace("/left", { presentation: "none" });
expect(native.entries.value.some((entry) => entry.route.path === '/middle')).toBe(false) expect(
await native.sibling('/middle', { replace: true }) native.entries.value.some((entry) => entry.route.path === "/middle"),
).toBe(false);
await native.sibling("/middle", { replace: true });
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({ mounted: true, status: 'inactive' }) expect(
expect(native.entries.value.find((entry) => entry.route.path === '/middle')).toMatchObject({ mounted: true, status: 'active' }) native.entries.value.find((entry) => entry.route.path === "/left"),
expect(native.entries.value.some((entry) => entry.route.path === '/right')).toBe(false) ).toMatchObject({ mounted: true, status: "inactive" });
}) expect(
native.entries.value.find((entry) => entry.route.path === "/middle"),
).toMatchObject({ mounted: true, status: "active" });
expect(
native.entries.value.some((entry) => entry.route.path === "/right"),
).toBe(false);
});
it('prepares a newly mounted destination for a paint before animation can begin', async () => { it("prepares a newly mounted destination for a paint before animation can begin", async () => {
vi.mocked(window.matchMedia).mockReturnValue({ matches: false } as MediaQueryList) vi.mocked(window.matchMedia).mockReturnValue({
let paint: FrameRequestCallback | undefined matches: false,
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { } as MediaQueryList);
paint = callback let paint: FrameRequestCallback | undefined;
return 1 vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
}) paint = callback;
const { native } = await harness() return 1;
});
const { native } = await harness();
let resolved = false let resolved = false;
const beginning = native.beginInteractive('push', '/b').then((id) => { const beginning = native.beginInteractive("push", "/b").then((id) => {
resolved = true resolved = true;
return id return id;
}) });
await vi.waitFor(() => expect(paint).toBeTypeOf('function')) await vi.waitFor(() => expect(paint).toBeTypeOf("function"));
expect(resolved).toBe(false) expect(resolved).toBe(false);
expect(native.entries.value.find((entry) => entry.route.path === '/b')?.mounted).toBe(true) expect(
paint?.(performance.now()) native.entries.value.find((entry) => entry.route.path === "/b")?.mounted,
expect(await beginning).not.toBeNull() ).toBe(true);
vi.mocked(window.matchMedia).mockReturnValue({ matches: true } as MediaQueryList) paint?.(performance.now());
await native.cancelInteractive() expect(await beginning).not.toBeNull();
}) vi.mocked(window.matchMedia).mockReturnValue({
matches: true,
} as MediaQueryList);
await native.cancelInteractive();
});
it('collapses pushed history when a tab replaces it with an existing root', async () => { it("collapses pushed history when a tab replaces it with an existing root", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.push('/b') await native.push("/b");
expect(native.canGoBack.value).toBe(true) expect(native.canGoBack.value).toBe(true);
await native.sibling('/a', { replace: true }) await native.sibling("/a", { replace: true });
expect(router.currentRoute.value.path).toBe('/a') expect(router.currentRoute.value.path).toBe("/a");
expect(native.canGoBack.value).toBe(false) expect(native.canGoBack.value).toBe(false);
expect(await native.beginInteractive('pop')).toBeNull() expect(await native.beginInteractive("pop")).toBeNull();
}) });
it('manually unloads inactive route instances but never the active view', async () => { it("manually unloads inactive route instances but never the active view", async () => {
const { native } = await harness() const { native } = await harness();
await native.replace('/left', { presentation: 'none' }) await native.replace("/left", { presentation: "none" });
await native.sibling('/middle', { replace: true }) await native.sibling("/middle", { replace: true });
expect(native.unload('/left')).toBe(1) expect(native.unload("/left")).toBe(1);
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({ expect(
native.entries.value.find((entry) => entry.route.path === "/left"),
).toMatchObject({
mounted: false, mounted: false,
evictionReason: 'manual', evictionReason: "manual",
}) });
expect(native.unload('/middle')).toBe(0) expect(native.unload("/middle")).toBe(0);
expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true) expect(
}) native.entries.value.find((entry) => entry.route.path === "/middle")
?.mounted,
).toBe(true);
});
it('evicts the least-recently-used inactive view when the cache limit is exceeded', async () => { it("evicts the least-recently-used inactive view when the cache limit is exceeded", async () => {
let clock = 0 let clock = 0;
vi.spyOn(performance, 'now').mockImplementation(() => ++clock) vi.spyOn(performance, "now").mockImplementation(() => ++clock);
const { native } = await harness() const { native } = await harness();
await native.replace('/left', { presentation: 'none' }) await native.replace("/left", { presentation: "none" });
await native.sibling('/middle', { replace: true }) await native.sibling("/middle", { replace: true });
await native.sibling('/right', { replace: true }) await native.sibling("/right", { replace: true });
await native.sibling('/a', { replace: true }) await native.sibling("/a", { replace: true });
expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({ expect(
native.entries.value.find((entry) => entry.route.path === "/left"),
).toMatchObject({
mounted: false, mounted: false,
evictionReason: 'cache-limit', evictionReason: "cache-limit",
}) });
expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true) expect(
expect(native.entries.value.find((entry) => entry.route.path === '/right')?.mounted).toBe(true) native.entries.value.find((entry) => entry.route.path === "/middle")
expect(native.cacheStats.value.inactive).toBe(2) ?.mounted,
}) ).toBe(true);
expect(
native.entries.value.find((entry) => entry.route.path === "/right")
?.mounted,
).toBe(true);
expect(native.cacheStats.value.inactive).toBe(2);
});
it('evicts a pushed route after it is popped out of history', async () => { it("evicts a pushed route after it is popped out of history", async () => {
const { native } = await harness() const { native } = await harness();
await native.push('/b') await native.push("/b");
const pushedKey = native.activeKey.value const pushedKey = native.activeKey.value;
await native.pop() await native.pop();
expect(native.entries.value.find((entry) => entry.key === pushedKey)).toMatchObject({ expect(
native.entries.value.find((entry) => entry.key === pushedKey),
).toMatchObject({
mounted: false, mounted: false,
status: 'evicted', status: "evicted",
evictionReason: 'popped', evictionReason: "popped",
}) });
}) });
it('honors cache opt-out even for a route that remains in back history', async () => { it("honors cache opt-out even for a route that remains in back history", async () => {
const { native } = await harness() const { native } = await harness();
await native.push('/no-cache') await native.push("/no-cache");
const noCacheKey = native.activeKey.value const noCacheKey = native.activeKey.value;
await native.push('/c') await native.push("/c");
expect(native.entries.value.find((entry) => entry.key === noCacheKey)).toMatchObject({ expect(
native.entries.value.find((entry) => entry.key === noCacheKey),
).toMatchObject({
mounted: false, mounted: false,
evictionReason: 'cache-disabled', evictionReason: "cache-disabled",
}) });
expect(await native.beginInteractive('pop')).not.toBeNull() expect(await native.beginInteractive("pop")).not.toBeNull();
expect(native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted).toBe(true) expect(
await native.cancelInteractive() native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted,
}) ).toBe(true);
await native.cancelInteractive();
});
it('keeps pinned views during normal trims and releases them when requested', async () => { it("keeps pinned views during normal trims and releases them when requested", async () => {
const { native } = await harness() const { native } = await harness();
await native.replace('/pinned', { presentation: 'none' }) await native.replace("/pinned", { presentation: "none" });
const pinnedKey = native.activeKey.value const pinnedKey = native.activeKey.value;
await native.push('/c') await native.push("/c");
native.trimCache() native.trimCache();
expect(native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted).toBe(true) expect(
native.trimCache({ includePinned: true }) native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted,
expect(native.entries.value.find((entry) => entry.key === pinnedKey)).toMatchObject({ ).toBe(true);
native.trimCache({ includePinned: true });
expect(
native.entries.value.find((entry) => entry.key === pinnedKey),
).toMatchObject({
mounted: false, mounted: false,
evictionReason: 'trimmed', evictionReason: "trimmed",
}) });
}) });
it('evicts a previously cached target when its guard rejects re-entry', async () => { it("evicts a previously cached target when its guard rejects re-entry", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.replace('/left', { presentation: 'none' }) await native.replace("/left", { presentation: "none" });
await native.sibling('/middle', { replace: true }) await native.sibling("/middle", { replace: true });
const cachedLeft = native.entries.value.find((entry) => entry.route.path === '/left') const cachedLeft = native.entries.value.find(
expect(cachedLeft?.mounted).toBe(true) (entry) => entry.route.path === "/left",
const removeGuard = router.beforeEach((to) => to.path === '/left' ? false : undefined) );
expect(cachedLeft?.mounted).toBe(true);
const removeGuard = router.beforeEach((to) =>
to.path === "/left" ? false : undefined,
);
expect(await native.sibling('/left', { replace: true })).toBe(false) expect(await native.sibling("/left", { replace: true })).toBe(false);
expect(native.entries.value.find((entry) => entry.key === cachedLeft?.key)).toMatchObject({ expect(
native.entries.value.find((entry) => entry.key === cachedLeft?.key),
).toMatchObject({
mounted: false, mounted: false,
evictionReason: 'navigation-rejected', evictionReason: "navigation-rejected",
}) });
expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0) expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0);
removeGuard() removeGuard();
}) });
it('does not preview a stale forward entry after pop then push', async () => { it("does not preview a stale forward entry after pop then push", async () => {
const { native } = await harness() const { native } = await harness();
await native.push('/b') await native.push("/b");
await native.pop() await native.pop();
await native.push('/c') await native.push("/c");
await native.beginInteractive('pop') await native.beginInteractive("pop");
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey) const target = native.entries.value.find(
expect(target?.route.path).toBe('/a') (entry) => entry.key === native.transaction.value?.toKey,
await native.cancelInteractive() );
}) expect(target?.route.path).toBe("/a");
await native.cancelInteractive();
});
it('dismisses with the presented route animation regardless of the route below it', async () => { it("dismisses with the presented route animation regardless of the route below it", async () => {
const { native } = await harness() const { native } = await harness();
await native.push('/b') await native.push("/b");
await native.present('/modal', 'sheet') await native.present("/modal", "sheet");
await native.beginInteractive('dismiss') await native.beginInteractive("dismiss");
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'sheet' }) expect(native.transaction.value).toMatchObject({
await native.cancelInteractive() direction: "back",
}) presentation: "sheet",
});
await native.cancelInteractive();
});
it('refuses to overlap a second transaction with an active gesture', async () => { it("refuses to overlap a second transaction with an active gesture", async () => {
const { native } = await harness() const { native } = await harness();
const first = await native.beginInteractive('push', '/b') const first = await native.beginInteractive("push", "/b");
expect(await native.beginInteractive('push', '/c')).toBeNull() expect(await native.beginInteractive("push", "/c")).toBeNull();
expect(native.transaction.value?.id).toBe(first) expect(native.transaction.value?.id).toBe(first);
await native.cancelInteractive() await native.cancelInteractive();
}) });
it('accepts imperative navigation as the previous transition finalizes', async () => { it("accepts imperative navigation as the previous transition finalizes", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.beginInteractive('push', '/b') await native.beginInteractive("push", "/b");
const finishing = native.finishInteractive(true) const finishing = native.finishInteractive(true);
const queued = native.push('/c') const queued = native.push("/c");
expect(await finishing).toBe(true) expect(await finishing).toBe(true);
expect(await queued).toBe(true) expect(await queued).toBe(true);
expect(router.currentRoute.value.path).toBe('/c') expect(router.currentRoute.value.path).toBe("/c");
expect(native.transaction.value).toBeNull() expect(native.transaction.value).toBeNull();
}) });
it('reconciles direct browser back navigation with the native stack', async () => { it("reconciles direct browser back navigation with the native stack", async () => {
const { router, native } = await harness() const { router, native } = await harness();
await native.push('/b') await native.push("/b");
const navigated = new Promise<void>((resolve) => { const navigated = new Promise<void>((resolve) => {
const remove = router.afterEach(() => { const remove = router.afterEach(() => {
remove() remove();
resolve() resolve();
}) });
}) });
router.back() router.back();
await navigated await navigated;
expect(router.currentRoute.value.path).toBe('/a') expect(router.currentRoute.value.path).toBe("/a");
expect(native.canGoBack.value).toBe(false) expect(native.canGoBack.value).toBe(false);
expect(await native.beginInteractive('pop')).toBeNull() expect(await native.beginInteractive("pop")).toBeNull();
}) });
}) });

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@ body,
z-index: 2147483646; z-index: 2147483646;
inset: 0; inset: 0;
background: #000; background: #000;
content: ''; content: "";
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
will-change: opacity; will-change: opacity;
@@ -65,58 +65,85 @@ body,
visibility: visible; visibility: visible;
} }
.nvr-view--from { z-index: 3; } .nvr-view--from {
.nvr-view--to { z-index: 2; } z-index: 3;
}
.nvr-view--to {
z-index: 2;
}
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from, .nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from { .nvr-view--from,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * -28%), 0, 0); transform: translate3d(calc(var(--native-progress) * -28%), 0, 0);
} }
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from::after, .nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from::after { .nvr-view--from::after,
opacity: calc(var(--native-progress) * .12); .nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--from::after {
opacity: calc(var(--native-progress) * 0.12);
} }
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--to, .nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--to { .nvr-view--to,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--to {
z-index: 4; z-index: 4;
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0); transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
box-shadow: -18px 0 42px rgba(0, 0, 0, .28); box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28);
} }
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--from { .nvr-router-view:is(
[data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--from {
z-index: 4; z-index: 4;
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0); transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
box-shadow: -18px 0 42px rgba(0, 0, 0, .25); box-shadow: -18px 0 42px rgba(0, 0, 0, 0.25);
} }
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to { .nvr-router-view:is(
[data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--to {
transform: translate3d(calc((var(--native-progress) - 1) * 28%), 0, 0); transform: translate3d(calc((var(--native-progress) - 1) * 28%), 0, 0);
} }
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to::after { .nvr-router-view:is(
opacity: calc((1 - var(--native-progress)) * .12); [data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * 0.12);
} }
/* Sibling routes are adjacent pages, not a foreground/background stack. */ /* Sibling routes are adjacent pages, not a foreground/background stack. */
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--from { .nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * -100%), 0, 0); transform: translate3d(calc(var(--native-progress) * -100%), 0, 0);
} }
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--to { .nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"]
.nvr-view--to {
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0); transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
} }
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--from { .nvr-router-view[data-native-presentation="slide"][data-native-direction="back"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0); transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
} }
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--to { .nvr-router-view[data-native-presentation="slide"][data-native-direction="back"]
.nvr-view--to {
transform: translate3d(calc((var(--native-progress) - 1) * 100%), 0, 0); transform: translate3d(calc((var(--native-progress) - 1) * 100%), 0, 0);
} }
.nvr-router-view[data-native-presentation="slide"] :is(.nvr-view--from, .nvr-view--to) { .nvr-router-view[data-native-presentation="slide"]
:is(.nvr-view--from, .nvr-view--to) {
z-index: 3; z-index: 3;
box-shadow: none; box-shadow: none;
} }
@@ -125,19 +152,20 @@ body,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to { .nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
z-index: 4; z-index: 4;
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0); transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
border-radius: calc((1 - var(--native-progress)) * 24px) calc((1 - var(--native-progress)) * 24px) 0 0; border-radius: calc((1 - var(--native-progress)) * 24px)
box-shadow: 0 -24px 60px rgba(0, 0, 0, .34); 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="modal"] .nvr-view--from, .nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from { .nvr-router-view[data-native-presentation="sheet"] .nvr-view--from {
transform: scale(calc(1 - var(--native-progress) * .04)); transform: scale(calc(1 - var(--native-progress) * 0.04));
border-radius: calc(var(--native-progress) * 18px); border-radius: calc(var(--native-progress) * 18px);
} }
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from::after, .nvr-router-view[data-native-presentation="modal"] .nvr-view--from::after,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from::after { .nvr-router-view[data-native-presentation="sheet"] .nvr-view--from::after {
opacity: calc(var(--native-progress) * .24); opacity: calc(var(--native-progress) * 0.24);
} }
.nvr-router-view[data-native-presentation="fade"] .nvr-view--from { .nvr-router-view[data-native-presentation="fade"] .nvr-view--from {
@@ -158,25 +186,35 @@ body,
touch-action: pan-x pinch-zoom; touch-action: pan-x pinch-zoom;
} }
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--from, .nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--from { .nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--from {
z-index: 4; z-index: 4;
transform: translate3d(0, calc(var(--native-progress) * 100%), 0); transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
border-radius: 22px 22px 0 0; border-radius: 22px 22px 0 0;
} }
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--to, .nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--to { .nvr-view--to,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--to {
z-index: 2; z-index: 2;
transform: scale(calc(.96 + var(--native-progress) * .04)); transform: scale(calc(0.96 + var(--native-progress) * 0.04));
border-radius: calc((1 - var(--native-progress)) * 18px); border-radius: calc((1 - var(--native-progress)) * 18px);
box-shadow: none; box-shadow: none;
} }
.nvr-router-view:is([data-native-presentation="modal"], [data-native-presentation="sheet"])[data-native-direction="back"] .nvr-view--to::after { .nvr-router-view:is(
opacity: calc((1 - var(--native-progress)) * .24); [data-native-presentation="modal"],
[data-native-presentation="sheet"]
)[data-native-direction="back"]
.nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * 0.24);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.nvr-view { will-change: auto; } .nvr-view {
will-change: auto;
}
} }

View File

@@ -1,196 +1,224 @@
import type { App, CSSProperties } from 'vue' import type { App, CSSProperties } from "vue";
import type { import type {
RouteLocationNormalizedLoaded, RouteLocationNormalizedLoaded,
RouteLocationRaw, RouteLocationRaw,
Router, Router,
} from 'vue-router' } from "vue-router";
export type NativePresentationName = export type NativePresentationName =
| 'push' | "push"
| 'reveal' | "reveal"
| 'slide' | "slide"
| 'fade' | "fade"
| 'modal' | "modal"
| 'sheet' | "sheet"
| 'none' | "none"
| (string & {}) | (string & {});
export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss' export type NativeGestureKind =
export type NativeDirection = 'forward' | 'back' | 'up' | 'down' "push" | "pop" | "sibling" | "present" | "dismiss";
export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted' export type NativeDirection = "forward" | "back" | "up" | "down";
export type NativeViewRole = 'active' | 'inactive' | 'from' | 'to' export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
export type NativeCachePolicy = boolean | 'pin' export type NativeViewRole = "active" | "inactive" | "from" | "to";
export type NativeCachePolicy = boolean | "pin";
export type NativeEvictionReason = export type NativeEvictionReason =
| 'cache-disabled' | "cache-disabled"
| 'cache-limit' | "cache-limit"
| 'navigation-rejected' | "navigation-rejected"
| 'popped' | "popped"
| 'manual' | "manual"
| 'trimmed' | "trimmed"
| 'memory-pressure' | "memory-pressure";
export type NativeDiagnosticEventType = export type NativeDiagnosticEventType =
| 'route-load-start' | "route-load-start"
| 'route-load-end' | "route-load-end"
| 'transaction-start' | "transaction-start"
| 'view-prepare-start' | "view-prepare-start"
| 'view-prepare-end' | "view-prepare-end"
| 'commit-start' | "commit-start"
| 'transaction-end' | "transaction-end"
| 'view-evicted' | "view-evicted";
export interface NativeDiagnosticEvent { export interface NativeDiagnosticEvent {
type: NativeDiagnosticEventType type: NativeDiagnosticEventType;
/** Monotonic `performance.now()` timestamp. */ /** Monotonic `performance.now()` timestamp. */
timestamp: number timestamp: number;
attempt?: number attempt?: number;
transactionId?: number transactionId?: number;
/** Route record name or declared path pattern; params and query values are omitted. */ /** Route record name or declared path pattern; params and query values are omitted. */
route?: string route?: string;
duration?: number duration?: number;
details?: Record<string, string | number | boolean | undefined> details?: Record<string, string | number | boolean | undefined>;
} }
export interface NativeRouteOptions { export interface NativeRouteOptions {
navigator?: string navigator?: string;
presentation?: NativePresentationName presentation?: NativePresentationName;
transition?: NativePresentationName transition?: NativePresentationName;
parent?: RouteLocationRaw | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw) parent?:
siblingGroup?: string | RouteLocationRaw
siblingOrder?: number | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw);
siblingHistory?: 'push' | 'replace' siblingGroup?: string;
siblingOrder?: number;
siblingHistory?: "push" | "replace";
/** `false` disables retention; `pin` exempts the route from LRU trimming. */ /** `false` disables retention; `pin` exempts the route from LRU trimming. */
cache?: NativeCachePolicy cache?: NativeCachePolicy;
gesture?: boolean | 'edge' | 'full' gesture?: boolean | "edge" | "full";
} }
declare module 'vue-router' { declare module "vue-router" {
interface RouteMeta { interface RouteMeta {
native?: NativeRouteOptions native?: NativeRouteOptions;
} }
} }
export interface NativeViewEntry { export interface NativeViewEntry {
key: string key: string;
route: RouteLocationNormalizedLoaded route: RouteLocationNormalizedLoaded;
status: NativeViewStatus status: NativeViewStatus;
mounted: boolean mounted: boolean;
synthetic: boolean synthetic: boolean;
/** True once Vue Router has made this route authoritative. */ /** True once Vue Router has made this route authoritative. */
committed: boolean committed: boolean;
lastUsed: number lastUsed: number;
scrollX: number scrollX: number;
scrollY: number scrollY: number;
evictionReason?: NativeEvictionReason evictionReason?: NativeEvictionReason;
} }
export interface NativeCacheStats { export interface NativeCacheStats {
maxInactive: number maxInactive: number;
descriptors: number descriptors: number;
mounted: number mounted: number;
inactive: number inactive: number;
pinned: number pinned: number;
evicted: number evicted: number;
totalEvictions: number totalEvictions: number;
lastEviction?: { key: string; route: string; reason: NativeEvictionReason } lastEviction?: { key: string; route: string; reason: NativeEvictionReason };
} }
export interface NativeViewLifecycle { export interface NativeViewLifecycle {
readonly key: string readonly key: string;
readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }> readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>;
readonly status: Readonly<{ value: NativeViewStatus }> readonly status: Readonly<{ value: NativeViewStatus }>;
readonly role: Readonly<{ value: NativeViewRole }> readonly role: Readonly<{ value: NativeViewRole }>;
readonly isActive: Readonly<{ value: boolean }> readonly isActive: Readonly<{ value: boolean }>;
readonly isVisible: Readonly<{ value: boolean }> readonly isVisible: Readonly<{ value: boolean }>;
readonly isPreview: Readonly<{ value: boolean }> readonly isPreview: Readonly<{ value: boolean }>;
readonly isCached: Readonly<{ value: boolean }> readonly isCached: Readonly<{ value: boolean }>;
readonly evictionReason: Readonly<{ value: NativeEvictionReason | undefined }> readonly evictionReason: Readonly<{
value: NativeEvictionReason | undefined;
}>;
} }
export interface NativeSourceRect { export interface NativeSourceRect {
top: number top: number;
left: number left: number;
width: number width: number;
height: number height: number;
viewportWidth: number viewportWidth: number;
viewportHeight: number viewportHeight: number;
} }
export interface NativeTransaction { export interface NativeTransaction {
id: number id: number;
kind: NativeGestureKind kind: NativeGestureKind;
direction: NativeDirection direction: NativeDirection;
presentation: NativePresentationName presentation: NativePresentationName;
fromKey: string fromKey: string;
toKey: string toKey: string;
progress: number progress: number;
velocity: number velocity: number;
phase: 'candidate' | 'interactive' | 'settling' | 'committing' | 'cancelled' phase: "candidate" | "interactive" | "settling" | "committing" | "cancelled";
replace: boolean replace: boolean;
sourceRect?: NativeSourceRect sourceRect?: NativeSourceRect;
} }
export interface NativePresentationContext { export interface NativePresentationContext {
progress: number progress: number;
role: 'from' | 'to' role: "from" | "to";
direction: NativeDirection direction: NativeDirection;
sourceRect?: NativeSourceRect sourceRect?: NativeSourceRect;
} }
export interface NativePresentationDefinition { export interface NativePresentationDefinition {
name: NativePresentationName name: NativePresentationName;
axis?: 'x' | 'y' axis?: "x" | "y";
layerStyle?: (context: NativePresentationContext) => CSSProperties layerStyle?: (context: NativePresentationContext) => CSSProperties;
} }
export interface NativePlatformAdapter { export interface NativePlatformAdapter {
name: string name: string;
install?: (runtime: NativeRouterRuntime) => void | (() => void) | Promise<void | (() => void)> install?: (
haptic?: (event: 'selection' | 'commit' | 'cancel') => void | Promise<void> runtime: NativeRouterRuntime,
exitAtRoot?: () => void | Promise<void> ) => void | (() => void) | Promise<void | (() => void)>;
haptic?: (event: "selection" | "commit" | "cancel") => void | Promise<void>;
exitAtRoot?: () => void | Promise<void>;
} }
export interface NativeRouterOptions { export interface NativeRouterOptions {
router: Router router: Router;
cache?: { maxInactive?: number } cache?: { maxInactive?: number };
edgeWidth?: number edgeWidth?: number;
platform?: NativePlatformAdapter platform?: NativePlatformAdapter;
presentations?: NativePresentationDefinition[] presentations?: NativePresentationDefinition[];
} }
export interface NativeNavigationOptions { export interface NativeNavigationOptions {
presentation?: NativePresentationName presentation?: NativePresentationName;
replace?: boolean replace?: boolean;
direction?: NativeDirection direction?: NativeDirection;
sourceRect?: NativeSourceRect sourceRect?: NativeSourceRect;
} }
export interface NativeRouterRuntime { export interface NativeRouterRuntime {
readonly router: Router readonly router: Router;
readonly entries: Readonly<{ value: readonly NativeViewEntry[] }> readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>;
readonly activeKey: Readonly<{ value: string }> readonly activeKey: Readonly<{ value: string }>;
readonly transaction: Readonly<{ value: NativeTransaction | null }> readonly transaction: Readonly<{ value: NativeTransaction | null }>;
readonly canGoBack: Readonly<{ value: boolean }> readonly canGoBack: Readonly<{ value: boolean }>;
readonly cacheStats: Readonly<{ value: NativeCacheStats }> readonly cacheStats: Readonly<{ value: NativeCacheStats }>;
install(app: App): void install(app: App): void;
push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean> push(
replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean> to: RouteLocationRaw,
sibling(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean> options?: NativeNavigationOptions,
pop(): Promise<boolean> ): Promise<boolean>;
present(to: RouteLocationRaw, presentation?: NativePresentationName): Promise<boolean> replace(
dismiss(): Promise<boolean> to: RouteLocationRaw,
preload(to: RouteLocationRaw): Promise<RouteLocationNormalizedLoaded> options?: NativeNavigationOptions,
beginInteractive(kind: NativeGestureKind, to?: RouteLocationRaw, options?: NativeNavigationOptions): Promise<number | null> ): Promise<boolean>;
updateInteractive(progress: number, velocity?: number): void sibling(
finishInteractive(forceCommit?: boolean): Promise<boolean> to: RouteLocationRaw,
cancelInteractive(): Promise<void> options?: NativeNavigationOptions,
): Promise<boolean>;
pop(): Promise<boolean>;
present(
to: RouteLocationRaw,
presentation?: NativePresentationName,
): Promise<boolean>;
dismiss(): Promise<boolean>;
preload(to: RouteLocationRaw): Promise<RouteLocationNormalizedLoaded>;
beginInteractive(
kind: NativeGestureKind,
to?: RouteLocationRaw,
options?: NativeNavigationOptions,
): Promise<number | null>;
updateInteractive(progress: number, velocity?: number): void;
finishInteractive(forceCommit?: boolean): Promise<boolean>;
cancelInteractive(): Promise<void>;
/** Unmount every inactive instance matching this location. Active and transitioning views are never unloaded. */ /** Unmount every inactive instance matching this location. Active and transitioning views are never unloaded. */
unload(to: RouteLocationRaw): number unload(to: RouteLocationRaw): number;
/** Unmount inactive cached views while retaining route/history descriptors. */ /** Unmount inactive cached views while retaining route/history descriptors. */
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void trimCache(options?: {
includePinned?: boolean;
reason?: NativeEvictionReason;
}): void;
/** Subscribe to timing-safe runtime diagnostics. No per-frame events are emitted here. */ /** Subscribe to timing-safe runtime diagnostics. No per-frame events are emitted here. */
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void;
registerPresentation(definition: NativePresentationDefinition): void registerPresentation(definition: NativePresentationDefinition): void;
presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined presentationFor(
dispose(): void name: NativePresentationName,
): NativePresentationDefinition | undefined;
dispose(): void;
} }

View File

@@ -1,10 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { resolve } from 'node:path' import { resolve } from "node:path";
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: 'dist', emptyOutDir: true, outDir: "dist",
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' }, emptyOutDir: true,
rollupOptions: { external: ['vue', 'vue-router'] }, lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
cssFileName: "style",
},
rollupOptions: { external: ["vue", "vue-router"] },
}, },
}) });

View File

@@ -3,8 +3,19 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"files": ["dist"], "files": [
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" }, "dist"
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, ],
"peerDependencies": { "@native-vue-router/core": "^0.1.0" } "scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": {
"@native-vue-router/core": "^0.1.0"
}
} }

View File

@@ -1,39 +1,48 @@
import type { NativePlatformAdapter, NativeRouterRuntime } from '@native-vue-router/core' import type {
NativePlatformAdapter,
NativeRouterRuntime,
} from "@native-vue-router/core";
export interface ElectronCommandLine { export interface ElectronCommandLine {
appendSwitch(name: string, value?: string): void appendSwitch(name: string, value?: string): void;
} }
export function disableElectronHistoryGestures(commandLine: ElectronCommandLine) { export function disableElectronHistoryGestures(
commandLine.appendSwitch('disable-features', 'OverscrollHistoryNavigation') commandLine: ElectronCommandLine,
) {
commandLine.appendSwitch("disable-features", "OverscrollHistoryNavigation");
} }
declare global { declare global {
interface Window { interface Window {
nativeVueHost?: { nativeVueHost?: {
onBack(callback: () => void): () => void onBack(callback: () => void): () => void;
onForward?(callback: () => void): () => void onForward?(callback: () => void): () => void;
onMemoryPressure?(callback: () => void): () => void onMemoryPressure?(callback: () => void): () => void;
} };
} }
} }
export function createElectronRendererAdapter(): NativePlatformAdapter { export function createElectronRendererAdapter(): NativePlatformAdapter {
return { return {
name: 'electron', name: "electron",
install(runtime: NativeRouterRuntime) { install(runtime: NativeRouterRuntime) {
const removeBack = window.nativeVueHost?.onBack(() => { const removeBack = window.nativeVueHost?.onBack(() => {
if (runtime.canGoBack.value) void runtime.pop() if (runtime.canGoBack.value) void runtime.pop();
}) });
const removeForward = window.nativeVueHost?.onForward?.(() => runtime.router.forward()) const removeForward = window.nativeVueHost?.onForward?.(() =>
const removeMemoryPressure = window.nativeVueHost?.onMemoryPressure?.(() => { runtime.router.forward(),
runtime.trimCache({ reason: 'memory-pressure' }) );
}) const removeMemoryPressure = window.nativeVueHost?.onMemoryPressure?.(
() => {
runtime.trimCache({ reason: "memory-pressure" });
},
);
return () => { return () => {
removeBack?.() removeBack?.();
removeForward?.() removeForward?.();
removeMemoryPressure?.() removeMemoryPressure?.();
} };
}, },
} };
} }

View File

@@ -1,10 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { resolve } from 'node:path' import { resolve } from "node:path";
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: 'dist', emptyOutDir: true, outDir: "dist",
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' }, emptyOutDir: true,
rollupOptions: { external: ['@native-vue-router/core'] }, lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
},
rollupOptions: { external: ["@native-vue-router/core"] },
}, },
}) });

View File

@@ -3,11 +3,20 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"files": ["dist"], "files": [
"sideEffects": ["./dist/style.css"], "dist"
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" }, ],
"sideEffects": [
"./dist/style.css"
],
"scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"exports": { "exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,71 +1,103 @@
import { defineComponent, h, type PropType } from 'vue' import { defineComponent, h, type PropType } from "vue";
import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router' import type {
import { useNativeRouter } from '@native-vue-router/core' RouteLocationNormalizedLoaded,
import './style.css' RouteLocationRaw,
} from "vue-router";
import { useNativeRouter } from "@native-vue-router/core";
import "./style.css";
export type NativePlatform = 'ios' | 'android' | 'desktop' export type NativePlatform = "ios" | "android" | "desktop";
export function detectNativePlatform(): NativePlatform { export function detectNativePlatform(): NativePlatform {
if (typeof navigator === 'undefined') return 'desktop' if (typeof navigator === "undefined") return "desktop";
if (/iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent) && ('ontouchend' in document)) return 'ios' if (
if (/Android/.test(navigator.userAgent)) return 'android' /iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent) &&
return 'desktop' "ontouchend" in document
)
return "ios";
if (/Android/.test(navigator.userAgent)) return "android";
return "desktop";
} }
export const NativeBackButton = defineComponent({ export const NativeBackButton = defineComponent({
name: 'NativeBackButton', name: "NativeBackButton",
props: { label: { type: String, default: 'Back' } }, props: { label: { type: String, default: "Back" } },
setup(props) { setup(props) {
const native = useNativeRouter() const native = useNativeRouter();
return () => h('button', { return () =>
type: 'button', h(
class: 'nvr-native-back', "button",
'aria-label': props.label, {
onClick: () => void native.pop(), type: "button",
}, [h('span', { 'aria-hidden': 'true' }, ''), h('span', props.label)]) class: "nvr-native-back",
"aria-label": props.label,
onClick: () => void native.pop(),
},
[h("span", { "aria-hidden": "true" }, ""), h("span", props.label)],
);
}, },
}) });
export interface NativeTabItem { export interface NativeTabItem {
label: string label: string;
to: RouteLocationRaw to: RouteLocationRaw;
icon?: string icon?: string;
activeWhen?: (current: RouteLocationNormalizedLoaded) => boolean activeWhen?: (current: RouteLocationNormalizedLoaded) => boolean;
} }
export const NativeTabBar = defineComponent({ export const NativeTabBar = defineComponent({
name: 'NativeTabBar', name: "NativeTabBar",
props: { props: {
items: { type: Array as PropType<NativeTabItem[]>, required: true }, items: { type: Array as PropType<NativeTabItem[]>, required: true },
}, },
setup(props) { setup(props) {
const native = useNativeRouter() const native = useNativeRouter();
return () => h('nav', { class: 'nvr-native-tabs', 'aria-label': 'Primary navigation' }, return () =>
props.items.map((item) => { h(
const current = native.router.currentRoute.value "nav",
const exact = current.path === native.router.resolve(item.to).path { class: "nvr-native-tabs", "aria-label": "Primary navigation" },
const active = exact || Boolean(item.activeWhen?.(current)) props.items.map((item) => {
const href = native.router.resolve(item.to).href const current = native.router.currentRoute.value;
return h('a', { const exact = current.path === native.router.resolve(item.to).path;
href, const active = exact || Boolean(item.activeWhen?.(current));
class: ['nvr-native-tab', active && 'nvr-native-tab--active'], const href = native.router.resolve(item.to).href;
'aria-current': active ? 'page' : undefined, return h(
onClick: (event: MouseEvent) => { "a",
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return {
event.preventDefault() href,
if (exact) return class: ["nvr-native-tab", active && "nvr-native-tab--active"],
void native.sibling(item.to, { replace: true }) "aria-current": active ? "page" : undefined,
}, onClick: (event: MouseEvent) => {
}, [ if (
h('span', { class: 'nvr-native-tab__icon', 'aria-hidden': 'true' }, item.icon ?? '•'), event.defaultPrevented ||
h('span', item.label), event.button !== 0 ||
]) event.metaKey ||
})) event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
event.preventDefault();
if (exact) return;
void native.sibling(item.to, { replace: true });
},
},
[
h(
"span",
{ class: "nvr-native-tab__icon", "aria-hidden": "true" },
item.icon ?? "•",
),
h("span", item.label),
],
);
}),
);
}, },
}) });
export const nativeMotionTokens = { export const nativeMotionTokens = {
ios: { edgeWidth: 28, commitThreshold: 0.36 }, ios: { edgeWidth: 28, commitThreshold: 0.36 },
android: { edgeWidth: 24, commitThreshold: 0.32 }, android: { edgeWidth: 24, commitThreshold: 0.32 },
desktop: { edgeWidth: 18, commitThreshold: 0.4 }, desktop: { edgeWidth: 18, commitThreshold: 0.4 },
} as const } as const;

View File

@@ -30,9 +30,14 @@
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: 1fr; grid-auto-columns: 1fr;
padding: 8px max(12px, var(--nvr-safe-right)) calc(8px + var(--nvr-safe-bottom)) max(12px, var(--nvr-safe-left)); padding: 8px max(12px, var(--nvr-safe-right))
calc(8px + var(--nvr-safe-bottom)) max(12px, var(--nvr-safe-left));
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, var(--nvr-view-background, #fff) 88%, transparent); background: color-mix(
in srgb,
var(--nvr-view-background, #fff) 88%,
transparent
);
backdrop-filter: blur(24px) saturate(1.6); backdrop-filter: blur(24px) saturate(1.6);
} }
@@ -48,9 +53,16 @@
font-weight: 600; font-weight: 600;
} }
.nvr-native-tab--active { color: var(--nvr-accent); } .nvr-native-tab--active {
.nvr-native-tab__icon { font-size: 19px; line-height: 1.2; } color: var(--nvr-accent);
}
.nvr-native-tab__icon {
font-size: 19px;
line-height: 1.2;
}
@media (pointer: fine) and (min-width: 900px) { @media (pointer: fine) and (min-width: 900px) {
.nvr-native-tabs { border-radius: 18px 18px 0 0; } .nvr-native-tabs {
border-radius: 18px 18px 0 0;
}
} }

View File

@@ -1,10 +1,18 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { resolve } from 'node:path' import { resolve } from "node:path";
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: 'dist', emptyOutDir: true, outDir: "dist",
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' }, emptyOutDir: true,
rollupOptions: { external: ['vue', 'vue-router', '@native-vue-router/core'] }, lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
cssFileName: "style",
},
rollupOptions: {
external: ["vue", "vue-router", "@native-vue-router/core"],
},
}, },
}) });

View File

@@ -1,16 +1,16 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: './apps/demo/e2e', testDir: "./apps/demo/e2e",
use: { baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure' }, use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" },
projects: [ projects: [
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'] } }, { name: "mobile-chromium", use: { ...devices["Pixel 7"] } },
{ name: 'mobile-webkit', use: { ...devices['iPhone 15'] } }, { name: "mobile-webkit", use: { ...devices["iPhone 15"] } },
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: "desktop", use: { ...devices["Desktop Chrome"] } },
], ],
webServer: { webServer: {
command: 'npm run build && npm run preview -- --host 127.0.0.1', command: "npm run build && npm run preview -- --host 127.0.0.1",
port: 4173, port: 4173,
reuseExistingServer: true, reuseExistingServer: true,
}, },
}) });

View File

@@ -12,7 +12,9 @@
"paths": { "paths": {
"@/*": ["./apps/demo/src/*"], "@/*": ["./apps/demo/src/*"],
"@native-vue-router/core": ["./packages/core/src/index.ts"], "@native-vue-router/core": ["./packages/core/src/index.ts"],
"@native-vue-router/preset-native": ["./packages/preset-native/src/index.ts"], "@native-vue-router/preset-native": [
"./packages/preset-native/src/index.ts"
],
"@native-vue-router/capacitor": ["./packages/capacitor/src/index.ts"], "@native-vue-router/capacitor": ["./packages/capacitor/src/index.ts"],
"@native-vue-router/electron": ["./packages/electron/src/index.ts"] "@native-vue-router/electron": ["./packages/electron/src/index.ts"]
} }

View File

@@ -20,5 +20,10 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "apps/capacitor/capacitor.config.ts"] "include": [
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
"apps/capacitor/capacitor.config.ts"
]
} }

View File

@@ -1,75 +1,120 @@
import path from 'node:path' import path from "node:path";
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
import vue from '@vitejs/plugin-vue' import vue from "@vitejs/plugin-vue";
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({ export default defineConfig({
base: './', base: "./",
root: path.resolve(__dirname, 'apps/demo'), root: path.resolve(__dirname, "apps/demo"),
publicDir: path.resolve(__dirname, 'public'), publicDir: path.resolve(__dirname, "public"),
define: { define: {
__NVR_BUILD_ID__: JSON.stringify(new Date().toISOString()), __NVR_BUILD_ID__: JSON.stringify(new Date().toISOString()),
}, },
server: { server: {
// iOS Safari can otherwise retain a tunnelled development response after // iOS Safari can otherwise retain a tunnelled development response after
// the dev server has restarted with a new build. // the dev server has restarted with a new build.
headers: { 'Cache-Control': 'no-store' }, headers: { "Cache-Control": "no-store" },
allowedHosts: ["demo.native-router.harvmaster.com"],
}, },
preview: { preview: {
// Production hosts should apply the same policy at least to index.html and // Production hosts should apply the same policy at least to index.html and
// sw.js. Fingerprinted assets can use immutable caching when deployed. // sw.js. Fingerprinted assets can use immutable caching when deployed.
headers: { 'Cache-Control': 'no-cache' }, headers: { "Cache-Control": "no-cache" },
}, },
plugins: [ plugins: [
vue(), vue(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: "autoUpdate",
devOptions: { devOptions: {
enabled: true, enabled: true,
navigateFallback: 'index.html', navigateFallback: "index.html",
suppressWarnings: true, suppressWarnings: true,
}, },
includeAssets: ['favicon.svg', 'app-icon.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'], includeAssets: [
"favicon.svg",
"app-icon.svg",
"apple-touch-icon.png",
"pwa-192.png",
"pwa-512.png",
],
manifest: { manifest: {
id: '/', id: "/",
name: 'Native Vue Messenger', name: "Native Vue Messenger",
short_name: 'NVR Messenger', short_name: "NVR Messenger",
description: 'Gesture-first navigation for Vue applications', description: "Gesture-first navigation for Vue applications",
theme_color: '#0b0d12', theme_color: "#0b0d12",
background_color: '#0b0d12', background_color: "#0b0d12",
display: 'standalone', display: "standalone",
scope: '/', scope: "/",
orientation: 'any', orientation: "any",
start_url: '/', start_url: "/",
icons: [ icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, {
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, src: "/pwa-192.png",
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, sizes: "192x192",
{ src: '/app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/app-icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
], ],
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
clientsClaim: true, clientsClaim: true,
skipWaiting: true, skipWaiting: true,
navigateFallback: '/index.html', navigateFallback: "/index.html",
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
}, },
}), }),
], ],
resolve: { resolve: {
alias: [ alias: [
{ find: /^@\/(.*)$/, replacement: `${path.resolve(__dirname, 'apps/demo/src')}/$1` }, {
{ find: /^@native-vue-router\/core$/, replacement: path.resolve(__dirname, 'packages/core/src/index.ts') }, find: /^@\/(.*)$/,
{ find: /^@native-vue-router\/preset-native$/, replacement: path.resolve(__dirname, 'packages/preset-native/src/index.ts') }, replacement: `${path.resolve(__dirname, "apps/demo/src")}/$1`,
{ find: /^@native-vue-router\/capacitor$/, replacement: path.resolve(__dirname, 'packages/capacitor/src/index.ts') }, },
{ find: /^@native-vue-router\/electron$/, replacement: path.resolve(__dirname, 'packages/electron/src/index.ts') }, {
find: /^@native-vue-router\/core$/,
replacement: path.resolve(__dirname, "packages/core/src/index.ts"),
},
{
find: /^@native-vue-router\/preset-native$/,
replacement: path.resolve(
__dirname,
"packages/preset-native/src/index.ts",
),
},
{
find: /^@native-vue-router\/capacitor$/,
replacement: path.resolve(__dirname, "packages/capacitor/src/index.ts"),
},
{
find: /^@native-vue-router\/electron$/,
replacement: path.resolve(__dirname, "packages/electron/src/index.ts"),
},
], ],
}, },
build: { build: {
outDir: path.resolve(__dirname, 'apps/demo/dist'), outDir: path.resolve(__dirname, "apps/demo/dist"),
emptyOutDir: true, emptyOutDir: true,
}, },
}) });

View File

@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'happy-dom', environment: "happy-dom",
include: ['packages/**/*.test.ts'], include: ["packages/**/*.test.ts"],
coverage: { reporter: ['text', 'html'] }, coverage: { reporter: ["text", "html"] },
}, },
}) });