diff --git a/README.md b/README.md
index 78fe94e..0c630cf 100644
--- a/README.md
+++ b/README.md
@@ -48,31 +48,35 @@ An installed web app cannot access `WKWebView.allowsBackForwardNavigationGesture
## Minimal integration
```ts
-import { createApp } from 'vue'
-import { createRouter, createWebHistory } from 'vue-router'
-import { createNativeRouter } from '@native-vue-router/core'
+import { createApp } from "vue";
+import { createRouter, createWebHistory } from "vue-router";
+import { createNativeRouter } from "@native-vue-router/core";
const router = createRouter({
history: createWebHistory(),
routes: [
- { path: '/', component: Home },
+ { path: "/", component: Home },
{
- path: '/chat/:id',
+ path: "/chat/:id",
component: Chat,
meta: {
- native: { presentation: 'push', parent: '/', gesture: 'edge' },
+ native: { presentation: "push", parent: "/", gesture: "edge" },
},
},
],
-})
+});
-const nativeRouter = createNativeRouter({ router })
-createApp(App).use(router).use(nativeRouter).mount('#app')
+const nativeRouter = createNativeRouter({ router });
+createApp(App).use(router).use(nativeRouter).mount("#app");
```
```vue
@@ -100,16 +104,16 @@ The Navigation Lab contains an opt-in profiler. Tap **Start profiling**, leave t
The core API is also available directly:
```ts
-import { createNativeNavigationProfiler } from '@native-vue-router/core'
+import { createNativeNavigationProfiler } from "@native-vue-router/core";
const profiler = createNativeNavigationProfiler(nativeRouter, {
metadata: { build: import.meta.env.VITE_BUILD_ID },
-})
+});
-profiler.start()
+profiler.start();
// Reproduce the navigation issue.
-const report = profiler.stop()
-const json = profiler.toJSON(report)
+const report = profiler.stop();
+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.
diff --git a/apps/capacitor/capacitor.config.ts b/apps/capacitor/capacitor.config.ts
index bcf4d9a..6b38b36 100644
--- a/apps/capacitor/capacitor.config.ts
+++ b/apps/capacitor/capacitor.config.ts
@@ -1,21 +1,21 @@
-import type { CapacitorConfig } from '@capacitor/cli'
+import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
- appId: 'dev.nativevuerouter.messenger',
- appName: 'Native Vue Messenger',
- webDir: '../demo/dist',
- backgroundColor: '#0b0d12',
+ appId: "dev.nativevuerouter.messenger",
+ appName: "Native Vue Messenger",
+ webDir: "../demo/dist",
+ backgroundColor: "#0b0d12",
plugins: {
App: { disableBackButtonHandler: true },
SplashScreen: {
launchAutoHide: true,
- backgroundColor: '#0b0d12',
- androidScaleType: 'CENTER_CROP',
+ backgroundColor: "#0b0d12",
+ androidScaleType: "CENTER_CROP",
},
- StatusBar: { style: 'DARK', backgroundColor: '#0b0d12' },
+ StatusBar: { style: "DARK", backgroundColor: "#0b0d12" },
},
- android: { backgroundColor: '#0b0d12' },
- ios: { backgroundColor: '#0b0d12', contentInset: 'never' },
-}
+ android: { backgroundColor: "#0b0d12" },
+ ios: { backgroundColor: "#0b0d12", contentInset: "never" },
+};
-export default config
+export default config;
diff --git a/apps/capacitor/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/capacitor/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
index 9b7d382..00b5bd3 100644
--- a/apps/capacitor/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/apps/capacitor/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,14 +1,14 @@
{
- "images" : [
+ "images": [
{
- "filename" : "AppIcon-512@2x.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
+ "filename": "AppIcon-512@2x.png",
+ "idiom": "universal",
+ "platform": "ios",
+ "size": "1024x1024"
}
],
- "info" : {
- "author" : "xcode",
- "version" : 1
+ "info": {
+ "author": "xcode",
+ "version": 1
}
}
diff --git a/apps/capacitor/ios/App/App/Assets.xcassets/Contents.json b/apps/capacitor/ios/App/App/Assets.xcassets/Contents.json
index da4a164..97a8662 100644
--- a/apps/capacitor/ios/App/App/Assets.xcassets/Contents.json
+++ b/apps/capacitor/ios/App/App/Assets.xcassets/Contents.json
@@ -1,6 +1,6 @@
{
- "info" : {
- "version" : 1,
- "author" : "xcode"
+ "info": {
+ "version": 1,
+ "author": "xcode"
}
-}
\ No newline at end of file
+}
diff --git a/apps/capacitor/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/apps/capacitor/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
index d7d96a6..b781492 100644
--- a/apps/capacitor/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
+++ b/apps/capacitor/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
@@ -1,23 +1,23 @@
{
- "images" : [
+ "images": [
{
- "idiom" : "universal",
- "filename" : "splash-2732x2732-2.png",
- "scale" : "1x"
+ "idiom": "universal",
+ "filename": "splash-2732x2732-2.png",
+ "scale": "1x"
},
{
- "idiom" : "universal",
- "filename" : "splash-2732x2732-1.png",
- "scale" : "2x"
+ "idiom": "universal",
+ "filename": "splash-2732x2732-1.png",
+ "scale": "2x"
},
{
- "idiom" : "universal",
- "filename" : "splash-2732x2732.png",
- "scale" : "3x"
+ "idiom": "universal",
+ "filename": "splash-2732x2732.png",
+ "scale": "3x"
}
],
- "info" : {
- "version" : 1,
- "author" : "xcode"
+ "info": {
+ "version": 1,
+ "author": "xcode"
}
-}
\ No newline at end of file
+}
diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts
index 00d5c65..2d1347c 100644
--- a/apps/demo/e2e/navigation.spec.ts
+++ b/apps/demo/e2e/navigation.spec.ts
@@ -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) {
await page.evaluate(() => {
const state = window as typeof window & {
- __nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
- __nvrObserver?: MutationObserver
- }
- state.__nvrObserver?.disconnect()
- state.__nvrEvents = []
- const view = document.querySelector('.nvr-router-view')
- if (!view) throw new Error('Native router view did not render')
+ __nvrEvents?: Array<{
+ direction: string | null;
+ presentation: string | null;
+ }>;
+ __nvrObserver?: MutationObserver;
+ };
+ 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(() => {
- if (view.classList.contains('nvr-router-view--interactive')) {
+ if (view.classList.contains("nvr-router-view--interactive")) {
state.__nvrEvents?.push({
- direction: view.getAttribute('data-native-direction'),
- presentation: view.getAttribute('data-native-presentation'),
- })
+ direction: view.getAttribute("data-native-direction"),
+ presentation: view.getAttribute("data-native-presentation"),
+ });
}
- })
- state.__nvrObserver.observe(view, { attributes: true })
- })
+ });
+ state.__nvrObserver.observe(view, { attributes: true });
+ });
}
async function recordedTransitions(page: Page) {
- return await page.evaluate(() => (window as typeof window & {
- __nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
- }).__nvrEvents ?? [])
+ return await page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __nvrEvents?: Array<{
+ direction: string | null;
+ presentation: string | null;
+ }>;
+ }
+ ).__nvrEvents ?? [],
+ );
}
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) {
// Start on the route header, outside conversation-owned drag targets.
- 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()
- 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()
+ 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();
+ 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) {
- await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 })
- await page.waitForTimeout(90)
+ await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 });
+ await page.waitForTimeout(90);
}
- await page.mouse.move(surface.x + surface.width * 0.27, y)
- await page.mouse.up()
+ await page.mouse.move(surface.x + surface.width * 0.27, y);
+ await page.mouse.up();
}
-test('navigates a conversation and returns through the native runtime', async ({ page }) => {
- await page.goto('/inbox')
- await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
- await page.getByText('Maya Chen').last().click()
- await expect(page).toHaveURL(/\/chat\/maya$/)
- await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
- await page.getByRole('button', { name: 'Back' }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
- await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
-})
+test("navigates a conversation and returns through the native runtime", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
+ await page.getByText("Maya Chen").last().click();
+ await expect(page).toHaveURL(/\/chat\/maya$/);
+ await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
+ await page.getByRole("button", { name: "Back" }).click();
+ 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 }) => {
- await page.goto('/inbox')
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(page).toHaveURL(/\/stories$/)
- await waitForTransition(page)
- await expect(page.getByRole('heading', { name: 'Stories' })).toBeVisible()
- await page.getByRole('link', { name: /You/ }).click()
- await expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await expect(page.getByRole('heading', { name: 'You' })).toBeVisible()
-})
+test("switches sibling routes without growing the primary history flow", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.getByRole("link", { name: /Stories/ }).click();
+ await expect(page).toHaveURL(/\/stories$/);
+ await waitForTransition(page);
+ await expect(page.getByRole("heading", { name: "Stories" })).toBeVisible();
+ await page.getByRole("link", { name: /You/ }).click();
+ 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 }) => {
- await page.goto('/inbox')
- await captureTransitions(page)
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(page).toHaveURL(/\/stories$/)
- await waitForTransition(page)
- expect(await recordedTransitions(page)).toContainEqual({ direction: 'forward', presentation: 'slide' })
+test("uses route order for tab direction and does not animate the active tab", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await captureTransitions(page);
+ await page.getByRole("link", { name: /Stories/ }).click();
+ await expect(page).toHaveURL(/\/stories$/);
+ await waitForTransition(page);
+ expect(await recordedTransitions(page)).toContainEqual({
+ direction: "forward",
+ presentation: "slide",
+ });
- await captureTransitions(page)
- await page.getByRole('link', { name: /Inbox/ }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
- expect(await recordedTransitions(page)).toContainEqual({ direction: 'back', presentation: 'slide' })
+ await captureTransitions(page);
+ await page.getByRole("link", { name: /Inbox/ }).click();
+ await expect(page).toHaveURL(/\/inbox$/);
+ await waitForTransition(page);
+ expect(await recordedTransitions(page)).toContainEqual({
+ direction: "back",
+ presentation: "slide",
+ });
- await captureTransitions(page)
- await page.getByRole('link', { name: /Inbox/ }).click()
- await page.waitForTimeout(100)
- expect(await recordedTransitions(page)).toEqual([])
- await expect(page).toHaveURL(/\/inbox$/)
-})
+ await captureTransitions(page);
+ await page.getByRole("link", { name: /Inbox/ }).click();
+ await page.waitForTimeout(100);
+ expect(await recordedTransitions(page)).toEqual([]);
+ await expect(page).toHaveURL(/\/inbox$/);
+});
-test('interrupts an active tab animation when another tab is tapped', async ({ page }) => {
- await page.goto('/inbox')
- const routerView = page.locator('.nvr-router-view')
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
- const firstTransaction = await routerView.getAttribute('data-native-transaction')
- expect(firstTransaction).not.toBeNull()
+test("interrupts an active tab animation when another tab is tapped", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ const routerView = page.locator(".nvr-router-view");
+ await page.getByRole("link", { name: /Stories/ }).click();
+ 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 expect(routerView).not.toHaveAttribute('data-native-transaction', firstTransaction!, { 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$/)
-})
+ await page.getByRole("link", { name: /You/ }).click();
+ await expect(routerView).not.toHaveAttribute(
+ "data-native-transaction",
+ firstTransaction!,
+ { 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 }) => {
- await page.goto('/inbox')
- await page.getByText('Maya Chen').last().click()
- await expect(page).toHaveURL(/\/chat\/maya$/)
- const routerView = page.locator('.nvr-router-view')
- await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
- const pushTransaction = await routerView.getAttribute('data-native-transaction')
+test("interrupts a settling push animation with an edge-back gesture", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.getByText("Maya Chen").last().click();
+ await expect(page).toHaveURL(/\/chat\/maya$/);
+ 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()
- 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.down()
- await page.mouse.move(frame.x + frame.width * 0.34, frame.y + frame.height * 0.5, { steps: 16 })
+ const frame = await page.locator(".app-frame").boundingBox();
+ 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.down();
+ 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(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()
-})
+ await expect(routerView).not.toHaveAttribute(
+ "data-native-transaction",
+ pushTransaction!,
+ { 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 }) => {
- await page.goto('/stories')
- const routerView = page.locator('.nvr-router-view')
- const frame = await routerView.boundingBox()
- if (!frame) throw new Error('Native router view did not render')
+test("moves sibling screens edge-to-edge at one-to-one drag progress", async ({
+ page,
+}) => {
+ await page.goto("/stories");
+ 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.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')
+ await page.mouse.move(
+ frame.x + frame.width * 0.55,
+ frame.y + frame.height * 0.45,
+ );
+ 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 to = await page.locator('[data-native-role="to"]').boundingBox()
- if (!from || !to) throw new Error('Both sibling pages must be live during a drag')
- expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3)
- await page.mouse.up()
-})
+ const from = await page.locator('[data-native-role="from"]').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");
+ 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 }) => {
- await page.goto('/inbox')
- const routerView = page.locator('.nvr-router-view')
+test("accepts a second fast tab flick while the first spring is still settling", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ const routerView = page.locator(".nvr-router-view");
// Commit by distance with a deliberately slow final sample, leaving enough
// baseline spring for the second fast gesture to interrupt deterministically.
- await flickToNextTab(page, true)
- await expect(page).toHaveURL(/\/stories$/)
- await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
+ await flickToNextTab(page, true);
+ await expect(page).toHaveURL(/\/stories$/);
+ await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
- await flickToNextTab(page)
- await expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
+ await flickToNextTab(page);
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
// A stale pointer-up cleanup used to leave an orphaned transaction here,
// permanently blocking both subsequent swipes and imperative tab links.
- await page.getByRole('link', { name: /Inbox/ }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
- await expect(routerView).not.toHaveAttribute('data-native-transaction')
-})
+ await page.getByRole("link", { name: /Inbox/ }).click();
+ await expect(page).toHaveURL(/\/inbox$/);
+ await waitForTransition(page);
+ await expect(routerView).not.toHaveAttribute("data-native-transaction");
+});
-test('renders a suspended pushed sibling and evicts it after backing out', async ({ page }) => {
- await page.goto('/profile')
- await page.getByRole('link', { name: /Runtime stress lab/ }).click()
+test("renders a suspended pushed sibling and evicts it after backing out", async ({
+ page,
+}) => {
+ await page.goto("/profile");
+ await page.getByRole("link", { name: /Runtime stress lab/ }).click();
- await expect(page.getByTestId('async-data-loading')).toBeVisible()
- await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
- await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible()
- await expect(page.getByRole('link', { name: /You/ })).toHaveAttribute('aria-current', 'page')
- await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
+ await expect(page.getByTestId("async-data-loading")).toBeVisible();
+ await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
+ await expect(
+ page.getByRole("navigation", { name: "Primary navigation" }),
+ ).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 firstMountId = await lab.getAttribute('data-mount-id')
- await page.getByRole('button', { name: 'Back' }).click()
- await expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
+ const lab = page.getByTestId("runtime-lab-view");
+ const firstMountId = await lab.getAttribute("data-mount-id");
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
// Popping a pushed route removes it after the exit animation. Keeping the
// 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.
- await page.evaluate(() => history.forward())
- await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
- await expect(page.getByTestId('async-data-loading')).toBeVisible()
- await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
- await expect(page.getByTestId('runtime-lab-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
-})
+ await page.evaluate(() => history.forward());
+ await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
+ await expect(page.getByTestId("async-data-loading")).toBeVisible();
+ await expect(page.getByTestId("async-data-ready")).toBeVisible({
+ 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 }) => {
- await page.goto('/profile')
- await page.getByRole('link', { name: /Runtime stress lab/ }).click()
- await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
+test("clicking the root tab from a pushed sibling collapses its back history", async ({
+ page,
+}) => {
+ 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 expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
+ await page.getByRole("link", { name: /You/ }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
- await expect(page.locator('.nvr-navigator')).toHaveAttribute('data-native-can-go-back', 'false')
- await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0)
-})
+ await expect(page.locator(".nvr-navigator")).toHaveAttribute(
+ "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 }) => {
- await page.goto('/stories')
- 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)
+test("lazily caches a visited sibling and pauses its active work while hidden", async ({
+ page,
+}) => {
+ await page.goto("/stories");
+ 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 expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await expect(stories).toHaveCount(1)
+ await page.getByRole("link", { name: /You/ }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
+ await expect(stories).toHaveCount(1);
- const hiddenTicks = Number(await stories.getAttribute('data-active-ticks'))
- await page.waitForTimeout(600)
- await expect(stories).toHaveAttribute('data-active-ticks', String(hiddenTicks))
+ const hiddenTicks = Number(await stories.getAttribute("data-active-ticks"));
+ await page.waitForTimeout(600);
+ await expect(stories).toHaveAttribute(
+ "data-active-ticks",
+ String(hiddenTicks),
+ );
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(page).toHaveURL(/\/stories$/)
- await waitForTransition(page)
- await expect(stories).toHaveAttribute('data-mount-id', mountId!)
- await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(hiddenTicks)
-})
+ await page.getByRole("link", { name: /Stories/ }).click();
+ await expect(page).toHaveURL(/\/stories$/);
+ await waitForTransition(page);
+ await expect(stories).toHaveAttribute("data-mount-id", mountId!);
+ 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 }) => {
- await page.goto('/stories')
- const stories = page.getByTestId('stories-view')
- const firstMountId = await stories.getAttribute('data-mount-id')
+test("evicts a cached sibling when its dynamic entry guard rejects it", async ({
+ page,
+}) => {
+ 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 expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await expect(stories).toHaveCount(1)
+ await page.getByRole("link", { name: /You/ }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
+ await expect(stories).toHaveCount(1);
- const guardToggle = page.getByRole('button', { name: 'Block Stories re-entry' })
- await guardToggle.click()
- await expect(guardToggle).toHaveAttribute('aria-pressed', 'true')
- await page.getByRole('link', { name: /Stories/ }).click()
+ const guardToggle = page.getByRole("button", {
+ name: "Block Stories re-entry",
+ });
+ 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.getByTestId('story-guard-status')).toHaveText('blocked')
- await waitForTransition(page)
- await expect(page.getByTestId('stories-view')).toHaveCount(0)
+ await expect(page).toHaveURL(/\/profile$/);
+ await expect(page.getByTestId("story-guard-status")).toHaveText("blocked");
+ await waitForTransition(page);
+ await expect(page.getByTestId("stories-view")).toHaveCount(0);
- await guardToggle.click()
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(page).toHaveURL(/\/stories$/)
- await waitForTransition(page)
- await expect(page.getByTestId('stories-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
-})
+ await guardToggle.click();
+ await page.getByRole("link", { name: /Stories/ }).click();
+ await expect(page).toHaveURL(/\/stories$/);
+ await waitForTransition(page);
+ 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 }) => {
- await page.goto('/stories')
- await page.getByRole('link', { name: /You/ }).click()
- await expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await expect(page.getByTestId('stories-view')).toHaveCount(1)
+test("manually unloads an inactive route through the public API demo", async ({
+ page,
+}) => {
+ await page.goto("/stories");
+ await page.getByRole("link", { name: /You/ }).click();
+ 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 expect(page).toHaveURL(/\/settings$/)
- await waitForTransition(page)
- await page.getByTestId('unload-stories').click()
+ await page.getByRole("link", { name: /Navigation lab/ }).click();
+ await expect(page).toHaveURL(/\/settings$/);
+ await waitForTransition(page);
+ await page.getByTestId("unload-stories").click();
- await expect(page.getByTestId('unload-stories')).toHaveText('Unloaded 1 Stories view')
- await expect(page.getByTestId('stories-view')).toHaveCount(0)
-})
+ await expect(page.getByTestId("unload-stories")).toHaveText(
+ "Unloaded 1 Stories view",
+ );
+ await expect(page.getByTestId("stories-view")).toHaveCount(0);
+});
-test('records a navigation frame profile across route changes', async ({ page }) => {
- await page.goto('/settings')
- await page.getByTestId('profile-start').click()
- await expect(page.locator('.profiler-badge')).toBeVisible()
+test("records a navigation frame profile across route changes", async ({
+ page,
+}) => {
+ 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 expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await page.getByRole('link', { name: /Runtime stress lab/ }).click()
- await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
- await page.getByRole('link', { name: /You/ }).click()
- await expect(page).toHaveURL(/\/profile$/)
- await waitForTransition(page)
- await page.getByRole('link', { name: /Navigation lab/ }).click()
- await expect(page).toHaveURL(/\/settings$/)
- await waitForTransition(page)
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
+ await page.getByRole("link", { name: /Runtime stress lab/ }).click();
+ await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
+ await page.getByRole("link", { name: /You/ }).click();
+ await expect(page).toHaveURL(/\/profile$/);
+ await waitForTransition(page);
+ await page.getByRole("link", { name: /Navigation lab/ }).click();
+ await expect(page).toHaveURL(/\/settings$/);
+ await waitForTransition(page);
- await page.getByTestId('profile-stop').click()
- await expect(page.locator('.profiler-badge')).toHaveCount(0)
- await expect(page.getByTestId('profile-status')).toContainText('navigations')
- await expect(page.getByTestId('profile-export')).toBeEnabled()
-})
+ await page.getByTestId("profile-stop").click();
+ await expect(page.locator(".profiler-badge")).toHaveCount(0);
+ await expect(page.getByTestId("profile-status")).toContainText("navigations");
+ await expect(page.getByTestId("profile-export")).toBeEnabled();
+});
-test('opens and dismisses the compose sheet', async ({ page }) => {
- await page.goto('/inbox')
- await page.getByRole('button', { name: 'Compose' }).click()
- await expect(page).toHaveURL(/\/compose$/)
- await expect(page.getByRole('heading', { name: 'New message' })).toBeVisible()
- await page.getByRole('button', { name: 'Cancel' }).click()
- await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
-})
+test("opens and dismisses the compose sheet", async ({ page }) => {
+ await page.goto("/inbox");
+ await page.getByRole("button", { name: "Compose" }).click();
+ await expect(page).toHaveURL(/\/compose$/);
+ await expect(
+ page.getByRole("heading", { name: "New message" }),
+ ).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 }) => {
- await page.goto('/inbox')
- const row = page.locator('.conversation-row').first()
- const box = await row.boundingBox()
- 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.down()
- await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, { steps: 12 })
- await expect(page.locator('[data-native-role="to"]')).toBeVisible()
- await page.mouse.up()
- await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
-})
+test("keeps the target live during a held component drag", async ({ page }) => {
+ await page.goto("/inbox");
+ const row = page.locator(".conversation-row").first();
+ const box = await row.boundingBox();
+ 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.down();
+ await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, {
+ steps: 12,
+ });
+ 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 }) => {
- await page.goto('/inbox')
- await page.getByText('Maya Chen').last().click()
- await expect(page).toHaveURL(/\/chat\/maya$/)
- const frame = await page.locator('.app-frame').boundingBox()
- 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.down()
- await page.mouse.move(frame.x + frame.width * 0.55, 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("holds an edge-back preview without committing the URL", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.getByText("Maya Chen").last().click();
+ await expect(page).toHaveURL(/\/chat\/maya$/);
+ const frame = await page.locator(".app-frame").boundingBox();
+ 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.down();
+ await page.mouse.move(
+ frame.x + frame.width * 0.55,
+ 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 }) => {
- await page.goto('/inbox')
- await page.getByText('Maya Chen').last().click()
- await page.getByRole('button', { name: 'Back' }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
- await page.getByText('Noah Williams').last().click()
- await expect(page).toHaveURL(/\/chat\/noah$/)
- await waitForTransition(page)
+test("never previews a stale conversation after a cancelled back gesture", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.getByText("Maya Chen").last().click();
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(page).toHaveURL(/\/inbox$/);
+ 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()
- 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.down()
- await page.mouse.move(frame.x + frame.width * 0.055, frame.y + frame.height * 0.5, { steps: 16 })
- await page.mouse.up()
- await waitForTransition(page)
- await expect(page).toHaveURL(/\/chat\/noah$/)
+ const frame = await page.locator(".app-frame").boundingBox();
+ 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.down();
+ await page.mouse.move(
+ frame.x + frame.width * 0.055,
+ frame.y + frame.height * 0.5,
+ { steps: 16 },
+ );
+ await page.mouse.up();
+ await waitForTransition(page);
+ await expect(page).toHaveURL(/\/chat\/noah$/);
- await page.getByRole('button', { name: 'Back' }).click()
- const backTarget = page.locator('[data-native-role="to"]')
- await expect(backTarget.getByRole('heading', { name: 'Messages' })).toBeVisible()
- await expect(backTarget.getByRole('heading', { name: 'Maya Chen' })).toHaveCount(0)
- await waitForTransition(page)
- await expect(page).toHaveURL(/\/inbox$/)
-})
+ await page.getByRole("button", { name: "Back" }).click();
+ const backTarget = page.locator('[data-native-role="to"]');
+ await expect(
+ backTarget.getByRole("heading", { name: "Messages" }),
+ ).toBeVisible();
+ 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 }) => {
- await page.goto('/inbox')
- await page.getByText('Maya Chen').last().click()
- await page.getByRole('button', { name: 'Back' }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
- await page.getByRole('link', { name: /Stories/ }).click()
- await expect(page).toHaveURL(/\/stories$/)
- await waitForTransition(page)
- await page.getByRole('link', { name: /Inbox/ }).click()
- await expect(page).toHaveURL(/\/inbox$/)
- await waitForTransition(page)
+test("always opens compose as a vertical sheet after prior navigation", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.getByText("Maya Chen").last().click();
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(page).toHaveURL(/\/inbox$/);
+ await waitForTransition(page);
+ await page.getByRole("link", { name: /Stories/ }).click();
+ await expect(page).toHaveURL(/\/stories$/);
+ 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()
- const routerView = page.locator('.nvr-router-view')
- await expect(routerView).toHaveAttribute('data-native-presentation', 'sheet')
- await expect(routerView).toHaveAttribute('data-native-direction', 'up')
- const frame = await routerView.boundingBox()
- const sheet = await page.locator('[data-native-role="to"]').boundingBox()
- if (!frame || !sheet) throw new Error('Sheet transition did not render')
- expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3)
- await waitForTransition(page)
- await page.getByRole('button', { name: 'Cancel' }).click()
-})
+ await page.getByRole("button", { name: "Compose" }).click();
+ const routerView = page.locator(".nvr-router-view");
+ await expect(routerView).toHaveAttribute("data-native-presentation", "sheet");
+ await expect(routerView).toHaveAttribute("data-native-direction", "up");
+ const frame = await routerView.boundingBox();
+ const sheet = await page.locator('[data-native-role="to"]').boundingBox();
+ if (!frame || !sheet) throw new Error("Sheet transition did not render");
+ expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3);
+ await waitForTransition(page);
+ await page.getByRole("button", { name: "Cancel" }).click();
+});
-test('drags a sheet down to dismiss it', async ({ page }) => {
- await page.goto('/inbox')
- await page.getByRole('button', { name: 'Compose' }).click()
- const sheet = await page.locator('.sheet-screen').boundingBox()
- if (!sheet) throw new Error('Sheet did not render')
- await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12)
- await page.mouse.down()
- await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + sheet.height * 0.55, { steps: 14 })
- await page.mouse.up()
- await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
-})
+test("drags a sheet down to dismiss it", async ({ page }) => {
+ await page.goto("/inbox");
+ await page.getByRole("button", { name: "Compose" }).click();
+ const sheet = await page.locator(".sheet-screen").boundingBox();
+ if (!sheet) throw new Error("Sheet did not render");
+ await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
+ await page.mouse.down();
+ await page.mouse.move(
+ sheet.x + sheet.width / 2,
+ sheet.y + sheet.height * 0.55,
+ { steps: 14 },
+ );
+ await page.mouse.up();
+ await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
+});
diff --git a/apps/demo/e2e/pwa.spec.ts b/apps/demo/e2e/pwa.spec.ts
index 8eed930..ee7556d 100644
--- a/apps/demo/e2e/pwa.spec.ts
+++ b/apps/demo/e2e/pwa.spec.ts
@@ -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) {
- return await page.locator('.nvr-navigator').evaluate((element, x) => {
- const event = new Event('touchstart', { bubbles: true, cancelable: true })
- Object.defineProperty(event, 'touches', {
+async function dispatchTouchStart(
+ page: import("@playwright/test").Page,
+ clientX: number,
+) {
+ 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 }],
- })
- return { dispatched: element.dispatchEvent(event), prevented: event.defaultPrevented }
- }, clientX)
+ });
+ return {
+ dispatched: element.dispatchEvent(event),
+ prevented: event.defaultPrevented,
+ };
+ }, clientX);
}
-test('ships an installable standalone manifest and iOS metadata', async ({ page, request }) => {
- await page.goto('/inbox')
- await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).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')
+test("ships an installable standalone manifest and iOS metadata", async ({
+ page,
+ request,
+}) => {
+ await page.goto("/inbox");
+ await expect(
+ page.locator('meta[name="apple-mobile-web-app-capable"]'),
+ ).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')
- expect(manifestResponse.ok()).toBe(true)
- const manifest = await manifestResponse.json()
- expect(manifest).toMatchObject({ id: '/', scope: '/', start_url: '/', display: 'standalone' })
- expect(manifest.icons).toEqual(expect.arrayContaining([
- expect.objectContaining({ src: '/pwa-192.png', sizes: '192x192', 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()')
-})
+ const manifestResponse = await request.get("/manifest.webmanifest");
+ expect(manifestResponse.ok()).toBe(true);
+ const manifest = await manifestResponse.json();
+ expect(manifest).toMatchObject({
+ id: "/",
+ scope: "/",
+ start_url: "/",
+ display: "standalone",
+ });
+ expect(manifest.icons).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ src: "/pwa-192.png",
+ sizes: "192x192",
+ 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) => {
- Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
- Object.defineProperty(navigator, 'standalone', { configurable: true, 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)
-})
+ Object.defineProperty(navigator, "userAgent", {
+ configurable: true,
+ value: userAgent,
+ });
+ Object.defineProperty(navigator, "standalone", {
+ configurable: true,
+ 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) => {
- Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
- Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
- }, 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')
+ Object.defineProperty(navigator, "userAgent", {
+ configurable: true,
+ value: userAgent,
+ });
+ Object.defineProperty(navigator, "standalone", {
+ configurable: true,
+ value: true,
+ });
+ }, 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, 1)).prevented).toBe(true)
- await expect(root).toHaveAttribute('data-pwa-edge-claims', '1')
-})
+ expect((await dispatchTouchStart(page, 80)).prevented).toBe(false);
+ expect((await dispatchTouchStart(page, 1)).prevented).toBe(true);
+ await expect(root).toHaveAttribute("data-pwa-edge-claims", "1");
+});
-test('registers and activates the offline service worker', async ({ page }) => {
- await page.goto('/inbox')
+test("registers and activates the offline service worker", async ({ page }) => {
+ await page.goto("/inbox");
const workerUrl = await page.evaluate(async () => {
- const registration = await navigator.serviceWorker.ready
- return registration.active?.scriptURL ?? ''
- })
- expect(workerUrl).toMatch(/\/sw\.js$/)
- await expect(page.locator('html')).not.toHaveAttribute('data-pwa-update-checks', '0')
-})
+ const registration = await navigator.serviceWorker.ready;
+ return registration.active?.scriptURL ?? "";
+ });
+ expect(workerUrl).toMatch(/\/sw\.js$/);
+ await expect(page.locator("html")).not.toHaveAttribute(
+ "data-pwa-update-checks",
+ "0",
+ );
+});
-test('precaches lazily split routes for offline navigation', async ({ page }) => {
- await page.goto('/inbox')
- await page.evaluate(async () => { await navigator.serviceWorker.ready })
+test("precaches lazily split routes for offline navigation", async ({
+ page,
+}) => {
+ await page.goto("/inbox");
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.ready;
+ });
const cachedUrls = await page.evaluate(async () => {
- const urls: string[] = []
+ const urls: string[] = [];
for (const name of await caches.keys()) {
- const cache = await caches.open(name)
- urls.push(...(await cache.keys()).map((request) => request.url))
+ const cache = await caches.open(name);
+ urls.push(...(await cache.keys()).map((request) => request.url));
}
- return urls
- })
- expect(cachedUrls.some((url) => /StoriesView-.*\.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)
-})
+ return urls;
+ });
+ expect(cachedUrls.some((url) => /StoriesView-.*\.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,
+ );
+});
diff --git a/apps/demo/src/components/AsyncLabData.vue b/apps/demo/src/components/AsyncLabData.vue
index 21ef469..41aef2f 100644
--- a/apps/demo/src/components/AsyncLabData.vue
+++ b/apps/demo/src/components/AsyncLabData.vue
@@ -1,6 +1,6 @@
diff --git a/apps/demo/src/data.ts b/apps/demo/src/data.ts
index ba992e4..2ea7360 100644
--- a/apps/demo/src/data.ts
+++ b/apps/demo/src/data.ts
@@ -1,103 +1,207 @@
-import { computed, reactive, readonly, ref } from 'vue'
+import { computed, reactive, readonly, ref } from "vue";
export interface Person {
- id: string
- name: string
- handle: string
- color: string
- online: boolean
- bio: string
+ id: string;
+ name: string;
+ handle: string;
+ color: string;
+ online: boolean;
+ bio: string;
}
export interface Message {
- id: string
- personId: string
- body: string
- sentAt: number
- mine: boolean
- status: 'sent' | 'delivered' | 'failed'
+ id: string;
+ personId: string;
+ body: string;
+ sentAt: number;
+ mine: boolean;
+ status: "sent" | "delivered" | "failed";
}
export interface Conversation {
- id: string
- personId: string
- unread: number
- pinned?: boolean
- messages: Message[]
+ id: string;
+ personId: string;
+ unread: number;
+ pinned?: boolean;
+ messages: Message[];
}
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: '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.' },
-]
+ {
+ 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: "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[] = [
{
- id: 'maya', personId: 'maya', unread: 2, pinned: true,
+ id: "maya",
+ personId: "maya",
+ unread: 2,
+ pinned: true,
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: [
- { 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,
- messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }],
+ id: "sofia",
+ 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,
- messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }],
+ id: "liam",
+ 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,
- messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }],
+ id: "amara",
+ 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(structuredClone(seedConversations))
-const ready = ref(false)
-const offline = ref(!navigator.onLine)
-const settings = reactive({ simulatedLatency: 180, simulateFailures: false })
+const conversations = ref(structuredClone(seedConversations));
+const ready = ref(false);
+const offline = ref(!navigator.onLine);
+const settings = reactive({ simulatedLatency: 180, simulateFailures: false });
function openDatabase() {
return new Promise((resolve, reject) => {
- const request = indexedDB.open('native-vue-messenger', 1)
- request.onupgradeneeded = () => request.result.createObjectStore('state')
- request.onsuccess = () => resolve(request.result)
- request.onerror = () => reject(request.error)
- })
+ const request = indexedDB.open("native-vue-messenger", 1);
+ request.onupgradeneeded = () => request.result.createObjectStore("state");
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
}
async function readStored() {
- const database = await openDatabase()
+ const database = await openDatabase();
return await new Promise((resolve, reject) => {
- const transaction = database.transaction('state', 'readonly')
- const request = transaction.objectStore('state').get('conversations')
- request.onsuccess = () => resolve(request.result as Conversation[] | undefined)
- request.onerror = () => reject(request.error)
- }).finally(() => database.close())
+ const transaction = database.transaction("state", "readonly");
+ const request = transaction.objectStore("state").get("conversations");
+ request.onsuccess = () =>
+ resolve(request.result as Conversation[] | undefined);
+ request.onerror = () => reject(request.error);
+ }).finally(() => database.close());
}
async function persist() {
try {
- const database = await openDatabase()
+ const database = await openDatabase();
await new Promise((resolve, reject) => {
- const transaction = database.transaction('state', 'readwrite')
- transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations')
- transaction.oncomplete = () => resolve()
- transaction.onerror = () => reject(transaction.error)
- })
- database.close()
+ const transaction = database.transaction("state", "readwrite");
+ transaction
+ .objectStore("state")
+ .put(structuredClone(conversations.value), "conversations");
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error);
+ });
+ database.close();
} catch {
// Private browsing and locked-down webviews can reject IndexedDB.
}
@@ -105,48 +209,58 @@ async function persist() {
async function initialize() {
try {
- const stored = await readStored()
- if (stored?.length) conversations.value = stored
- else await persist()
+ const stored = await readStored();
+ if (stored?.length) conversations.value = stored;
+ else await persist();
} finally {
- ready.value = true
+ ready.value = true;
}
}
-window.addEventListener('online', () => { offline.value = false })
-window.addEventListener('offline', () => { offline.value = true })
-void initialize()
+window.addEventListener("online", () => {
+ offline.value = false;
+});
+window.addEventListener("offline", () => {
+ offline.value = true;
+});
+void initialize();
export function useDemoStore() {
- const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id))
- const personFor = (id: string) => people.find((person) => person.id === id)
+ const conversationFor = (id: string) =>
+ computed(() =>
+ conversations.value.find((conversation) => conversation.id === id),
+ );
+ const personFor = (id: string) => people.find((person) => person.id === id);
const markRead = (id: string) => {
- const conversation = conversations.value.find((item) => item.id === id)
- if (conversation) conversation.unread = 0
- void persist()
- }
+ const conversation = conversations.value.find((item) => item.id === id);
+ if (conversation) conversation.unread = 0;
+ void persist();
+ };
const sendMessage = async (id: string, body: string) => {
- const conversation = conversations.value.find((item) => item.id === id)
- if (!conversation || !body.trim()) return false
+ const conversation = conversations.value.find((item) => item.id === id);
+ if (!conversation || !body.trim()) return false;
const message: Message = {
id: crypto.randomUUID(),
personId: id,
body: body.trim(),
sentAt: Date.now(),
mine: true,
- status: 'sent',
- }
- conversation.messages.push(message)
- await persist()
- await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency))
- message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered'
- await persist()
- return message.status === 'delivered'
- }
+ status: "sent",
+ };
+ conversation.messages.push(message);
+ await persist();
+ await new Promise((resolve) =>
+ window.setTimeout(resolve, settings.simulatedLatency),
+ );
+ message.status =
+ settings.simulateFailures || offline.value ? "failed" : "delivered";
+ await persist();
+ return message.status === "delivered";
+ };
const reset = async () => {
- conversations.value = structuredClone(seedConversations)
- await persist()
- }
+ conversations.value = structuredClone(seedConversations);
+ await persist();
+ };
return {
people: readonly(ref(people)),
conversations: readonly(conversations),
@@ -158,14 +272,14 @@ export function useDemoStore() {
markRead,
sendMessage,
reset,
- }
+ };
}
export function relativeTime(value: number) {
- const minutes = Math.floor((Date.now() - value) / 60_000)
- if (minutes < 1) return 'now'
- if (minutes < 60) return `${minutes}m`
- const hours = Math.floor(minutes / 60)
- if (hours < 24) return `${hours}h`
- return `${Math.floor(hours / 24)}d`
+ const minutes = Math.floor((Date.now() - value) / 60_000);
+ if (minutes < 1) return "now";
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ return `${Math.floor(hours / 24)}d`;
}
diff --git a/apps/demo/src/guard-state.ts b/apps/demo/src/guard-state.ts
index 34abd68..089ab56 100644
--- a/apps/demo/src/guard-state.ts
+++ b/apps/demo/src/guard-state.ts
@@ -1,50 +1,50 @@
-import { readonly, reactive } from 'vue'
+import { readonly, reactive } from "vue";
function createGuardState() {
return reactive({
blockEntry: false,
checks: 0,
- status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked',
- })
+ status: "idle" as "idle" | "checking" | "allowed" | "blocked",
+ });
}
-const storyState = createGuardState()
-const labState = createGuardState()
+const storyState = createGuardState();
+const labState = createGuardState();
-export const storyEntryGuard = readonly(storyState)
-export const runtimeLabGuard = readonly(labState)
+export const storyEntryGuard = readonly(storyState);
+export const runtimeLabGuard = readonly(labState);
export function setStoryEntryBlocked(blocked: boolean) {
- storyState.blockEntry = blocked
- if (storyState.status !== 'checking') storyState.status = 'idle'
+ storyState.blockEntry = blocked;
+ if (storyState.status !== "checking") storyState.status = "idle";
}
async function evaluate(state: ReturnType) {
- state.checks += 1
- state.status = 'checking'
- await new Promise((resolve) => window.setTimeout(resolve, 320))
- const allowed = !state.blockEntry
- state.status = allowed ? 'allowed' : 'blocked'
- return allowed
+ state.checks += 1;
+ state.status = "checking";
+ await new Promise((resolve) => window.setTimeout(resolve, 320));
+ const allowed = !state.blockEntry;
+ state.status = allowed ? "allowed" : "blocked";
+ return allowed;
}
/** Dynamic guard used to reject a sibling that may already be cached. */
export function evaluateStoryEntry() {
- storyState.checks += 1
+ storyState.checks += 1;
if (!storyState.blockEntry) {
- storyState.status = 'allowed'
- return true
+ storyState.status = "allowed";
+ return true;
}
- storyState.status = 'checking'
+ storyState.status = "checking";
return new Promise((resolve) => {
window.setTimeout(() => {
- storyState.status = 'blocked'
- resolve(false)
- }, 320)
- })
+ storyState.status = "blocked";
+ resolve(false);
+ }, 320);
+ });
}
/** Always-allowing asynchronous guard for the deeper stress-lab route. */
export function evaluateRuntimeLabEntry() {
- return evaluate(labState)
+ return evaluate(labState);
}
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 36ad01d..377226b 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -1,29 +1,32 @@
-import { createApp } from 'vue'
-import { createNativeRouter } from '@native-vue-router/core'
-import { createCapacitorAdapter } from '@native-vue-router/capacitor'
-import { createElectronRendererAdapter } from '@native-vue-router/electron'
-import App from './App.vue'
-import { createPwaAdapter } from './pwa'
-import { router } from './router'
-import './style.css'
+import { createApp } from "vue";
+import { createNativeRouter } from "@native-vue-router/core";
+import { createCapacitorAdapter } from "@native-vue-router/capacitor";
+import { createElectronRendererAdapter } from "@native-vue-router/electron";
+import App from "./App.vue";
+import { createPwaAdapter } from "./pwa";
+import { router } from "./router";
+import "./style.css";
-const isElectron = navigator.userAgent.toLowerCase().includes('electron')
-const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true })
+const isElectron = navigator.userAgent.toLowerCase().includes("electron");
+const capacitorPlatform = createCapacitorAdapter({
+ haptics: true,
+ exitAtRoot: true,
+});
const platform = isElectron
? createElectronRendererAdapter()
- : capacitorPlatform.name !== 'capacitor-web'
+ : capacitorPlatform.name !== "capacitor-web"
? capacitorPlatform
- : createPwaAdapter()
+ : createPwaAdapter();
const nativeRouter = createNativeRouter({
router,
cache: { maxInactive: 4 },
platform,
-})
+});
-const app = createApp(App)
-app.use(router)
-app.use(nativeRouter)
+const app = createApp(App);
+app.use(router);
+app.use(nativeRouter);
-await router.isReady()
-app.mount('#app')
+await router.isReady();
+app.mount("#app");
diff --git a/apps/demo/src/navigation-profiler.ts b/apps/demo/src/navigation-profiler.ts
index c2e958b..0530420 100644
--- a/apps/demo/src/navigation-profiler.ts
+++ b/apps/demo/src/navigation-profiler.ts
@@ -1,66 +1,74 @@
-import { ref } from 'vue'
+import { ref } from "vue";
import {
createNativeNavigationProfiler,
type NativeNavigationProfiler,
type NativeProfilerReport,
type NativeRouterRuntime,
-} from '@native-vue-router/core'
-import { pwaBuildId } from './pwa'
+} from "@native-vue-router/core";
+import { pwaBuildId } from "./pwa";
-let profiler: NativeNavigationProfiler | undefined
-export const profilerRecording = ref(false)
-export const profilerHasCapture = ref(false)
+let profiler: NativeNavigationProfiler | undefined;
+export const profilerRecording = ref(false);
+export const profilerHasCapture = ref(false);
function instance(runtime: NativeRouterRuntime) {
profiler ??= createNativeNavigationProfiler(runtime, {
- metadata: { app: 'nvr-messenger-demo', build: pwaBuildId },
- })
- return profiler
+ metadata: { app: "nvr-messenger-demo", build: pwaBuildId },
+ });
+ return profiler;
}
export function startDemoProfile(runtime: NativeRouterRuntime) {
- const active = instance(runtime)
- active.start()
- profilerRecording.value = true
- profilerHasCapture.value = true
+ const active = instance(runtime);
+ active.start();
+ profilerRecording.value = true;
+ profilerHasCapture.value = true;
}
export function stopDemoProfile(runtime: NativeRouterRuntime) {
- const report = instance(runtime).stop()
- profilerRecording.value = false
- return report
+ const report = instance(runtime).stop();
+ profilerRecording.value = false;
+ return report;
}
export function snapshotDemoProfile(runtime: NativeRouterRuntime) {
- return instance(runtime).snapshot()
+ return instance(runtime).snapshot();
}
-export async function shareDemoProfile(runtime: NativeRouterRuntime, report?: NativeProfilerReport) {
- const active = instance(runtime)
- const current = 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' })
+export async function shareDemoProfile(
+ runtime: NativeRouterRuntime,
+ report?: NativeProfilerReport,
+) {
+ const active = instance(runtime);
+ const current =
+ 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 & {
- canShare?: (data: ShareData) => boolean
- share?: (data: ShareData) => Promise
- }
+ canShare?: (data: ShareData) => boolean;
+ share?: (data: ShareData) => Promise;
+ };
if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) {
await shareNavigator.share({
- title: 'Native Vue Router performance profile',
- text: 'Frame pacing and navigation diagnostics. Route params and query values are omitted.',
+ title: "Native Vue Router performance profile",
+ text: "Frame pacing and navigation diagnostics. Route params and query values are omitted.",
files: [file],
- })
- return { report: current, method: 'shared' as const }
+ });
+ return { report: current, method: "shared" as const };
}
- const url = URL.createObjectURL(file)
- const link = document.createElement('a')
- link.href = url
- link.download = filename
- link.click()
- window.setTimeout(() => URL.revokeObjectURL(url), 1_000)
- return { report: current, method: 'downloaded' as const }
+ const url = URL.createObjectURL(file);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ link.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+ return { report: current, method: "downloaded" as const };
}
diff --git a/apps/demo/src/pwa.ts b/apps/demo/src/pwa.ts
index ccbe60c..44854a9 100644
--- a/apps/demo/src/pwa.ts
+++ b/apps/demo/src/pwa.ts
@@ -1,62 +1,77 @@
-import { reactive, readonly } from 'vue'
-import type { NativePlatformAdapter } from '@native-vue-router/core'
+import { reactive, readonly } from "vue";
+import type { NativePlatformAdapter } from "@native-vue-router/core";
interface StandaloneNavigator extends Navigator {
- standalone?: boolean
+ standalone?: boolean;
}
-type ServiceWorkerState = 'unsupported' | 'installing' | 'ready'
-export type PwaUpdateState = 'idle' | 'checking' | 'current' | 'ready' | 'error'
+type ServiceWorkerState = "unsupported" | "installing" | "ready";
+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({
ios: false,
standalone: false,
edgeGuard: false,
edgeClaims: 0,
- serviceWorker: 'installing' as ServiceWorkerState,
- updateState: 'idle' as PwaUpdateState,
+ serviceWorker: "installing" as ServiceWorkerState,
+ updateState: "idle" as PwaUpdateState,
updateChecks: 0,
-})
+});
-export const pwaEnvironment = readonly(state)
+export const pwaEnvironment = readonly(state);
-export function recordPwaUpdateState(updateState: PwaUpdateState, checked = false) {
- state.updateState = updateState
- if (checked) state.updateChecks += 1
- document.documentElement.dataset.pwaUpdateState = state.updateState
- document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
+export function recordPwaUpdateState(
+ updateState: PwaUpdateState,
+ checked = false,
+) {
+ state.updateState = updateState;
+ if (checked) state.updateChecks += 1;
+ document.documentElement.dataset.pwaUpdateState = state.updateState;
+ document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
}
export function isIOSWebKit() {
- const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number }
- return /iPad|iPhone|iPod/.test(navigator.userAgent)
- || (/Macintosh/.test(navigator.userAgent) && (navigatorWithTouch.maxTouchPoints ?? 0) > 1)
+ const navigatorWithTouch = navigator as Navigator & {
+ maxTouchPoints?: number;
+ };
+ return (
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (/Macintosh/.test(navigator.userAgent) &&
+ (navigatorWithTouch.maxTouchPoints ?? 0) > 1)
+ );
}
export function isStandalonePwa() {
- return Boolean((navigator as StandaloneNavigator).standalone)
- || window.matchMedia('(display-mode: standalone)').matches
- || window.matchMedia('(display-mode: fullscreen)').matches
+ return (
+ Boolean((navigator as StandaloneNavigator).standalone) ||
+ window.matchMedia("(display-mode: standalone)").matches ||
+ window.matchMedia("(display-mode: fullscreen)").matches
+ );
}
function updateEnvironment() {
- state.ios = isIOSWebKit()
- state.standalone = isStandalonePwa()
- state.edgeGuard = state.ios && state.standalone
- document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
- document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
- document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
- document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
- document.documentElement.dataset.pwaUpdateState = state.updateState
- document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
+ state.ios = isIOSWebKit();
+ state.standalone = isStandalonePwa();
+ state.edgeGuard = state.ios && state.standalone;
+ document.documentElement.dataset.pwaPlatform = state.ios ? "ios" : "other";
+ document.documentElement.dataset.pwaDisplayMode = state.standalone
+ ? "standalone"
+ : "browser";
+ document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard
+ ? "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 {
- edgeWidth?: number
+ edgeWidth?: number;
}
/**
@@ -66,65 +81,104 @@ export interface PwaAdapterOptions {
* Safari does not expose WKWebView's native gesture switch to web content,
* so this is deliberately scoped to standalone mode and reinforced by CSS.
*/
-export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatformAdapter {
- const edgeWidth = options.edgeWidth ?? 32
+export function createPwaAdapter(
+ options: PwaAdapterOptions = {},
+): NativePlatformAdapter {
+ const edgeWidth = options.edgeWidth ?? 32;
return {
- name: 'pwa',
+ name: "pwa",
install() {
- updateEnvironment()
- const displayMode = window.matchMedia('(display-mode: standalone)')
- const update = () => updateEnvironment()
- displayMode.addEventListener('change', update)
- document.addEventListener('visibilitychange', update)
+ updateEnvironment();
+ const displayMode = window.matchMedia("(display-mode: standalone)");
+ const update = () => updateEnvironment();
+ displayMode.addEventListener("change", update);
+ document.addEventListener("visibilitychange", update);
- if (!('serviceWorker' in navigator)) state.serviceWorker = 'unsupported'
+ if (!("serviceWorker" in navigator)) state.serviceWorker = "unsupported";
else {
- state.serviceWorker = navigator.serviceWorker.controller ? 'ready' : 'installing'
- void navigator.serviceWorker.ready.then(() => { state.serviceWorker = 'ready' })
- navigator.serviceWorker.addEventListener('controllerchange', updateServiceWorkerState)
+ state.serviceWorker = navigator.serviceWorker.controller
+ ? "ready"
+ : "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) => {
- if (!state.edgeGuard || event.touches.length !== 1) return undefined
- if (!(event.target instanceof Element) || !event.target.closest('.nvr-navigator')) 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
- }
+ if (!state.edgeGuard || event.touches.length !== 1) return undefined;
+ if (
+ !(event.target instanceof Element) ||
+ !event.target.closest(".nvr-navigator")
+ )
+ 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 touch = touchAtLeadingEdge(event)
- if (!touch) return
- reservedTouch = touch.identifier
- state.edgeClaims += 1
- document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
- event.preventDefault()
- }
+ const touch = touchAtLeadingEdge(event);
+ if (!touch) return;
+ reservedTouch = touch.identifier;
+ state.edgeClaims += 1;
+ document.documentElement.dataset.pwaEdgeClaims = String(
+ state.edgeClaims,
+ );
+ event.preventDefault();
+ };
const holdEdge = (event: TouchEvent) => {
- if (reservedTouch === null) return
- if ([...event.touches].some((touch) => touch.identifier === reservedTouch)) event.preventDefault()
- }
- const releaseEdge = () => { reservedTouch = null }
+ if (reservedTouch === null) return;
+ if (
+ [...event.touches].some((touch) => touch.identifier === reservedTouch)
+ )
+ event.preventDefault();
+ };
+ const releaseEdge = () => {
+ reservedTouch = null;
+ };
- document.addEventListener('touchstart', reserveEdge, { capture: true, passive: false })
- document.addEventListener('touchmove', holdEdge, { capture: true, passive: false })
- document.addEventListener('touchend', releaseEdge, { capture: true, passive: true })
- document.addEventListener('touchcancel', releaseEdge, { capture: true, passive: true })
+ document.addEventListener("touchstart", reserveEdge, {
+ capture: true,
+ passive: false,
+ });
+ 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 () => {
- displayMode.removeEventListener('change', update)
- document.removeEventListener('visibilitychange', update)
- if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('controllerchange', updateServiceWorkerState)
- document.removeEventListener('touchstart', reserveEdge, true)
- document.removeEventListener('touchmove', holdEdge, true)
- document.removeEventListener('touchend', releaseEdge, true)
- document.removeEventListener('touchcancel', releaseEdge, true)
- }
+ displayMode.removeEventListener("change", update);
+ document.removeEventListener("visibilitychange", update);
+ if ("serviceWorker" in navigator)
+ navigator.serviceWorker.removeEventListener(
+ "controllerchange",
+ updateServiceWorkerState,
+ );
+ document.removeEventListener("touchstart", reserveEdge, true);
+ document.removeEventListener("touchmove", holdEdge, true);
+ document.removeEventListener("touchend", releaseEdge, true);
+ document.removeEventListener("touchcancel", releaseEdge, true);
+ };
},
- }
+ };
}
function updateServiceWorkerState() {
- state.serviceWorker = 'ready'
+ state.serviceWorker = "ready";
}
diff --git a/apps/demo/src/router.ts b/apps/demo/src/router.ts
index 5d41113..f05c062 100644
--- a/apps/demo/src/router.ts
+++ b/apps/demo/src/router.ts
@@ -1,46 +1,116 @@
-import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
-import { evaluateRuntimeLabEntry, evaluateStoryEntry } from './guard-state'
+import {
+ createRouter,
+ createWebHashHistory,
+ createWebHistory,
+ type RouteRecordRaw,
+} from "vue-router";
+import { evaluateRuntimeLabEntry, evaluateStoryEntry } from "./guard-state";
const routes: RouteRecordRaw[] = [
- { path: '/', redirect: '/inbox' },
+ { path: "/", redirect: "/inbox" },
{
- path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'),
- meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } },
+ path: "/inbox",
+ 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,
- 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'),
- meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
+ path: "/profile",
+ 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,
- 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'),
- meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
+ path: "/chat/:id",
+ name: "chat",
+ component: () => import("./views/ChatView.vue"),
+ meta: {
+ native: { presentation: "push", parent: "/inbox", gesture: "edge" },
+ },
},
{
- path: '/chat/:id/details', name: 'chat-details', component: () => import('./views/ContactView.vue'),
- meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } },
+ path: "/chat/:id/details",
+ 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'),
- meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } },
+ path: "/compose",
+ name: "compose",
+ component: () => import("./views/ComposeView.vue"),
+ meta: {
+ native: { presentation: "sheet", parent: "/inbox", gesture: "full" },
+ },
},
{
- path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'),
- meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } },
+ path: "/settings",
+ name: "settings",
+ component: () => import("./views/SettingsView.vue"),
+ meta: {
+ native: { presentation: "push", parent: "/profile", gesture: "edge" },
+ },
},
-]
+];
export const router = createRouter({
- history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(),
+ history:
+ window.location.protocol === "file:"
+ ? createWebHashHistory()
+ : createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 }),
-})
+});
diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css
index fffb597..c5aacb7 100644
--- a/apps/demo/src/style.css
+++ b/apps/demo/src/style.css
@@ -2,28 +2,57 @@
@import "@native-vue-router/preset-native/style.css";
:root {
- font-family: Inter, ui-rounded, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-family:
+ Inter,
+ ui-rounded,
+ "SF Pro Display",
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
color: #f5f6fa;
background: #050608;
font-synthesis: none;
text-rendering: optimizeLegibility;
--nvr-view-background: #0b0d12;
--nvr-accent: #8b73ff;
- --line: rgba(255, 255, 255, .08);
+ --line: rgba(255, 255, 255, 0.08);
--muted: #898e9c;
--surface: #13161d;
}
-* { box-sizing: border-box; }
-html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; }
-button, input { font: inherit; }
-button, a { -webkit-tap-highlight-color: transparent; }
-button { color: inherit; }
+* {
+ box-sizing: border-box;
+}
+html,
+body,
+#app {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ overscroll-behavior: none;
+}
+button,
+input {
+ font: inherit;
+}
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+button {
+ color: inherit;
+}
body {
min-width: 320px;
background:
- radial-gradient(circle at 50% -15%, rgba(124, 92, 255, .18), transparent 36%),
+ radial-gradient(
+ circle at 50% -15%,
+ rgba(124, 92, 255, 0.18),
+ transparent 36%
+ ),
#050608;
}
@@ -41,7 +70,7 @@ html[data-pwa-edge-guard="active"] body {
margin: 0 auto;
overflow: hidden;
background: #0b0d12;
- box-shadow: 0 0 80px rgba(0, 0, 0, .5);
+ box-shadow: 0 0 80px rgba(0, 0, 0, 0.5);
}
.app-tabs {
@@ -55,12 +84,13 @@ html[data-pwa-edge-guard="active"] body {
/* Blurring content that is itself moving forces WebKit to repeatedly
rasterize the tab/header chrome. Use the same dark material without the
live blur for the short duration of a route transition. */
-.app-frame:has(.nvr-router-view--interactive) :is(.app-tabs, .app-header, .composer) {
+.app-frame:has(.nvr-router-view--interactive)
+ :is(.app-tabs, .app-header, .composer) {
backdrop-filter: none;
}
.app-frame:has(.nvr-router-view--interactive) .app-tabs {
- background: rgba(11, 13, 18, .98);
+ background: rgba(11, 13, 18, 0.98);
}
.screen {
@@ -69,12 +99,20 @@ html[data-pwa-edge-guard="active"] body {
overflow: auto;
overscroll-behavior: contain;
background:
- radial-gradient(circle at 88% 4%, rgba(124, 92, 255, .09), transparent 24%),
+ radial-gradient(
+ circle at 88% 4%,
+ rgba(124, 92, 255, 0.09),
+ transparent 24%
+ ),
#0b0d12;
scrollbar-width: none;
}
-.screen::-webkit-scrollbar { display: none; }
-.screen--tabs { padding-bottom: calc(76px + env(safe-area-inset-bottom)); }
+.screen::-webkit-scrollbar {
+ display: none;
+}
+.screen--tabs {
+ padding-bottom: calc(76px + env(safe-area-inset-bottom));
+}
.app-header {
position: sticky;
@@ -86,7 +124,7 @@ html[data-pwa-edge-guard="active"] body {
min-height: calc(58px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 14px 0;
border-bottom: 1px solid var(--line);
- background: rgba(11, 13, 18, .82);
+ background: rgba(11, 13, 18, 0.82);
backdrop-filter: blur(24px) saturate(1.45);
}
@@ -100,11 +138,33 @@ html[data-pwa-edge-guard="active"] body {
backdrop-filter: none;
}
-.app-header__title { min-width: 0; }
-.app-header__title p { margin: 0 0 3px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; }
-.app-header__title h1 { overflow: hidden; margin: 0; text-overflow: ellipsis; font-size: 17px; line-height: 1.2; white-space: nowrap; }
-.app-header--large .app-header__title h1 { font-size: clamp(32px, 8vw, 42px); letter-spacing: -.045em; }
-.app-header__actions { display: flex; align-items: center; justify-content: flex-end; }
+.app-header__title {
+ min-width: 0;
+}
+.app-header__title p {
+ margin: 0 0 3px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+}
+.app-header__title h1 {
+ overflow: hidden;
+ margin: 0;
+ text-overflow: ellipsis;
+ font-size: 17px;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+.app-header--large .app-header__title h1 {
+ font-size: clamp(32px, 8vw, 42px);
+ letter-spacing: -0.045em;
+}
+.app-header__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
.round-button,
.avatar-button {
@@ -112,34 +172,67 @@ html[data-pwa-edge-guard="active"] body {
width: 44px;
height: 44px;
place-items: center;
- border: 1px solid rgba(255,255,255,.1);
+ border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
color: #fff;
- background: rgba(255,255,255,.07);
+ background: rgba(255, 255, 255, 0.07);
text-decoration: none;
cursor: pointer;
}
-.round-button { font-size: 25px; font-weight: 300; }
-.avatar-button { border: 0; background: transparent; }
+.round-button {
+ font-size: 25px;
+ font-weight: 300;
+}
+.avatar-button {
+ border: 0;
+ background: transparent;
+}
-.content-stack { padding: 0 16px 24px; }
+.content-stack {
+ padding: 0 16px 24px;
+}
.search-field {
display: flex;
align-items: center;
gap: 10px;
height: 46px;
padding: 0 14px;
- border: 1px solid rgba(255,255,255,.06);
+ border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 15px;
color: var(--muted);
- background: rgba(255,255,255,.055);
+ background: rgba(255, 255, 255, 0.055);
+}
+.search-field input {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ color: #fff;
+ background: transparent;
+}
+.search-field input::placeholder {
+ color: #707582;
}
-.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: #fff; background: transparent; }
-.search-field input::placeholder { color: #707582; }
-.story-strip { display: flex; gap: 18px; padding: 22px 3px 20px; overflow-x: auto; scrollbar-width: none; }
-.story-strip::-webkit-scrollbar { display: none; }
-.story-person { display: flex; flex: 0 0 auto; flex-direction: column; align-items: center; gap: 7px; color: #c9ccd5; font-size: 11px; }
+.story-strip {
+ display: flex;
+ gap: 18px;
+ padding: 22px 3px 20px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.story-strip::-webkit-scrollbar {
+ display: none;
+}
+.story-person {
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: column;
+ align-items: center;
+ gap: 7px;
+ color: #c9ccd5;
+ font-size: 11px;
+}
.avatar {
position: relative;
@@ -151,20 +244,62 @@ html[data-pwa-edge-guard="active"] body {
border: 2px solid color-mix(in srgb, var(--avatar-color) 65%, white 5%);
border-radius: 50%;
color: #fff;
- background: linear-gradient(145deg, color-mix(in srgb, var(--avatar-color) 84%, white), color-mix(in srgb, var(--avatar-color) 54%, #111));
- box-shadow: inset 0 0 22px rgba(255,255,255,.12), 0 8px 24px color-mix(in srgb, var(--avatar-color) 22%, transparent);
+ background: linear-gradient(
+ 145deg,
+ color-mix(in srgb, var(--avatar-color) 84%, white),
+ color-mix(in srgb, var(--avatar-color) 54%, #111)
+ );
+ box-shadow:
+ inset 0 0 22px rgba(255, 255, 255, 0.12),
+ 0 8px 24px color-mix(in srgb, var(--avatar-color) 22%, transparent);
font-size: 15px;
font-weight: 750;
}
-.avatar i { position: absolute; right: -1px; bottom: 1px; width: 12px; height: 12px; border: 2px solid #0b0d12; border-radius: 50%; background: #34d399; }
-.avatar--sm { width: 38px; height: 38px; font-size: 11px; }
-.avatar--lg { width: 60px; height: 60px; }
-.avatar--xl { width: 102px; height: 102px; font-size: 27px; }
+.avatar i {
+ position: absolute;
+ right: -1px;
+ bottom: 1px;
+ width: 12px;
+ height: 12px;
+ border: 2px solid #0b0d12;
+ border-radius: 50%;
+ background: #34d399;
+}
+.avatar--sm {
+ width: 38px;
+ height: 38px;
+ font-size: 11px;
+}
+.avatar--lg {
+ width: 60px;
+ height: 60px;
+}
+.avatar--xl {
+ width: 102px;
+ height: 102px;
+ font-size: 27px;
+}
-.section-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 2px 4px 10px; }
-.section-heading h2 { margin: 0; font-size: 16px; }
-.section-heading span { color: #646a77; font-size: 11px; }
-.conversation-list { overflow: hidden; border: 1px solid rgba(255,255,255,.055); border-radius: 21px; background: rgba(255,255,255,.03); }
+.section-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 2px 4px 10px;
+}
+.section-heading h2 {
+ margin: 0;
+ font-size: 16px;
+}
+.section-heading span {
+ color: #646a77;
+ font-size: 11px;
+}
+.conversation-list {
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.055);
+ border-radius: 21px;
+ background: rgba(255, 255, 255, 0.03);
+}
.conversation-row {
position: relative;
display: flex;
@@ -180,113 +315,623 @@ html[data-pwa-edge-guard="active"] body {
background: transparent;
cursor: pointer;
}
-.conversation-row:last-child { border-bottom: 0; }
-.conversation-row:active { background: rgba(255,255,255,.045); }
-.conversation-copy { min-width: 0; flex: 1; }
-.conversation-copy > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
-.conversation-copy strong { display: block; overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
-.conversation-copy time { color: #6f7480; font-size: 11px; }
-.conversation-copy p { overflow: hidden; margin: 5px 0 0; color: #858a97; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
-.unread-badge { display: grid; min-width: 20px; height: 20px; padding: 0 6px; place-items: center; border-radius: 10px; background: var(--nvr-accent); font-size: 11px; font-weight: 800; }
-.chevron { color: #515663; font-size: 24px; }
+.conversation-row:last-child {
+ border-bottom: 0;
+}
+.conversation-row:active {
+ background: rgba(255, 255, 255, 0.045);
+}
+.conversation-copy {
+ min-width: 0;
+ flex: 1;
+}
+.conversation-copy > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.conversation-copy strong {
+ display: block;
+ overflow: hidden;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.conversation-copy time {
+ color: #6f7480;
+ font-size: 11px;
+}
+.conversation-copy p {
+ overflow: hidden;
+ margin: 5px 0 0;
+ color: #858a97;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.unread-badge {
+ display: grid;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 6px;
+ place-items: center;
+ border-radius: 10px;
+ background: var(--nvr-accent);
+ font-size: 11px;
+ font-weight: 800;
+}
+.chevron {
+ color: #515663;
+ font-size: 24px;
+}
-.story-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0 16px; }
-.story-card { position: relative; display: flex; min-height: 240px; flex-direction: column; justify-content: flex-end; gap: 9px; overflow: hidden; padding: 16px; border-radius: 25px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.09); }
-.story-card__glow { position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(0,0,0,.62)); }
-.story-card > *:not(.story-card__glow) { position: relative; z-index: 1; }
-.story-card strong { font-size: 13px; }
-.story-card p { margin: 2px 0 0; color: rgba(255,255,255,.7); font-size: 11px; }
-.story-card__mark { position: absolute !important; top: 18px; right: 18px; font-size: 28px; }
-.gesture-tip { margin: 22px auto; padding: 0 34px; color: #646a77; font-size: 12px; line-height: 1.5; text-align: center; }
+.story-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ padding: 0 16px;
+}
+.story-card {
+ position: relative;
+ display: flex;
+ min-height: 240px;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 9px;
+ overflow: hidden;
+ padding: 16px;
+ border-radius: 25px;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
+}
+.story-card__glow {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(transparent 30%, rgba(0, 0, 0, 0.62));
+}
+.story-card > *:not(.story-card__glow) {
+ position: relative;
+ z-index: 1;
+}
+.story-card strong {
+ font-size: 13px;
+}
+.story-card p {
+ margin: 2px 0 0;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+}
+.story-card__mark {
+ position: absolute !important;
+ top: 18px;
+ right: 18px;
+ font-size: 28px;
+}
+.gesture-tip {
+ margin: 22px auto;
+ padding: 0 34px;
+ color: #646a77;
+ font-size: 12px;
+ line-height: 1.5;
+ text-align: center;
+}
-.profile-card { margin: 0 16px 18px; padding: 28px 20px 22px; border: 1px solid var(--line); border-radius: 26px; background: linear-gradient(145deg, rgba(124,92,255,.16), rgba(255,255,255,.025)); text-align: center; }
-.profile-avatar { display: grid; width: 88px; height: 88px; margin: 0 auto 14px; place-items: center; border: 2px solid #9b88ff; border-radius: 30px; background: linear-gradient(145deg, #9b88ff, #4735a5); box-shadow: 0 18px 44px rgba(124,92,255,.26); font-size: 24px; font-weight: 800; transform: rotate(-3deg); }
-.profile-card h2 { margin: 0; font-size: 24px; }
-.profile-card > p { margin: 5px 0 22px; color: var(--muted); font-size: 13px; }
-.profile-stats { display: grid; grid-template-columns: repeat(3, 1fr); }
-.profile-stats div { display: flex; flex-direction: column; gap: 3px; border-right: 1px solid var(--line); }
-.profile-stats div:last-child { border: 0; }
-.profile-stats strong { font-size: 17px; }
-.profile-stats span { color: var(--muted); font-size: 11px; }
+.profile-card {
+ margin: 0 16px 18px;
+ padding: 28px 20px 22px;
+ border: 1px solid var(--line);
+ border-radius: 26px;
+ background: linear-gradient(
+ 145deg,
+ rgba(124, 92, 255, 0.16),
+ rgba(255, 255, 255, 0.025)
+ );
+ text-align: center;
+}
+.profile-avatar {
+ display: grid;
+ width: 88px;
+ height: 88px;
+ margin: 0 auto 14px;
+ place-items: center;
+ border: 2px solid #9b88ff;
+ border-radius: 30px;
+ background: linear-gradient(145deg, #9b88ff, #4735a5);
+ box-shadow: 0 18px 44px rgba(124, 92, 255, 0.26);
+ font-size: 24px;
+ font-weight: 800;
+ transform: rotate(-3deg);
+}
+.profile-card h2 {
+ margin: 0;
+ font-size: 24px;
+}
+.profile-card > p {
+ margin: 5px 0 22px;
+ color: var(--muted);
+ font-size: 13px;
+}
+.profile-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+}
+.profile-stats div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ border-right: 1px solid var(--line);
+}
+.profile-stats div:last-child {
+ border: 0;
+}
+.profile-stats strong {
+ font-size: 17px;
+}
+.profile-stats span {
+ color: var(--muted);
+ font-size: 11px;
+}
-.settings-list, .settings-group { overflow: hidden; margin: 0 16px 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.03); }
-.settings-list > a, .settings-list > button { display: grid; width: 100%; min-height: 58px; grid-template-columns: 30px 1fr auto; align-items: center; padding: 0 15px; border: 0; border-bottom: 1px solid var(--line); color: inherit; background: transparent; text-align: left; text-decoration: none; }
-.settings-list > :last-child { border-bottom: 0; }
-.settings-list strong { font-size: 14px; }
-.settings-list i { color: var(--muted); font-size: 12px; font-style: normal; }
-.settings-list .danger { color: #ff6c76; }
+.settings-list,
+.settings-group {
+ overflow: hidden;
+ margin: 0 16px 18px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.03);
+}
+.settings-list > a,
+.settings-list > button {
+ display: grid;
+ width: 100%;
+ min-height: 58px;
+ grid-template-columns: 30px 1fr auto;
+ align-items: center;
+ padding: 0 15px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ color: inherit;
+ background: transparent;
+ text-align: left;
+ text-decoration: none;
+}
+.settings-list > :last-child {
+ border-bottom: 0;
+}
+.settings-list strong {
+ font-size: 14px;
+}
+.settings-list i {
+ color: var(--muted);
+ font-size: 12px;
+ font-style: normal;
+}
+.settings-list .danger {
+ color: #ff6c76;
+}
-.chat-screen { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
-.message-list { display: flex; min-height: 0; flex-direction: column; gap: 8px; overflow-y: auto; padding: 18px 14px; overscroll-behavior: contain; }
-.message-day { align-self: center; margin-bottom: 9px; padding: 5px 10px; border-radius: 12px; color: #737986; background: rgba(255,255,255,.04); font-size: 10px; font-weight: 700; }
-.message { max-width: min(78%, 480px); align-self: flex-start; }
-.message p { margin: 0; padding: 10px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; font-size: 14px; line-height: 1.42; }
-.message footer { display: flex; gap: 6px; margin: 4px 5px 0; color: #656b78; font-size: 9px; }
-.message--mine { align-self: flex-end; }
-.message--mine p { border-radius: 18px 18px 5px 18px; background: linear-gradient(145deg, #8268ff, #6247d7); }
-.message--mine footer { justify-content: flex-end; }
-.typing-pill { display: flex; width: 50px; gap: 4px; padding: 12px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; }
-.typing-pill i { width: 5px; height: 5px; border-radius: 50%; background: #777d89; animation: typing 1s infinite alternate; }
-.typing-pill i:nth-child(2) { animation-delay: .2s; }.typing-pill i:nth-child(3) { animation-delay: .4s; }
-@keyframes typing { to { opacity: .25; transform: translateY(-3px); } }
-.composer { display: grid; grid-template-columns: 38px 1fr 38px; gap: 8px; padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: rgba(11,13,18,.92); backdrop-filter: blur(24px); }
-.composer input { min-width: 0; border: 1px solid var(--line); border-radius: 20px; outline: 0; padding: 0 15px; color: #fff; background: rgba(255,255,255,.055); }
-.composer button { border: 0; border-radius: 50%; background: rgba(255,255,255,.07); font-size: 21px; }
-.composer .send-button { background: var(--nvr-accent); font-weight: 700; }
-.composer .send-button:disabled { opacity: .35; }
+.chat-screen {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ overflow: hidden;
+}
+.message-list {
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ padding: 18px 14px;
+ overscroll-behavior: contain;
+}
+.message-day {
+ align-self: center;
+ margin-bottom: 9px;
+ padding: 5px 10px;
+ border-radius: 12px;
+ color: #737986;
+ background: rgba(255, 255, 255, 0.04);
+ font-size: 10px;
+ font-weight: 700;
+}
+.message {
+ max-width: min(78%, 480px);
+ align-self: flex-start;
+}
+.message p {
+ margin: 0;
+ padding: 10px 13px;
+ border-radius: 18px 18px 18px 5px;
+ background: #1a1e27;
+ font-size: 14px;
+ line-height: 1.42;
+}
+.message footer {
+ display: flex;
+ gap: 6px;
+ margin: 4px 5px 0;
+ color: #656b78;
+ font-size: 9px;
+}
+.message--mine {
+ align-self: flex-end;
+}
+.message--mine p {
+ border-radius: 18px 18px 5px 18px;
+ background: linear-gradient(145deg, #8268ff, #6247d7);
+}
+.message--mine footer {
+ justify-content: flex-end;
+}
+.typing-pill {
+ display: flex;
+ width: 50px;
+ gap: 4px;
+ padding: 12px 13px;
+ border-radius: 18px 18px 18px 5px;
+ background: #1a1e27;
+}
+.typing-pill i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #777d89;
+ animation: typing 1s infinite alternate;
+}
+.typing-pill i:nth-child(2) {
+ animation-delay: 0.2s;
+}
+.typing-pill i:nth-child(3) {
+ animation-delay: 0.4s;
+}
+@keyframes typing {
+ to {
+ opacity: 0.25;
+ transform: translateY(-3px);
+ }
+}
+.composer {
+ display: grid;
+ grid-template-columns: 38px 1fr 38px;
+ gap: 8px;
+ padding: 10px max(12px, env(safe-area-inset-right))
+ calc(10px + env(safe-area-inset-bottom))
+ max(12px, env(safe-area-inset-left));
+ border-top: 1px solid var(--line);
+ background: rgba(11, 13, 18, 0.92);
+ backdrop-filter: blur(24px);
+}
+.composer input {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ outline: 0;
+ padding: 0 15px;
+ color: #fff;
+ background: rgba(255, 255, 255, 0.055);
+}
+.composer button {
+ border: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.07);
+ font-size: 21px;
+}
+.composer .send-button {
+ background: var(--nvr-accent);
+ font-weight: 700;
+}
+.composer .send-button:disabled {
+ opacity: 0.35;
+}
-.contact-hero { padding: 36px 24px 20px; text-align: center; }
-.contact-hero h1 { margin: 15px 0 2px; font-size: 26px; }
-.contact-hero p { max-width: 340px; margin: 5px auto; color: var(--muted); font-size: 13px; line-height: 1.5; }
-.contact-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 0 16px 22px; }
-.contact-actions button { display: flex; min-height: 72px; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,.035); font-size: 11px; }
-.contact-actions span { color: #9b88ff; font-size: 21px; }
+.contact-hero {
+ padding: 36px 24px 20px;
+ text-align: center;
+}
+.contact-hero h1 {
+ margin: 15px 0 2px;
+ font-size: 26px;
+}
+.contact-hero p {
+ max-width: 340px;
+ margin: 5px auto;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+.contact-actions {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin: 0 16px 22px;
+}
+.contact-actions button {
+ display: flex;
+ min-height: 72px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.035);
+ font-size: 11px;
+}
+.contact-actions span {
+ color: #9b88ff;
+ font-size: 21px;
+}
-.sheet-screen { border-radius: 22px 22px 0 0; }
-.sheet-handle { position: sticky; z-index: 12; top: 8px; width: 38px; height: 5px; margin: 8px auto 0; border-radius: 4px; background: #4e5360; }
-.sheet-header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 16px; }
-.sheet-header h1 { margin: 0; font-size: 16px; }
-.sheet-header button { justify-self: start; border: 0; color: #9b88ff; background: transparent; }
-.compose-search { margin: 0 16px 14px; }
+.sheet-screen {
+ border-radius: 22px 22px 0 0;
+}
+.sheet-handle {
+ position: sticky;
+ z-index: 12;
+ top: 8px;
+ width: 38px;
+ height: 5px;
+ margin: 8px auto 0;
+ border-radius: 4px;
+ background: #4e5360;
+}
+.sheet-header {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ padding: 16px;
+}
+.sheet-header h1 {
+ margin: 0;
+ font-size: 16px;
+}
+.sheet-header button {
+ justify-self: start;
+ border: 0;
+ color: #9b88ff;
+ background: transparent;
+}
+.compose-search {
+ margin: 0 16px 14px;
+}
-.lab-intro { display: flex; align-items: center; gap: 16px; margin: 20px 16px; padding: 18px; border: 1px solid rgba(124,92,255,.2); border-radius: 20px; background: rgba(124,92,255,.1); }
-.lab-intro > span { display: grid; width: 54px; height: 54px; place-items: center; border-radius: 17px; background: #7c5cff; font-size: 20px; font-weight: 800; }
-.lab-intro strong { font-size: 14px; }.lab-intro p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
-.lab-intro--timer > span { font-variant-numeric: tabular-nums; }
-.runtime-lab-screen .settings-group { padding-bottom: 0; }
-.lab-probe { display: flex; min-height: 86px; align-items: center; gap: 14px; padding: 15px; border-top: 1px solid var(--line); }
-.lab-probe__icon, .lab-spinner { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border-radius: 14px; }
-.lab-probe__icon { color: #07130f; background: #3dd9aa; font-size: 20px; font-weight: 900; }
-.lab-probe strong { font-size: 13px; }
-.lab-probe p { margin: 4px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
-.lab-spinner { border: 3px solid rgba(155,136,255,.2); border-top-color: #9b88ff; animation: lab-spin .7s linear infinite; }
-@keyframes lab-spin { to { transform: rotate(360deg); } }
-.settings-group { padding: 8px 0; }
-.settings-group h2 { margin: 8px 15px; color: #737986; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
-.settings-group > label, .settings-group > div { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 15px; padding: 10px 15px; border-top: 1px solid var(--line); }
-.settings-group > p { margin: 0; padding: 12px 15px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; line-height: 1.55; }
-.settings-group label span, .settings-group div span { display: flex; flex-direction: column; gap: 3px; }
-.settings-group strong { font-size: 13px; }.settings-group small { color: var(--muted); }
-.settings-group input[type="range"] { width: 120px; accent-color: var(--nvr-accent); }
-.settings-group input[type="checkbox"] { width: 42px; height: 24px; accent-color: var(--nvr-accent); }
-.settings-group b { color: #3dd9aa; font-size: 12px; }.settings-group b.offline { color: #ff7a84; }
-.reset-button { display: block; width: calc(100% - 32px); min-height: 48px; margin: 0 16px 30px; border: 1px solid rgba(255,108,118,.2); border-radius: 15px; color: #ff7a84; background: rgba(255,108,118,.07); }
-.reset-button--neutral { margin-bottom: 18px; border-color: rgba(124,92,255,.22); color: #ad9fff; background: rgba(124,92,255,.08); }
-.profiler-controls { display: grid; grid-template-columns: 1fr .7fr 1fr; gap: 8px; margin: 0 16px 18px; }
-.profiler-controls button { min-height: 44px; border: 1px solid rgba(124,92,255,.24); border-radius: 13px; color: #c2b8ff; background: rgba(124,92,255,.1); font-weight: 700; }
-.profiler-controls button:disabled { opacity: .38; }
-.profiler-badge { position: absolute; z-index: 60; top: calc(8px + env(safe-area-inset-top)); right: 10px; display: flex; align-items: center; gap: 6px; padding: 6px 9px; border: 1px solid rgba(255,108,118,.35); border-radius: 999px; color: #ff9aa2; background: rgba(24,10,13,.9); font-size: 10px; font-weight: 800; pointer-events: none; }
-.profiler-badge i { width: 7px; height: 7px; border-radius: 50%; background: #ff6c76; box-shadow: 0 0 9px #ff6c76; }
-.empty-state { display: grid; place-items: center; }
-.update-toast { position: absolute; z-index: 50; right: 14px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 14px; display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 15px; background: rgba(28,31,40,.96); box-shadow: 0 18px 50px rgba(0,0,0,.4); font-size: 12px; }
-.update-toast button { border: 0; color: #a998ff; background: transparent; font-weight: 700; }
+.lab-intro {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin: 20px 16px;
+ padding: 18px;
+ border: 1px solid rgba(124, 92, 255, 0.2);
+ border-radius: 20px;
+ background: rgba(124, 92, 255, 0.1);
+}
+.lab-intro > span {
+ display: grid;
+ width: 54px;
+ height: 54px;
+ place-items: center;
+ border-radius: 17px;
+ background: #7c5cff;
+ font-size: 20px;
+ font-weight: 800;
+}
+.lab-intro strong {
+ font-size: 14px;
+}
+.lab-intro p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 11px;
+}
+.lab-intro--timer > span {
+ font-variant-numeric: tabular-nums;
+}
+.runtime-lab-screen .settings-group {
+ padding-bottom: 0;
+}
+.lab-probe {
+ display: flex;
+ min-height: 86px;
+ align-items: center;
+ gap: 14px;
+ padding: 15px;
+ border-top: 1px solid var(--line);
+}
+.lab-probe__icon,
+.lab-spinner {
+ display: grid;
+ flex: 0 0 auto;
+ width: 42px;
+ height: 42px;
+ place-items: center;
+ border-radius: 14px;
+}
+.lab-probe__icon {
+ color: #07130f;
+ background: #3dd9aa;
+ font-size: 20px;
+ font-weight: 900;
+}
+.lab-probe strong {
+ font-size: 13px;
+}
+.lab-probe p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.lab-spinner {
+ border: 3px solid rgba(155, 136, 255, 0.2);
+ border-top-color: #9b88ff;
+ animation: lab-spin 1.2s linear infinite;
+}
+@keyframes lab-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.settings-group {
+ padding: 8px 0;
+}
+.settings-group h2 {
+ margin: 8px 15px;
+ color: #737986;
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.settings-group > label,
+.settings-group > div {
+ display: flex;
+ min-height: 62px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ padding: 10px 15px;
+ border-top: 1px solid var(--line);
+}
+.settings-group > p {
+ margin: 0;
+ padding: 12px 15px;
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.55;
+}
+.settings-group label span,
+.settings-group div span {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.settings-group strong {
+ font-size: 13px;
+}
+.settings-group small {
+ color: var(--muted);
+}
+.settings-group input[type="range"] {
+ width: 120px;
+ accent-color: var(--nvr-accent);
+}
+.settings-group input[type="checkbox"] {
+ width: 42px;
+ height: 24px;
+ accent-color: var(--nvr-accent);
+}
+.settings-group b {
+ color: #3dd9aa;
+ font-size: 12px;
+}
+.settings-group b.offline {
+ color: #ff7a84;
+}
+.reset-button {
+ display: block;
+ width: calc(100% - 32px);
+ min-height: 48px;
+ margin: 0 16px 30px;
+ border: 1px solid rgba(255, 108, 118, 0.2);
+ border-radius: 15px;
+ color: #ff7a84;
+ background: rgba(255, 108, 118, 0.07);
+}
+.reset-button--neutral {
+ margin-bottom: 18px;
+ border-color: rgba(124, 92, 255, 0.22);
+ color: #ad9fff;
+ background: rgba(124, 92, 255, 0.08);
+}
+.profiler-controls {
+ display: grid;
+ grid-template-columns: 1fr 0.7fr 1fr;
+ gap: 8px;
+ margin: 0 16px 18px;
+}
+.profiler-controls button {
+ min-height: 44px;
+ border: 1px solid rgba(124, 92, 255, 0.24);
+ border-radius: 13px;
+ color: #c2b8ff;
+ background: rgba(124, 92, 255, 0.1);
+ font-weight: 700;
+}
+.profiler-controls button:disabled {
+ opacity: 0.38;
+}
+.profiler-badge {
+ position: absolute;
+ z-index: 60;
+ top: calc(8px + env(safe-area-inset-top));
+ right: 10px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 9px;
+ border: 1px solid rgba(255, 108, 118, 0.35);
+ border-radius: 999px;
+ color: #ff9aa2;
+ background: rgba(24, 10, 13, 0.9);
+ font-size: 10px;
+ font-weight: 800;
+ pointer-events: none;
+}
+.profiler-badge i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #ff6c76;
+ box-shadow: 0 0 9px #ff6c76;
+}
+.empty-state {
+ display: grid;
+ place-items: center;
+}
+.update-toast {
+ position: absolute;
+ z-index: 50;
+ right: 14px;
+ bottom: calc(82px + env(safe-area-inset-bottom));
+ left: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 15px;
+ background: rgba(28, 31, 40, 0.96);
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
+ font-size: 12px;
+}
+.update-toast button {
+ border: 0;
+ color: #a998ff;
+ background: transparent;
+ font-weight: 700;
+}
@media (min-width: 741px) {
- .app-frame { height: min(920px, calc(100dvh - 28px)); margin-top: 14px; border: 1px solid rgba(255,255,255,.08); border-radius: 30px; }
- .story-grid { grid-template-columns: repeat(4, 1fr); }
- .story-card { min-height: 300px; }
+ .app-frame {
+ height: min(920px, calc(100dvh - 28px));
+ margin-top: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 30px;
+ }
+ .story-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+ .story-card {
+ min-height: 300px;
+ }
}
@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ }
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 06bf591..6032884 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -26,14 +26,15 @@ Component gesture owners stop propagation before a containing navigator can clai
```ts
interface NativeRouteOptions {
- navigator?: string
- presentation?: 'push' | 'reveal' | 'slide' | 'fade' | 'modal' | 'sheet' | string
- parent?: RouteLocationRaw | ((route) => RouteLocationRaw)
- siblingGroup?: string
- siblingOrder?: number
- siblingHistory?: 'push' | 'replace'
- cache?: boolean | 'pin'
- gesture?: boolean | 'edge' | 'full'
+ navigator?: string;
+ presentation?:
+ "push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | string;
+ parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
+ siblingGroup?: string;
+ siblingOrder?: number;
+ siblingHistory?: "push" | "replace";
+ cache?: boolean | "pin";
+ gesture?: boolean | "edge" | "full";
}
```
@@ -42,15 +43,17 @@ interface NativeRouteOptions {
## Custom presentations
```ts
-nativeRouter.registerPresentation(definePresentation({
- name: 'scale-fade',
- axis: 'x',
- layerStyle({ role, progress }) {
- return role === 'to'
- ? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
- : { opacity: 1 - progress * 0.4 }
- },
-}))
+nativeRouter.registerPresentation(
+ definePresentation({
+ name: "scale-fade",
+ axis: "x",
+ layerStyle({ role, progress }) {
+ return role === "to"
+ ? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
+ : { opacity: 1 - progress * 0.4 };
+ },
+ }),
+);
```
Applications can call `beginInteractive()`, `updateInteractive()`, and `finishInteractive()` to drive the same transaction engine from a bespoke recognizer.
@@ -72,16 +75,16 @@ import {
onNativeViewEvict,
useNativeViewActiveEffect,
useNativeViewLifecycle,
-} from '@native-vue-router/core'
+} from "@native-vue-router/core";
-const view = useNativeViewLifecycle()
+const view = useNativeViewLifecycle();
useNativeViewActiveEffect(() => {
- const timer = startPolling()
- return () => stopPolling(timer)
-})
+ const timer = startPolling();
+ 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.
diff --git a/docs/challenges-and-tradeoffs.md b/docs/challenges-and-tradeoffs.md
index 770eaab..ec8d3f3 100644
--- a/docs/challenges-and-tradeoffs.md
+++ b/docs/challenges-and-tradeoffs.md
@@ -193,12 +193,12 @@ Deployment infrastructure must still avoid long-lived caching for `sw.js` and th
## Summary of deliberate trade-offs
-| Decision | Benefit | Cost |
-| --- | --- | --- |
-| 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 |
-| 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 |
-| 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 |
-| Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts |
+| Decision | Benefit | Cost |
+| --------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
+| 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 |
+| 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 |
+| 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 |
+| Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts |
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index 88e247f..2056e54 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -19,9 +19,9 @@ Vue Router remains the authority for semantic navigation: route matching, URLs,
That separation produces two ledgers:
-| Ledger | Owns | Source of truth for |
-| --- | --- | --- |
-| Vue Router | Current committed route and browser history | What URL the application is actually on |
+| Ledger | Owns | Source of truth for |
+| ------------------- | --------------------------------------------------- | ------------------------------------------------- |
+| 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 |
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.
diff --git a/docs/principles-and-scalability.md b/docs/principles-and-scalability.md
index 5976796..1ff732d 100644
--- a/docs/principles-and-scalability.md
+++ b/docs/principles-and-scalability.md
@@ -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:
-| Resource | Growth behavior |
-| --- | --- |
-| Animated surfaces | Constant: `from` and `to` |
-| Mounted inactive component trees | Bounded by `maxInactive` |
-| Route descriptors/history keys | Grows with navigation history |
-| Per-frame transaction state | Constant |
-| Lazy route code | Loaded on first preview or navigation |
+| Resource | Growth behavior |
+| -------------------------------- | ------------------------------------- |
+| Animated surfaces | Constant: `from` and `to` |
+| Mounted inactive component trees | Bounded by `maxInactive` |
+| Route descriptors/history keys | Grows with navigation history |
+| Per-frame transaction state | Constant |
+| 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.
@@ -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:
```ts
-nativeRouter.registerPresentation(definePresentation({
- name: 'scale-fade',
- axis: 'x',
- layerStyle({ role, progress }) {
- return role === 'to'
- ? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
- : { opacity: 1 - progress * 0.25 }
- },
-}))
+nativeRouter.registerPresentation(
+ definePresentation({
+ name: "scale-fade",
+ axis: "x",
+ layerStyle({ role, progress }) {
+ return role === "to"
+ ? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
+ : { opacity: 1 - progress * 0.25 };
+ },
+ }),
+);
```
A presentation does not decide history or commit. Keeping motion separate from navigation semantics makes new visual styles safer to add.
diff --git a/package-lock.json b/package-lock.json
index 8365455..cf50f61 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -39,6 +39,7 @@
"@vue/tsconfig": "^0.9.1",
"electron": "^43.1.1",
"happy-dom": "^20.10.6",
+ "prettier": "^3.9.6",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^3.2.4",
@@ -1836,6 +1837,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1852,6 +1854,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1868,6 +1871,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1884,6 +1888,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1900,6 +1905,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1916,6 +1922,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1932,6 +1939,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1948,6 +1956,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1964,6 +1973,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1980,6 +1990,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1996,6 +2007,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2012,6 +2024,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2028,6 +2041,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2044,6 +2058,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2060,6 +2075,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2076,6 +2092,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2092,6 +2109,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2108,6 +2126,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2124,6 +2143,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2140,6 +2160,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2156,6 +2177,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2172,6 +2194,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2188,6 +2211,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2204,6 +2228,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2220,6 +2245,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2236,6 +2262,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5084,9 +5111,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+ "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
@@ -6786,6 +6813,22 @@
"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": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
diff --git a/package.json b/package.json
index e78dab8..de048e4 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"test:e2e": "playwright test",
"preview": "vite preview",
"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": {
"@capacitor/app": "^8.0.0",
@@ -46,6 +47,7 @@
"@vue/tsconfig": "^0.9.1",
"electron": "^43.1.1",
"happy-dom": "^20.10.6",
+ "prettier": "^3.9.6",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^3.2.4",
diff --git a/packages/capacitor/package.json b/packages/capacitor/package.json
index 9713bd2..177e7e7 100644
--- a/packages/capacitor/package.json
+++ b/packages/capacitor/package.json
@@ -3,9 +3,18 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
- "files": ["dist"],
- "scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
- "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
+ "files": [
+ "dist"
+ ],
+ "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": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.0.0",
diff --git a/packages/capacitor/src/index.ts b/packages/capacitor/src/index.ts
index 4c40a0f..4faefd4 100644
--- a/packages/capacitor/src/index.ts
+++ b/packages/capacitor/src/index.ts
@@ -1,50 +1,66 @@
-import { App } from '@capacitor/app'
-import { Capacitor } from '@capacitor/core'
-import { Haptics, ImpactStyle } from '@capacitor/haptics'
-import type { NativePlatformAdapter, NativeRouterRuntime } from '@native-vue-router/core'
+import { App } from "@capacitor/app";
+import { Capacitor } from "@capacitor/core";
+import { Haptics, ImpactStyle } from "@capacitor/haptics";
+import type {
+ NativePlatformAdapter,
+ NativeRouterRuntime,
+} from "@native-vue-router/core";
export interface CapacitorAdapterOptions {
- exitAtRoot?: boolean
- haptics?: boolean
+ exitAtRoot?: boolean;
+ haptics?: boolean;
/** Release inactive component trees when the native app backgrounds. Defaults to true. */
- trimCacheOnPause?: boolean
- deepLinkPath?: (url: URL) => string
+ trimCacheOnPause?: boolean;
+ deepLinkPath?: (url: URL) => string;
}
-export function createCapacitorAdapter(options: CapacitorAdapterOptions = {}): NativePlatformAdapter {
- const enabled = Capacitor.isNativePlatform()
+export function createCapacitorAdapter(
+ options: CapacitorAdapterOptions = {},
+): NativePlatformAdapter {
+ const enabled = Capacitor.isNativePlatform();
return {
- name: enabled ? `capacitor-${Capacitor.getPlatform()}` : 'capacitor-web',
+ name: enabled ? `capacitor-${Capacitor.getPlatform()}` : "capacitor-web",
async haptic(event) {
- if (!enabled || options.haptics === false) return
- if (event === 'selection') await Haptics.selectionChanged()
- else await Haptics.impact({ style: event === 'commit' ? ImpactStyle.Light : ImpactStyle.Medium })
+ if (!enabled || options.haptics === false) return;
+ if (event === "selection") await Haptics.selectionChanged();
+ else
+ await Haptics.impact({
+ style: event === "commit" ? ImpactStyle.Light : ImpactStyle.Medium,
+ });
},
async install(runtime: NativeRouterRuntime) {
- if (!enabled) return
+ if (!enabled) return;
const handles = await Promise.all([
- App.addListener('backButton', async () => {
- if (runtime.transaction.value) return await runtime.cancelInteractive()
- if (runtime.canGoBack.value) return void await runtime.pop()
- if (options.exitAtRoot !== false) await App.exitApp()
+ App.addListener("backButton", async () => {
+ if (runtime.transaction.value)
+ return await runtime.cancelInteractive();
+ if (runtime.canGoBack.value) return void (await runtime.pop());
+ if (options.exitAtRoot !== false) await App.exitApp();
}),
- App.addListener('appUrlOpen', ({ url }) => {
- const parsed = new URL(url)
- const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}`
- void runtime.push(path || '/')
+ App.addListener("appUrlOpen", ({ url }) => {
+ const parsed = new URL(url);
+ const path =
+ options.deepLinkPath?.(parsed) ??
+ `${parsed.pathname}${parsed.search}${parsed.hash}`;
+ void runtime.push(path || "/");
}),
- App.addListener('pause', () => {
- void runtime.cancelInteractive()
- if (options.trimCacheOnPause !== false) runtime.trimCache({ reason: 'memory-pressure' })
+ App.addListener("pause", () => {
+ void runtime.cancelInteractive();
+ if (options.trimCacheOnPause !== false)
+ runtime.trimCache({ reason: "memory-pressure" });
}),
- ])
- const launch = await App.getLaunchUrl()
+ ]);
+ const launch = await App.getLaunchUrl();
if (launch?.url) {
- const parsed = new URL(launch.url)
- const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}`
- if (path) void runtime.replace(path, { presentation: 'none' })
+ const parsed = new URL(launch.url);
+ const path =
+ 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()));
+ };
},
- }
+ };
}
diff --git a/packages/capacitor/vite.config.ts b/packages/capacitor/vite.config.ts
index b357973..5d403a3 100644
--- a/packages/capacitor/vite.config.ts
+++ b/packages/capacitor/vite.config.ts
@@ -1,10 +1,22 @@
-import { defineConfig } from 'vite'
-import { resolve } from 'node:path'
+import { defineConfig } from "vite";
+import { resolve } from "node:path";
export default defineConfig({
build: {
- outDir: 'dist', emptyOutDir: true,
- lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' },
- rollupOptions: { external: ['@capacitor/app', '@capacitor/core', '@capacitor/haptics', '@native-vue-router/core'] },
+ outDir: "dist",
+ emptyOutDir: true,
+ lib: {
+ entry: resolve(__dirname, "src/index.ts"),
+ formats: ["es"],
+ fileName: "index",
+ },
+ rollupOptions: {
+ external: [
+ "@capacitor/app",
+ "@capacitor/core",
+ "@capacitor/haptics",
+ "@native-vue-router/core",
+ ],
+ },
},
-})
+});
diff --git a/packages/core/package.json b/packages/core/package.json
index 855fe6d..612d91b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -3,11 +3,20 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
- "files": ["dist"],
- "sideEffects": ["./dist/style.css"],
- "scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
+ "files": [
+ "dist"
+ ],
+ "sideEffects": [
+ "./dist/style.css"
+ ],
+ "scripts": {
+ "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
+ },
"exports": {
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
"./style.css": "./dist/style.css"
},
"peerDependencies": {
diff --git a/packages/core/src/components.ts b/packages/core/src/components.ts
index 5510292..843bcdb 100644
--- a/packages/core/src/components.ts
+++ b/packages/core/src/components.ts
@@ -13,14 +13,14 @@ import {
type PropType,
type InjectionKey,
type VNode,
-} from 'vue'
+} from "vue";
import {
routeLocationKey,
RouterView,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
-} from 'vue-router'
-import { nativeRouterKey } from './runtime'
+} from "vue-router";
+import { nativeRouterKey } from "./runtime";
import type {
NativeDirection,
NativeNavigationOptions,
@@ -29,202 +29,301 @@ import type {
NativeViewEntry,
NativeViewLifecycle,
NativeViewRole,
-} from './types'
+} from "./types";
-const nativeViewLifecycleKey: InjectionKey = Symbol('native-view-lifecycle')
+const nativeViewLifecycleKey: InjectionKey = Symbol(
+ "native-view-lifecycle",
+);
export function useNativeRouter() {
- const runtime = inject(nativeRouterKey)
- if (!runtime) throw new Error('Native Vue Router is not installed. Call app.use(nativeRouter).')
- return runtime
+ const runtime = inject(nativeRouterKey);
+ if (!runtime)
+ throw new Error(
+ "Native Vue Router is not installed. Call app.use(nativeRouter).",
+ );
+ return runtime;
}
export function useNativeViewLifecycle() {
- const lifecycle = inject(nativeViewLifecycleKey)
- if (!lifecycle) throw new Error('Native view lifecycle APIs must be used inside NativeRouterView.')
- return lifecycle
+ const lifecycle = inject(nativeViewLifecycleKey);
+ if (!lifecycle)
+ throw new Error(
+ "Native view lifecycle APIs must be used inside NativeRouterView.",
+ );
+ return lifecycle;
}
-type NativeViewHook = () => void
+type NativeViewHook = () => void;
-function onNativeViewState(source: Readonly<{ value: boolean }>, entering: boolean, hook: NativeViewHook) {
+function onNativeViewState(
+ source: Readonly<{ value: boolean }>,
+ entering: boolean,
+ hook: NativeViewHook,
+) {
onMounted(() => {
- if (entering && source.value) hook()
- })
- watch(() => source.value, (value, previous) => {
- if (value === entering && previous !== entering) hook()
- }, { flush: 'sync' })
+ if (entering && source.value) hook();
+ });
+ watch(
+ () => source.value,
+ (value, previous) => {
+ if (value === entering && previous !== entering) hook();
+ },
+ { flush: "sync" },
+ );
}
export function onNativeViewActivate(hook: NativeViewHook) {
- onNativeViewState(useNativeViewLifecycle().isActive, true, hook)
+ onNativeViewState(useNativeViewLifecycle().isActive, true, hook);
}
export function onNativeViewDeactivate(hook: NativeViewHook) {
- onNativeViewState(useNativeViewLifecycle().isActive, false, hook)
+ onNativeViewState(useNativeViewLifecycle().isActive, false, hook);
}
export function onNativeViewShow(hook: NativeViewHook) {
- onNativeViewState(useNativeViewLifecycle().isVisible, true, hook)
+ onNativeViewState(useNativeViewLifecycle().isVisible, true, hook);
}
export function onNativeViewHide(hook: NativeViewHook) {
- onNativeViewState(useNativeViewLifecycle().isVisible, false, hook)
+ onNativeViewState(useNativeViewLifecycle().isVisible, false, hook);
}
-export function onNativeViewEvict(hook: (reason: NativeViewLifecycle['evictionReason']['value']) => void) {
- const lifecycle = useNativeViewLifecycle()
+export function onNativeViewEvict(
+ hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void,
+) {
+ const lifecycle = useNativeViewLifecycle();
onBeforeUnmount(() => {
- if (lifecycle.status.value === 'evicted') hook(lifecycle.evictionReason.value)
- })
+ if (lifecycle.status.value === "evicted")
+ hook(lifecycle.evictionReason.value);
+ });
}
function useNativeViewEffect(
source: Readonly<{ value: boolean }>,
effect: () => void | (() => void),
) {
- let cleanup: void | (() => void)
+ let cleanup: void | (() => void);
const stopEffect = () => {
- cleanup?.()
- cleanup = undefined
- }
- const stopWatch = watch(() => source.value, (enabled) => {
- stopEffect()
- if (enabled) cleanup = effect()
- }, { immediate: true, flush: 'sync' })
+ cleanup?.();
+ cleanup = undefined;
+ };
+ const stopWatch = watch(
+ () => source.value,
+ (enabled) => {
+ stopEffect();
+ if (enabled) cleanup = effect();
+ },
+ { immediate: true, flush: "sync" },
+ );
onScopeDispose(() => {
- stopWatch()
- stopEffect()
- })
+ stopWatch();
+ stopEffect();
+ });
}
/** Runs an effect only while this route is the semantically active route. */
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
- useNativeViewEffect(useNativeViewLifecycle().isActive, effect)
+ useNativeViewEffect(useNativeViewLifecycle().isActive, effect);
}
/** Runs an effect while this route is active or participating in a transition. */
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
- useNativeViewEffect(useNativeViewLifecycle().isVisible, effect)
+ useNativeViewEffect(useNativeViewLifecycle().isVisible, effect);
}
const NativeRouteScope = defineComponent({
- name: 'NativeRouteScope',
+ name: "NativeRouteScope",
props: {
- route: { type: Object as PropType, required: true },
+ route: {
+ type: Object as PropType,
+ required: true,
+ },
entryKey: { type: String, required: true },
},
setup(props, { slots }) {
- const runtime = useNativeRouter()
- const scopedRoute = shallowReactive({ ...props.route }) as RouteLocationNormalizedLoaded
- watch(() => props.route, (route) => Object.assign(scopedRoute, route), { immediate: true })
- provide(routeLocationKey, scopedRoute)
- const entry = computed(() => runtime.entries.value.find((candidate) => candidate.key === props.entryKey))
- const role = computed(() => entry.value ? interactiveRole(entry.value, runtime) : 'inactive')
+ const runtime = useNativeRouter();
+ const scopedRoute = shallowReactive({
+ ...props.route,
+ }) as RouteLocationNormalizedLoaded;
+ watch(
+ () => props.route,
+ (route) => Object.assign(scopedRoute, route),
+ { immediate: true },
+ );
+ provide(routeLocationKey, scopedRoute);
+ const entry = computed(() =>
+ runtime.entries.value.find(
+ (candidate) => candidate.key === props.entryKey,
+ ),
+ );
+ const role = computed(() =>
+ entry.value ? interactiveRole(entry.value, runtime) : "inactive",
+ );
provide(nativeViewLifecycleKey, {
key: props.entryKey,
route: computed(() => entry.value?.route ?? props.route),
- status: computed(() => entry.value?.status ?? 'evicted'),
+ status: computed(() => entry.value?.status ?? "evicted"),
role,
isActive: computed(() => runtime.activeKey.value === props.entryKey),
- isVisible: computed(() => role.value !== 'inactive'),
- isPreview: computed(() => runtime.transaction.value?.toKey === props.entryKey),
- isCached: computed(() => Boolean(entry.value?.mounted && role.value === 'inactive')),
+ isVisible: computed(() => role.value !== "inactive"),
+ isPreview: computed(
+ () => runtime.transaction.value?.toKey === props.entryKey,
+ ),
+ isCached: computed(() =>
+ Boolean(entry.value?.mounted && role.value === "inactive"),
+ ),
evictionReason: computed(() => entry.value?.evictionReason),
- })
- return () => slots.default?.()
+ });
+ return () => slots.default?.();
},
-})
+});
-function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime): NativeViewRole {
- const transaction = runtime.transaction.value
- if (!transaction) return entry.key === runtime.activeKey.value ? 'active' : 'inactive'
- if (entry.key === transaction.fromKey) return 'from'
- if (entry.key === transaction.toKey) return 'to'
- return 'inactive'
+function interactiveRole(
+ entry: NativeViewEntry,
+ runtime: NativeRouterRuntime,
+): NativeViewRole {
+ const transaction = runtime.transaction.value;
+ if (!transaction)
+ return entry.key === runtime.activeKey.value ? "active" : "inactive";
+ if (entry.key === transaction.fromKey) return "from";
+ if (entry.key === transaction.toKey) return "to";
+ return "inactive";
}
export const NativeRouterView = defineComponent({
- name: 'NativeRouterView',
+ name: "NativeRouterView",
setup(_, { slots }) {
- const runtime = useNativeRouter()
+ const runtime = useNativeRouter();
return () => {
- const transaction = runtime.transaction.value
- const style = transaction ? { '--native-progress': String(transaction.progress) } : undefined
+ const transaction = runtime.transaction.value;
+ const style = transaction
+ ? { "--native-progress": String(transaction.progress) }
+ : undefined;
const children = runtime.entries.value
.filter((entry) => entry.mounted)
.map((entry) => {
- const role = interactiveRole(entry, runtime)
- const definition = transaction ? runtime.presentationFor(transaction.presentation) : undefined
- const customStyle = transaction && (role === 'from' || role === 'to')
- ? definition?.layerStyle?.({
- progress: transaction.progress,
- role,
- direction: transaction.direction,
- sourceRect: transaction.sourceRect,
- })
- : undefined
- return h('section', {
- key: entry.key,
- class: ['nvr-view', `nvr-view--${role}`],
- style: customStyle,
- 'data-native-role': role,
- 'data-native-route': entry.route.fullPath,
- 'data-native-presentation': transaction?.presentation,
- 'data-native-direction': transaction?.direction,
- inert: role === 'inactive' ? '' : undefined,
- 'aria-hidden': role === 'inactive' ? 'true' : undefined,
- }, [
- h(RouterView, { route: entry.route }, {
- default: ({ Component, route }: { Component: VNode | null; route: RouteLocationNormalizedLoaded }) => {
- if (slots.default) return slots.default({ Component, route, entry })
- return Component
- ? h(NativeRouteScope, { route, entryKey: entry.key }, { default: () => Component })
- : null
- },
- }),
- ])
- })
- return h('div', {
- class: ['nvr-router-view', transaction && 'nvr-router-view--interactive'],
- style,
- 'data-native-presentation': transaction?.presentation,
- 'data-native-direction': transaction?.direction,
- 'data-native-transaction': transaction?.id,
- 'data-native-velocity': transaction ? String(transaction.velocity) : undefined,
- }, children)
- }
+ const role = interactiveRole(entry, runtime);
+ const definition = transaction
+ ? runtime.presentationFor(transaction.presentation)
+ : undefined;
+ const customStyle =
+ transaction && (role === "from" || role === "to")
+ ? definition?.layerStyle?.({
+ progress: transaction.progress,
+ role,
+ direction: transaction.direction,
+ sourceRect: transaction.sourceRect,
+ })
+ : undefined;
+ return h(
+ "section",
+ {
+ key: entry.key,
+ class: ["nvr-view", `nvr-view--${role}`],
+ style: customStyle,
+ "data-native-role": role,
+ "data-native-route": entry.route.fullPath,
+ "data-native-presentation": transaction?.presentation,
+ "data-native-direction": transaction?.direction,
+ inert: role === "inactive" ? "" : undefined,
+ "aria-hidden": role === "inactive" ? "true" : undefined,
+ },
+ [
+ h(
+ RouterView,
+ { route: entry.route },
+ {
+ default: ({
+ Component,
+ route,
+ }: {
+ Component: VNode | null;
+ route: RouteLocationNormalizedLoaded;
+ }) => {
+ if (slots.default)
+ return slots.default({ Component, route, entry });
+ return Component
+ ? h(
+ NativeRouteScope,
+ { route, entryKey: entry.key },
+ { default: () => Component },
+ )
+ : null;
+ },
+ },
+ ),
+ ],
+ );
+ });
+ return h(
+ "div",
+ {
+ class: [
+ "nvr-router-view",
+ transaction && "nvr-router-view--interactive",
+ ],
+ style,
+ "data-native-presentation": transaction?.presentation,
+ "data-native-direction": transaction?.direction,
+ "data-native-transaction": transaction?.id,
+ "data-native-velocity": transaction
+ ? String(transaction.velocity)
+ : undefined,
+ },
+ children,
+ );
+ };
},
-})
+});
export const NativeLink = defineComponent({
- name: 'NativeLink',
+ name: "NativeLink",
props: {
- to: { type: [String, Object] as PropType, required: true },
+ to: {
+ type: [String, Object] as PropType,
+ required: true,
+ },
replace: Boolean,
presentation: String,
},
setup(props, { slots, attrs }) {
- const runtime = useNativeRouter()
- const href = computed(() => runtime.router.resolve(props.to).href)
+ const runtime = useNativeRouter();
+ const href = computed(() => runtime.router.resolve(props.to).href);
const activate = (event: MouseEvent) => {
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
- event.preventDefault()
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ )
+ return;
+ event.preventDefault();
void (props.replace
? runtime.replace(props.to, { presentation: props.presentation })
- : runtime.push(props.to, { presentation: props.presentation }))
- }
- return () => h('a', { ...attrs, href: href.value, onClick: activate }, slots.default?.())
+ : runtime.push(props.to, { presentation: props.presentation }));
+ };
+ return () =>
+ h(
+ "a",
+ { ...attrs, href: href.value, onClick: activate },
+ slots.default?.(),
+ );
},
-})
+});
function shouldIgnoreGesture(target: EventTarget | null) {
- if (!(target instanceof Element)) return true
- return Boolean(target.closest('[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]'))
+ if (!(target instanceof Element)) return true;
+ return Boolean(
+ target.closest(
+ '[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
+ ),
+ );
}
function sourceRect(element: HTMLElement): NativeSourceRect {
- const rect = element.getBoundingClientRect()
+ const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
@@ -232,7 +331,7 @@ function sourceRect(element: HTMLElement): NativeSourceRect {
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
- }
+ };
}
function createPointerGesture(
@@ -240,341 +339,422 @@ function createPointerGesture(
begin: (direction: NativeDirection) => Promise,
directionForDelta: (deltaX: number) => NativeDirection | null,
) {
- let pointerId = -1
- let startX = 0
- let startY = 0
- let lastX = 0
- let lastTime = 0
- let captured = false
- let beginPromise: Promise | null = null
- let ending = false
- let bufferedProgress = 0
- let bufferedVelocity = 0
- let gestureSign = 0
+ let pointerId = -1;
+ let startX = 0;
+ let startY = 0;
+ let lastX = 0;
+ let lastTime = 0;
+ let captured = false;
+ let beginPromise: Promise | null = null;
+ let ending = false;
+ let bufferedProgress = 0;
+ let bufferedVelocity = 0;
+ let gestureSign = 0;
const down = (event: PointerEvent) => {
- if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
- pointerId = event.pointerId
- startX = lastX = event.clientX
- startY = event.clientY
- lastTime = event.timeStamp
- captured = false
- beginPromise = null
- ending = false
- bufferedProgress = 0
- bufferedVelocity = 0
- gestureSign = 0
- }
+ if (
+ !event.isPrimary ||
+ event.button !== 0 ||
+ shouldIgnoreGesture(event.target)
+ )
+ return;
+ pointerId = event.pointerId;
+ startX = lastX = event.clientX;
+ startY = event.clientY;
+ lastTime = event.timeStamp;
+ captured = false;
+ beginPromise = null;
+ ending = false;
+ bufferedProgress = 0;
+ bufferedVelocity = 0;
+ gestureSign = 0;
+ };
const move = async (event: PointerEvent) => {
- if (event.pointerId !== pointerId) return
- const dx = event.clientX - startX
- const dy = event.clientY - startY
+ if (event.pointerId !== pointerId) return;
+ const dx = event.clientX - startX;
+ const dy = event.clientY - startY;
if (!captured) {
- if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return
- const direction = directionForDelta(dx)
- if (!direction) return reset()
- captured = true
- ending = false
- gestureSign = Math.sign(dx)
- element()?.setPointerCapture(pointerId)
- beginPromise = begin(direction)
+ if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return;
+ const direction = directionForDelta(dx);
+ if (!direction) return reset();
+ captured = true;
+ ending = false;
+ gestureSign = Math.sign(dx);
+ element()?.setPointerCapture(pointerId);
+ beginPromise = begin(direction);
}
- event.preventDefault()
- const elapsed = Math.max(8, event.timeStamp - lastTime)
- const width = Math.max(1, element()?.clientWidth ?? window.innerWidth)
- bufferedVelocity = ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width)
- bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width))
- lastX = event.clientX
- lastTime = event.timeStamp
- const pending = beginPromise
+ event.preventDefault();
+ const elapsed = Math.max(8, event.timeStamp - lastTime);
+ const width = Math.max(1, element()?.clientWidth ?? window.innerWidth);
+ bufferedVelocity =
+ ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width);
+ bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width));
+ lastX = event.clientX;
+ lastTime = event.timeStamp;
+ const pending = beginPromise;
if (pending) {
- const id = await pending
- if (pending !== beginPromise || ending) return
- if (id === null) return reset()
- const runtime = injectRuntimeFromElement(element())
- runtime?.updateInteractive(bufferedProgress, bufferedVelocity)
+ const id = await pending;
+ if (pending !== beginPromise || ending) return;
+ if (id === null) return reset();
+ const runtime = injectRuntimeFromElement(element());
+ runtime?.updateInteractive(bufferedProgress, bufferedVelocity);
}
- }
+ };
const up = async (event: PointerEvent) => {
- if (event.pointerId !== pointerId) return
- ending = true
- const runtime = injectRuntimeFromElement(element())
- const pending = beginPromise
- const shouldFinish = captured
- const progress = bufferedProgress
- const velocity = bufferedVelocity
+ if (event.pointerId !== pointerId) return;
+ ending = true;
+ const runtime = injectRuntimeFromElement(element());
+ const pending = beginPromise;
+ const shouldFinish = captured;
+ const progress = bufferedProgress;
+ const velocity = bufferedVelocity;
// Detach this pointer before awaiting preload/navigation/animation work. A
// new gesture may now start without this release callback erasing it.
- reset()
- const id = pending ? await pending : null
+ reset();
+ const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
- runtime.updateInteractive(progress, velocity)
- await runtime.finishInteractive()
+ runtime.updateInteractive(progress, velocity);
+ await runtime.finishInteractive();
}
- }
+ };
const cancel = async () => {
- ending = true
- const runtime = injectRuntimeFromElement(element())
- const pending = beginPromise
- const shouldCancel = captured
- reset()
- const id = pending ? await pending : null
- if (shouldCancel && id !== null && runtime?.transaction.value?.id === id) await runtime.cancelInteractive()
- }
+ ending = true;
+ const runtime = injectRuntimeFromElement(element());
+ const pending = beginPromise;
+ const shouldCancel = captured;
+ reset();
+ const id = pending ? await pending : null;
+ if (shouldCancel && id !== null && runtime?.transaction.value?.id === id)
+ await runtime.cancelInteractive();
+ };
const reset = () => {
- pointerId = -1
- captured = false
- beginPromise = null
- ending = false
- bufferedProgress = 0
- bufferedVelocity = 0
- gestureSign = 0
- }
- return { down, move, up, cancel }
+ pointerId = -1;
+ captured = false;
+ beginPromise = null;
+ ending = false;
+ bufferedProgress = 0;
+ bufferedVelocity = 0;
+ gestureSign = 0;
+ };
+ return { down, move, up, cancel };
}
-const runtimeByElement = new WeakMap()
+const runtimeByElement = new WeakMap();
function injectRuntimeFromElement(element: Element | null) {
- return element ? runtimeByElement.get(element) : undefined
+ return element ? runtimeByElement.get(element) : undefined;
}
export const NativeGestureLink = defineComponent({
- name: 'NativeGestureLink',
+ name: "NativeGestureLink",
inheritAttrs: false,
props: {
- to: { type: [String, Object] as PropType, required: true },
- presentation: { type: String, default: 'reveal' },
+ to: {
+ type: [String, Object] as PropType,
+ required: true,
+ },
+ presentation: { type: String, default: "reveal" },
replace: Boolean,
- direction: { type: String as PropType<'left' | 'right' | 'any'>, default: 'any' },
- as: { type: String, default: 'div' },
+ direction: {
+ type: String as PropType<"left" | "right" | "any">,
+ default: "any",
+ },
+ as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
- const runtime = useNativeRouter()
- const root = ref(null)
- let dragDistance = 0
- let suppressClick = false
+ const runtime = useNativeRouter();
+ const root = ref(null);
+ let dragDistance = 0;
+ let suppressClick = false;
const gesture = createPointerGesture(
() => root.value,
- async (direction) => runtime.beginInteractive('push', props.to, {
- presentation: props.presentation,
- replace: props.replace,
- direction,
- sourceRect: root.value ? sourceRect(root.value) : undefined,
- }),
+ async (direction) =>
+ runtime.beginInteractive("push", props.to, {
+ presentation: props.presentation,
+ replace: props.replace,
+ direction,
+ sourceRect: root.value ? sourceRect(root.value) : undefined,
+ }),
(dx) => {
- if (props.direction === 'left' && dx >= 0) return null
- if (props.direction === 'right' && dx <= 0) return null
- return dx < 0 ? 'forward' : 'back'
+ if (props.direction === "left" && dx >= 0) return null;
+ if (props.direction === "right" && dx <= 0) return null;
+ return dx < 0 ? "forward" : "back";
},
- )
- onBeforeUnmount(() => void runtime.cancelInteractive())
+ );
+ onBeforeUnmount(() => void runtime.cancelInteractive());
const click = (event: MouseEvent) => {
if (suppressClick) {
- suppressClick = false
- event.preventDefault()
- event.stopPropagation()
- return
+ suppressClick = false;
+ event.preventDefault();
+ event.stopPropagation();
+ return;
}
- void runtime.push(props.to, { presentation: props.presentation, replace: props.replace })
- }
- return () => h(props.as, {
- ...attrs,
- ref: (value: unknown) => {
- root.value = value as HTMLElement | null
- if (root.value) runtimeByElement.set(root.value, runtime)
- },
- class: ['nvr-gesture-link', attrs.class],
- onPointerdown: (event: PointerEvent) => {
- // Component-owned gestures outrank their containing navigator.
- event.stopPropagation()
- dragDistance = 0
- suppressClick = false
- gesture.down(event)
- },
- onPointermove: (event: PointerEvent) => {
- if (event.buttons) {
- dragDistance += Math.abs(event.movementX)
- if (dragDistance > 8) suppressClick = true
- }
- void gesture.move(event)
- },
- onPointerup: gesture.up,
- onPointercancel: gesture.cancel,
- onClick: click,
- }, slots.default?.())
+ void runtime.push(props.to, {
+ presentation: props.presentation,
+ replace: props.replace,
+ });
+ };
+ return () =>
+ h(
+ props.as,
+ {
+ ...attrs,
+ ref: (value: unknown) => {
+ root.value = value as HTMLElement | null;
+ if (root.value) runtimeByElement.set(root.value, runtime);
+ },
+ class: ["nvr-gesture-link", attrs.class],
+ onPointerdown: (event: PointerEvent) => {
+ // Component-owned gestures outrank their containing navigator.
+ event.stopPropagation();
+ dragDistance = 0;
+ suppressClick = false;
+ gesture.down(event);
+ },
+ onPointermove: (event: PointerEvent) => {
+ if (event.buttons) {
+ dragDistance += Math.abs(event.movementX);
+ if (dragDistance > 8) suppressClick = true;
+ }
+ void gesture.move(event);
+ },
+ onPointerup: gesture.up,
+ onPointercancel: gesture.cancel,
+ onClick: click,
+ },
+ slots.default?.(),
+ );
},
-})
+});
export const NativeNavigator = defineComponent({
- name: 'NativeNavigator',
+ name: "NativeNavigator",
props: {
- siblings: { type: Array as PropType, default: () => [] },
+ siblings: {
+ type: Array as PropType,
+ default: () => [],
+ },
edgeWidth: { type: Number, default: 28 },
},
setup(props, { slots }) {
- const runtime = useNativeRouter()
- const root = ref(null)
- let candidate: 'back' | 'sibling' | null = null
+ const runtime = useNativeRouter();
+ const root = ref(null);
+ let candidate: "back" | "sibling" | null = null;
const gesture = createPointerGesture(
() => root.value,
async (direction) => {
- if (candidate === 'back') return runtime.beginInteractive('pop')
- const current = runtime.router.currentRoute.value.fullPath
- const index = props.siblings.findIndex((route) => runtime.router.resolve(route).fullPath === current)
- const nextIndex = index + (direction === 'forward' ? 1 : -1)
- const target = props.siblings[nextIndex]
- if (index < 0 || !target) return null
- return runtime.beginInteractive('sibling', target, {
- replace: runtime.router.resolve(target).meta.native?.siblingHistory !== 'push',
+ if (candidate === "back") return runtime.beginInteractive("pop");
+ const current = runtime.router.currentRoute.value.fullPath;
+ const index = props.siblings.findIndex(
+ (route) => runtime.router.resolve(route).fullPath === current,
+ );
+ const nextIndex = index + (direction === "forward" ? 1 : -1);
+ const target = props.siblings[nextIndex];
+ if (index < 0 || !target) return null;
+ return runtime.beginInteractive("sibling", target, {
+ replace:
+ runtime.router.resolve(target).meta.native?.siblingHistory !==
+ "push",
direction,
- presentation: 'slide',
- })
+ presentation: "slide",
+ });
},
(dx) => {
- const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl'
- const logicalDx = rtl ? -dx : dx
- if (candidate === 'back') return logicalDx > 0 ? 'back' : null
- return logicalDx < 0 ? 'forward' : 'back'
+ const rtl =
+ getComputedStyle(root.value ?? document.documentElement).direction ===
+ "rtl";
+ const logicalDx = rtl ? -dx : dx;
+ if (candidate === "back") return logicalDx > 0 ? "back" : null;
+ return logicalDx < 0 ? "forward" : "back";
},
- )
- const gestureSettingAllowsNavigation = () => runtime.router.currentRoute.value.meta.native?.gesture !== false
+ );
+ const gestureSettingAllowsNavigation = () =>
+ runtime.router.currentRoute.value.meta.native?.gesture !== false;
const atLeadingEdge = (event: PointerEvent) => {
- const rect = root.value?.getBoundingClientRect()
- const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl'
+ const rect = root.value?.getBoundingClientRect();
+ const rtl =
+ getComputedStyle(root.value ?? document.documentElement).direction ===
+ "rtl";
return rect
- ? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= props.edgeWidth
- : false
- }
+ ? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <=
+ props.edgeWidth
+ : false;
+ };
const captureDown = (event: PointerEvent) => {
- if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
- if (!atLeadingEdge(event) || !gestureSettingAllowsNavigation() || !runtime.canGoBack.value) return
- candidate = 'back'
- gesture.down(event)
+ if (
+ !event.isPrimary ||
+ event.button !== 0 ||
+ shouldIgnoreGesture(event.target)
+ )
+ return;
+ if (
+ !atLeadingEdge(event) ||
+ !gestureSettingAllowsNavigation() ||
+ !runtime.canGoBack.value
+ )
+ return;
+ candidate = "back";
+ gesture.down(event);
// The application shell owns the physical back edge. Component-owned
// route gestures retain priority everywhere else.
- event.stopPropagation()
- }
+ event.stopPropagation();
+ };
const down = (event: PointerEvent) => {
- candidate = props.siblings.length && gestureSettingAllowsNavigation() ? 'sibling' : null
- if (candidate) gesture.down(event)
- }
- return () => h('div', {
- ref: (value: unknown) => {
- root.value = value as HTMLElement | null
- if (root.value) runtimeByElement.set(root.value, runtime)
- },
- class: 'nvr-navigator',
- 'data-native-can-go-back': String(runtime.canGoBack.value),
- onPointerdownCapture: captureDown,
- onPointerdown: down,
- onPointermove: gesture.move,
- onPointerup: gesture.up,
- onPointercancel: gesture.cancel,
- }, slots.default?.())
+ candidate =
+ props.siblings.length && gestureSettingAllowsNavigation()
+ ? "sibling"
+ : null;
+ if (candidate) gesture.down(event);
+ };
+ return () =>
+ h(
+ "div",
+ {
+ ref: (value: unknown) => {
+ root.value = value as HTMLElement | null;
+ if (root.value) runtimeByElement.set(root.value, runtime);
+ },
+ class: "nvr-navigator",
+ "data-native-can-go-back": String(runtime.canGoBack.value),
+ onPointerdownCapture: captureDown,
+ onPointerdown: down,
+ onPointermove: gesture.move,
+ onPointerup: gesture.up,
+ onPointercancel: gesture.cancel,
+ },
+ slots.default?.(),
+ );
},
-})
+});
export const NativeDismissGesture = defineComponent({
- name: 'NativeDismissGesture',
+ name: "NativeDismissGesture",
inheritAttrs: false,
props: {
- as: { type: String, default: 'div' },
+ as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
- const runtime = useNativeRouter()
- const root = ref(null)
- let pointerId = -1
- let startX = 0
- let startY = 0
- let lastY = 0
- let lastTime = 0
- let captured = false
- let beginPromise: Promise | null = null
- let ending = false
- let progress = 0
- let velocity = 0
+ const runtime = useNativeRouter();
+ const root = ref(null);
+ let pointerId = -1;
+ let startX = 0;
+ let startY = 0;
+ let lastY = 0;
+ let lastTime = 0;
+ let captured = false;
+ let beginPromise: Promise | null = null;
+ let ending = false;
+ let progress = 0;
+ let velocity = 0;
const reset = () => {
- pointerId = -1
- captured = false
- beginPromise = null
- ending = false
- progress = 0
- velocity = 0
- }
+ pointerId = -1;
+ captured = false;
+ beginPromise = null;
+ ending = false;
+ progress = 0;
+ velocity = 0;
+ };
const down = (event: PointerEvent) => {
- if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
- event.stopPropagation()
- pointerId = event.pointerId
- startX = event.clientX
- startY = lastY = event.clientY
- lastTime = event.timeStamp
- captured = false
- beginPromise = null
- ending = false
- progress = 0
- velocity = 0
- }
+ if (
+ !event.isPrimary ||
+ event.button !== 0 ||
+ shouldIgnoreGesture(event.target)
+ )
+ return;
+ event.stopPropagation();
+ pointerId = event.pointerId;
+ startX = event.clientX;
+ startY = lastY = event.clientY;
+ lastTime = event.timeStamp;
+ captured = false;
+ beginPromise = null;
+ ending = false;
+ progress = 0;
+ velocity = 0;
+ };
const move = async (event: PointerEvent) => {
- if (event.pointerId !== pointerId) return
- const dx = event.clientX - startX
- const dy = event.clientY - startY
+ if (event.pointerId !== pointerId) return;
+ const dx = event.clientX - startX;
+ const dy = event.clientY - startY;
if (!captured) {
- if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return
- captured = true
- ending = false
- root.value?.setPointerCapture(pointerId)
- beginPromise = runtime.beginInteractive('dismiss', undefined, { direction: 'back' })
+ if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return;
+ captured = true;
+ ending = false;
+ root.value?.setPointerCapture(pointerId);
+ beginPromise = runtime.beginInteractive("dismiss", undefined, {
+ direction: "back",
+ });
}
- event.preventDefault()
- progress = Math.max(0, Math.min(1, dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight)))
- const height = Math.max(1, root.value?.clientHeight ?? window.innerHeight)
- velocity = ((event.clientY - lastY) * 1000) / (Math.max(8, event.timeStamp - lastTime) * height)
- lastY = event.clientY
- lastTime = event.timeStamp
- const pending = beginPromise
+ event.preventDefault();
+ progress = Math.max(
+ 0,
+ Math.min(
+ 1,
+ dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight),
+ ),
+ );
+ const height = Math.max(
+ 1,
+ root.value?.clientHeight ?? window.innerHeight,
+ );
+ velocity =
+ ((event.clientY - lastY) * 1000) /
+ (Math.max(8, event.timeStamp - lastTime) * height);
+ lastY = event.clientY;
+ lastTime = event.timeStamp;
+ const pending = beginPromise;
if (pending) {
- const id = await pending
- if (pending !== beginPromise || ending) return
- if (id === null) return reset()
- runtime.updateInteractive(progress, velocity)
+ const id = await pending;
+ if (pending !== beginPromise || ending) return;
+ if (id === null) return reset();
+ runtime.updateInteractive(progress, velocity);
}
- }
+ };
const up = async (event: PointerEvent) => {
- if (event.pointerId !== pointerId) return
- ending = true
- const pending = beginPromise
- const shouldFinish = captured
- const finalProgress = progress
- const finalVelocity = velocity
- reset()
- const id = pending ? await pending : null
+ if (event.pointerId !== pointerId) return;
+ ending = true;
+ const pending = beginPromise;
+ const shouldFinish = captured;
+ const finalProgress = progress;
+ const finalVelocity = velocity;
+ reset();
+ const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
- runtime.updateInteractive(finalProgress, finalVelocity)
- await runtime.finishInteractive()
+ runtime.updateInteractive(finalProgress, finalVelocity);
+ await runtime.finishInteractive();
}
- }
+ };
const cancel = async () => {
- ending = true
- const pending = beginPromise
- const shouldCancel = captured
- reset()
- const id = pending ? await pending : null
- if (shouldCancel && id !== null && runtime.transaction.value?.id === id) await runtime.cancelInteractive()
- }
- onBeforeUnmount(() => void cancel())
- return () => h(props.as, {
- ...attrs,
- ref: (value: unknown) => { root.value = value as HTMLElement | null },
- class: ['nvr-dismiss-gesture', attrs.class],
- onPointerdown: down,
- onPointermove: move,
- onPointerup: up,
- onPointercancel: cancel,
- }, slots.default?.())
+ ending = true;
+ const pending = beginPromise;
+ const shouldCancel = captured;
+ reset();
+ const id = pending ? await pending : null;
+ if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
+ await runtime.cancelInteractive();
+ };
+ onBeforeUnmount(() => void cancel());
+ return () =>
+ h(
+ props.as,
+ {
+ ...attrs,
+ ref: (value: unknown) => {
+ root.value = value as HTMLElement | null;
+ },
+ class: ["nvr-dismiss-gesture", attrs.class],
+ onPointerdown: down,
+ onPointermove: move,
+ onPointerup: up,
+ onPointercancel: cancel,
+ },
+ slots.default?.(),
+ );
},
-})
+});
-export function navigationOptionsFromElement(element: HTMLElement): NativeNavigationOptions {
- return { sourceRect: sourceRect(element) }
+export function navigationOptionsFromElement(
+ element: HTMLElement,
+): NativeNavigationOptions {
+ return { sourceRect: sourceRect(element) };
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6b7ab1a..b2fa91c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,11 +1,11 @@
-export * from './types'
-export * from './runtime'
-export * from './components'
-export * from './profiler'
-import './style.css'
+export * from "./types";
+export * from "./runtime";
+export * from "./components";
+export * from "./profiler";
+import "./style.css";
-declare module '@vue/runtime-core' {
+declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
- $nativeRouter: import('./types').NativeRouterRuntime
+ $nativeRouter: import("./types").NativeRouterRuntime;
}
}
diff --git a/packages/core/src/profiler.ts b/packages/core/src/profiler.ts
index 0b8ab12..0c02b33 100644
--- a/packages/core/src/profiler.ts
+++ b/packages/core/src/profiler.ts
@@ -1,111 +1,117 @@
-import { readonly, ref } from 'vue'
-import type { NativeDiagnosticEvent, NativeRouterRuntime } from './types'
+import { readonly, ref } from "vue";
+import type { NativeDiagnosticEvent, NativeRouterRuntime } from "./types";
export interface NativeProfilerOptions {
/** Extra non-sensitive identifiers such as an application build ID. */
- metadata?: Record
+ metadata?: Record;
/** Maximum rAF samples retained. Defaults to 30,000 (about eight minutes at 60 Hz). */
- maxFrames?: number
+ maxFrames?: number;
}
export interface NativeProfilerFrame {
- at: number
- delta: number
- route: string
- transactionId?: number
- phase?: string
- progress?: number
+ at: number;
+ delta: number;
+ route: string;
+ transactionId?: number;
+ phase?: string;
+ progress?: number;
}
export interface NativeProfilerPerformanceEntry {
- type: 'longtask' | 'layout-shift' | 'resource' | 'event'
- at: number
- duration: number
- name?: string
- value?: number
- size?: number
- hadRecentInput?: boolean
+ type: "longtask" | "layout-shift" | "resource" | "event";
+ at: number;
+ duration: number;
+ name?: string;
+ value?: number;
+ size?: number;
+ hadRecentInput?: boolean;
}
export interface NativeProfilerTransactionSummary {
- id: number
- route?: string
- kind?: string
- cold?: boolean
- outcome?: string
- duration?: number
- frames: number
- slowFrames: number
- p95FrameMs?: number
- maxFrameMs?: number
+ id: number;
+ route?: string;
+ kind?: string;
+ cold?: boolean;
+ outcome?: string;
+ duration?: number;
+ frames: number;
+ slowFrames: number;
+ p95FrameMs?: number;
+ maxFrameMs?: number;
}
export interface NativeProfilerReport {
- schema: 'native-vue-router-profile@1'
- startedAt: string
- duration: number
+ schema: "native-vue-router-profile@1";
+ startedAt: string;
+ duration: number;
environment: {
- userAgent: string
- viewport: { width: number; height: number; devicePixelRatio: number }
- displayMode: 'standalone' | 'browser'
- visibility: DocumentVisibilityState
- hardwareConcurrency?: number
- deviceMemory?: number
- }
- metadata: Record
+ userAgent: string;
+ viewport: { width: number; height: number; devicePixelRatio: number };
+ displayMode: "standalone" | "browser";
+ visibility: DocumentVisibilityState;
+ hardwareConcurrency?: number;
+ deviceMemory?: number;
+ };
+ metadata: Record;
summary: {
- frames: number
- estimatedRefreshMs: number
- estimatedRefreshHz: number
- averageFps: number
- p95FrameMs: number
- maxFrameMs: number
- framesOver20ms: number
- framesOver34ms: number
- framesOver50ms: number
- droppedFrames: number
- longTasks: number
- cumulativeLayoutShift: number
- }
- transactions: NativeProfilerTransactionSummary[]
- events: NativeDiagnosticEvent[]
- frames: NativeProfilerFrame[]
- performanceEntries: NativeProfilerPerformanceEntry[]
- visibility: Array<{ at: number; state: DocumentVisibilityState }>
+ frames: number;
+ estimatedRefreshMs: number;
+ estimatedRefreshHz: number;
+ averageFps: number;
+ p95FrameMs: number;
+ maxFrameMs: number;
+ framesOver20ms: number;
+ framesOver34ms: number;
+ framesOver50ms: number;
+ droppedFrames: number;
+ longTasks: number;
+ cumulativeLayoutShift: number;
+ };
+ transactions: NativeProfilerTransactionSummary[];
+ events: NativeDiagnosticEvent[];
+ frames: NativeProfilerFrame[];
+ performanceEntries: NativeProfilerPerformanceEntry[];
+ visibility: Array<{ at: number; state: DocumentVisibilityState }>;
}
export interface NativeNavigationProfiler {
- readonly recording: Readonly<{ value: boolean }>
- start(): void
- stop(): NativeProfilerReport
- snapshot(): NativeProfilerReport
- clear(): void
- toJSON(report?: NativeProfilerReport): string
- dispose(): void
+ readonly recording: Readonly<{ value: boolean }>;
+ start(): void;
+ stop(): NativeProfilerReport;
+ snapshot(): NativeProfilerReport;
+ clear(): void;
+ toJSON(report?: NativeProfilerReport): string;
+ dispose(): void;
}
function round(value: number, digits = 2) {
- const scale = 10 ** digits
- return Math.round(value * scale) / scale
+ const scale = 10 ** digits;
+ return Math.round(value * scale) / scale;
}
function percentile(values: number[], position: number) {
- if (!values.length) return 0
- const sorted = [...values].sort((a, b) => a - b)
- return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))] ?? 0
+ if (!values.length) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ return (
+ sorted[
+ Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))
+ ] ?? 0
+ );
}
function routeLabel(runtime: NativeRouterRuntime) {
- const route = runtime.router.currentRoute.value
- return route.name != null ? String(route.name) : route.matched.at(-1)?.path ?? route.path
+ const route = runtime.router.currentRoute.value;
+ return route.name != null
+ ? String(route.name)
+ : (route.matched.at(-1)?.path ?? route.path);
}
function resourceName(value: string) {
try {
- const url = new URL(value, window.location.href)
- return url.pathname.split('/').at(-1) || url.pathname
+ const url = new URL(value, window.location.href);
+ return url.pathname.split("/").at(-1) || url.pathname;
} 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,
options: NativeProfilerOptions = {},
): NativeNavigationProfiler {
- const mutableRecording = ref(false)
- const recording = readonly(mutableRecording)
- const maxFrames = Math.max(300, options.maxFrames ?? 30_000)
- let startedAt = 0
- let endedAt = 0
- let startedAtIso = ''
- let previousFrame: number | undefined
- let animationFrame = 0
- let frames: NativeProfilerFrame[] = []
- let events: NativeDiagnosticEvent[] = []
- let performanceEntries: NativeProfilerPerformanceEntry[] = []
- let visibility: Array<{ at: number; state: DocumentVisibilityState }> = []
- let observers: PerformanceObserver[] = []
+ const mutableRecording = ref(false);
+ const recording = readonly(mutableRecording);
+ const maxFrames = Math.max(300, options.maxFrames ?? 30_000);
+ let startedAt = 0;
+ let endedAt = 0;
+ let startedAtIso = "";
+ let previousFrame: number | undefined;
+ let animationFrame = 0;
+ let frames: NativeProfilerFrame[] = [];
+ let events: NativeDiagnosticEvent[] = [];
+ let performanceEntries: NativeProfilerPerformanceEntry[] = [];
+ let visibility: Array<{ at: number; state: DocumentVisibilityState }> = [];
+ 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) => {
- if (!mutableRecording.value) return
- const details = event.type === 'transaction-start' && typeof document !== 'undefined'
- ? {
- ...event.details,
- mountedViews: runtime.cacheStats.value.mounted,
- }
- : event.details
- events.push({ ...event, timestamp: relative(event.timestamp), details })
- })
+ if (!mutableRecording.value) return;
+ const details =
+ event.type === "transaction-start" && typeof document !== "undefined"
+ ? {
+ ...event.details,
+ mountedViews: runtime.cacheStats.value.mounted,
+ }
+ : event.details;
+ events.push({ ...event, timestamp: relative(event.timestamp), details });
+ });
const sampleFrame = (timestamp: number) => {
- if (!mutableRecording.value) return
+ if (!mutableRecording.value) return;
if (previousFrame !== undefined && frames.length < maxFrames) {
- const transaction = runtime.transaction.value
+ const transaction = runtime.transaction.value;
frames.push({
at: relative(timestamp),
delta: round(timestamp - previousFrame, 3),
@@ -155,98 +163,123 @@ export function createNativeNavigationProfiler(
transactionId: transaction?.id,
phase: transaction?.phase,
progress: transaction ? round(transaction.progress, 4) : undefined,
- })
+ });
}
- previousFrame = timestamp
- animationFrame = window.requestAnimationFrame(sampleFrame)
- }
+ previousFrame = timestamp;
+ animationFrame = window.requestAnimationFrame(sampleFrame);
+ };
- const observe = (type: NativeProfilerPerformanceEntry['type']) => {
- if (typeof PerformanceObserver === 'undefined') return
- if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return
+ const observe = (type: NativeProfilerPerformanceEntry["type"]) => {
+ if (typeof PerformanceObserver === "undefined") return;
+ if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return;
try {
const observer = new PerformanceObserver((list) => {
- if (!mutableRecording.value) return
+ if (!mutableRecording.value) return;
for (const entry of list.getEntries()) {
const extra = entry as PerformanceEntry & {
- value?: number
- hadRecentInput?: boolean
- transferSize?: number
- interactionId?: number
- }
+ value?: number;
+ hadRecentInput?: boolean;
+ transferSize?: number;
+ interactionId?: number;
+ };
performanceEntries.push({
type,
at: relative(entry.startTime),
duration: round(entry.duration, 3),
- name: type === 'resource'
- ? resourceName(entry.name)
- : type === 'event'
- ? entry.name
- : undefined,
+ name:
+ type === "resource"
+ ? resourceName(entry.name)
+ : type === "event"
+ ? entry.name
+ : undefined,
value: extra.value,
size: extra.transferSize,
hadRecentInput: extra.hadRecentInput,
- })
+ });
}
- })
- observer.observe(type === 'event'
- ? { type, buffered: false, durationThreshold: 16 } as PerformanceObserverInit
- : { type, buffered: false } as PerformanceObserverInit)
- observers.push(observer)
+ });
+ observer.observe(
+ type === "event"
+ ? ({
+ type,
+ buffered: false,
+ durationThreshold: 16,
+ } as PerformanceObserverInit)
+ : ({ type, buffered: false } as PerformanceObserverInit),
+ );
+ observers.push(observer);
} catch {
// Performance entry support differs between Safari, Chromium, and hosts.
}
- }
+ };
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 = () => {
- if (animationFrame) window.cancelAnimationFrame(animationFrame)
- animationFrame = 0
- for (const observer of observers) observer.disconnect()
- observers = []
- document.removeEventListener('visibilitychange', onVisibility)
- }
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
+ animationFrame = 0;
+ for (const observer of observers) observer.disconnect();
+ observers = [];
+ document.removeEventListener("visibilitychange", onVisibility);
+ };
const transactionSummaries = (baseline: number) => {
- const starts = new Map()
- const ends = new Map()
+ const starts = new Map();
+ const ends = new Map();
for (const event of events) {
- if (event.transactionId == null) continue
- if (event.type === 'transaction-start') starts.set(event.transactionId, event)
- if (event.type === 'transaction-end') ends.set(event.transactionId, event)
+ if (event.transactionId == null) continue;
+ if (event.type === "transaction-start")
+ starts.set(event.transactionId, event);
+ if (event.type === "transaction-end")
+ ends.set(event.transactionId, event);
}
return [...starts].map(([id, start]) => {
- const end = ends.get(id)
- const samples = frames.filter((frame) => frame.transactionId === id).map((frame) => frame.delta)
+ const end = ends.get(id);
+ const samples = frames
+ .filter((frame) => frame.transactionId === id)
+ .map((frame) => frame.delta);
return {
id,
route: start.route,
- kind: String(start.details?.kind ?? ''),
+ kind: String(start.details?.kind ?? ""),
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,
frames: samples.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,
- }
- })
- }
+ };
+ });
+ };
const snapshot = (): NativeProfilerReport => {
- const duration = startedAt ? (mutableRecording.value ? performance.now() : 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, .1) || 16.667)
- const totalFrameTime = deltas.reduce((sum, value) => sum + value, 0)
+ const duration = startedAt
+ ? (mutableRecording.value
+ ? performance.now()
+ : 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
- .filter((entry) => entry.type === 'layout-shift' && !entry.hadRecentInput)
- .reduce((sum, entry) => sum + (entry.value ?? 0), 0)
- const nav = navigator as Navigator & { deviceMemory?: number }
+ .filter((entry) => entry.type === "layout-shift" && !entry.hadRecentInput)
+ .reduce((sum, entry) => sum + (entry.value ?? 0), 0);
+ const nav = navigator as Navigator & { deviceMemory?: number };
return {
- schema: 'native-vue-router-profile@1',
+ schema: "native-vue-router-profile@1",
startedAt: startedAtIso || new Date().toISOString(),
duration: round(duration, 3),
environment: {
@@ -256,7 +289,9 @@ export function createNativeNavigationProfiler(
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
},
- displayMode: window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser',
+ displayMode: window.matchMedia("(display-mode: standalone)").matches
+ ? "standalone"
+ : "browser",
visibility: document.visibilityState,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: nav.deviceMemory,
@@ -266,14 +301,19 @@ export function createNativeNavigationProfiler(
frames: frames.length,
estimatedRefreshMs: round(baseline, 3),
estimatedRefreshHz: round(1000 / baseline, 1),
- averageFps: round(totalFrameTime ? deltas.length * 1000 / totalFrameTime : 0, 1),
- p95FrameMs: round(percentile(deltas, .95), 3),
+ averageFps: round(
+ totalFrameTime ? (deltas.length * 1000) / totalFrameTime : 0,
+ 1,
+ ),
+ p95FrameMs: round(percentile(deltas, 0.95), 3),
maxFrameMs: round(deltas.length ? Math.max(...deltas) : 0, 3),
framesOver20ms: deltas.filter((delta) => delta > 20).length,
framesOver34ms: deltas.filter((delta) => delta > 34).length,
framesOver50ms: deltas.filter((delta) => delta > 50).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),
},
transactions: transactionSummaries(baseline),
@@ -281,40 +321,40 @@ export function createNativeNavigationProfiler(
frames: [...frames],
performanceEntries: [...performanceEntries],
visibility: [...visibility],
- }
- }
+ };
+ };
const clear = () => {
- frames = []
- events = []
- performanceEntries = []
- visibility = []
- previousFrame = undefined
- endedAt = 0
- }
+ frames = [];
+ events = [];
+ performanceEntries = [];
+ visibility = [];
+ previousFrame = undefined;
+ endedAt = 0;
+ };
const start = () => {
- if (mutableRecording.value) return
- clear()
- startedAt = performance.now()
- endedAt = 0
- startedAtIso = new Date().toISOString()
- mutableRecording.value = true
- visibility.push({ at: 0, state: document.visibilityState })
- document.addEventListener('visibilitychange', onVisibility)
- observe('longtask')
- observe('layout-shift')
- observe('resource')
- observe('event')
- animationFrame = window.requestAnimationFrame(sampleFrame)
- }
+ if (mutableRecording.value) return;
+ clear();
+ startedAt = performance.now();
+ endedAt = 0;
+ startedAtIso = new Date().toISOString();
+ mutableRecording.value = true;
+ visibility.push({ at: 0, state: document.visibilityState });
+ document.addEventListener("visibilitychange", onVisibility);
+ observe("longtask");
+ observe("layout-shift");
+ observe("resource");
+ observe("event");
+ animationFrame = window.requestAnimationFrame(sampleFrame);
+ };
const stop = () => {
- endedAt = performance.now()
- mutableRecording.value = false
- stopSampling()
- return snapshot()
- }
+ endedAt = performance.now();
+ mutableRecording.value = false;
+ stopSampling();
+ return snapshot();
+ };
return {
recording,
@@ -324,9 +364,9 @@ export function createNativeNavigationProfiler(
clear,
toJSON: (report = snapshot()) => JSON.stringify(report, null, 2),
dispose() {
- mutableRecording.value = false
- stopSampling()
- removeDiagnostic()
+ mutableRecording.value = false;
+ stopSampling();
+ removeDiagnostic();
},
- }
+ };
}
diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts
index 0fe0a8f..998eb7c 100644
--- a/packages/core/src/runtime.test.ts
+++ b/packages/core/src/runtime.test.ts
@@ -1,372 +1,481 @@
-import { createApp, defineComponent, nextTick } from 'vue'
-import { createMemoryHistory, createRouter } from 'vue-router'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createApp, defineComponent, nextTick } from "vue";
+import { createMemoryHistory, createRouter } from "vue-router";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createNativeRouter,
definePresentation,
shouldCommitGesture,
springTimeScaleForVelocity,
-} from './runtime'
-import { createNativeNavigationProfiler } from './profiler'
+} from "./runtime";
+import { createNativeNavigationProfiler } from "./profiler";
-const Page = defineComponent({ template: 'page
' })
+const Page = defineComponent({ template: "page
" });
-async function harness(blockB: boolean | 'redirect' = false) {
+async function harness(blockB: boolean | "redirect" = false) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
- { path: '/a', component: Page },
- { path: '/b', 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: '/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' } } },
+ { path: "/a", component: Page },
+ { path: "/b", 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: "/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)
- await router.push('/a')
- await router.isReady()
- const native = createNativeRouter({ router, cache: { maxInactive: 2 } })
- const app = createApp(Page)
- app.use(router)
- app.use(native)
- await nextTick()
- return { router, native }
+ });
+ if (blockB)
+ router.beforeEach((to) =>
+ to.path === "/b" ? (blockB === "redirect" ? "/modal" : false) : undefined,
+ );
+ await router.push("/a");
+ await router.isReady();
+ const native = createNativeRouter({ router, cache: { maxInactive: 2 } });
+ const app = createApp(Page);
+ app.use(router);
+ app.use(native);
+ await nextTick();
+ return { router, native };
}
beforeEach(() => {
- vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList)
-})
+ vi.spyOn(window, "matchMedia").mockReturnValue({
+ matches: true,
+ } as MediaQueryList);
+});
-describe('gesture decisions', () => {
- it('uses progress or a deliberate velocity to commit', () => {
- expect(shouldCommitGesture(0.4, 0)).toBe(true)
- expect(shouldCommitGesture(0.12, 1.4)).toBe(true)
- expect(shouldCommitGesture(0.04, 4)).toBe(false)
- expect(shouldCommitGesture(0.2, 0.4)).toBe(false)
- })
+describe("gesture decisions", () => {
+ it("uses progress or a deliberate velocity to commit", () => {
+ expect(shouldCommitGesture(0.4, 0)).toBe(true);
+ expect(shouldCommitGesture(0.12, 1.4)).toBe(true);
+ expect(shouldCommitGesture(0.04, 4)).toBe(false);
+ expect(shouldCommitGesture(0.2, 0.4)).toBe(false);
+ });
- it('settles a fast flick more quickly without unbounded spring steps', () => {
- expect(springTimeScaleForVelocity(0)).toBe(1)
- expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6)
- expect(springTimeScaleForVelocity(8)).toBe(3)
- expect(springTimeScaleForVelocity(-20)).toBe(3)
- })
-})
+ it("settles a fast flick more quickly without unbounded spring steps", () => {
+ expect(springTimeScaleForVelocity(0)).toBe(1);
+ expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6);
+ expect(springTimeScaleForVelocity(8)).toBe(3);
+ expect(springTimeScaleForVelocity(-20)).toBe(3);
+ });
+});
-describe('native router transactions', () => {
- it('exports opt-in frame diagnostics without route params or query values', async () => {
- const { native } = await harness()
- const profiler = createNativeNavigationProfiler(native, { metadata: { build: 'test' } })
- profiler.start()
+describe("native router transactions", () => {
+ it("exports opt-in frame diagnostics without route params or query values", async () => {
+ const { native } = await harness();
+ const profiler = createNativeNavigationProfiler(native, {
+ metadata: { build: "test" },
+ });
+ profiler.start();
- await native.push('/item/private-id?token=secret')
- const report = profiler.stop()
+ await native.push("/item/private-id?token=secret");
+ const report = profiler.stop();
- expect(report.schema).toBe('native-vue-router-profile@1')
- expect(report.metadata).toEqual({ build: 'test' })
- expect(report.events.map((event) => event.type)).toEqual(expect.arrayContaining([
- 'route-load-start',
- 'route-load-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')
- profiler.dispose()
- })
+ expect(report.schema).toBe("native-vue-router-profile@1");
+ expect(report.metadata).toEqual({ build: "test" });
+ expect(report.events.map((event) => event.type)).toEqual(
+ expect.arrayContaining([
+ "route-load-start",
+ "route-load-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");
+ profiler.dispose();
+ });
- it('preloads a target without changing URL history', async () => {
- const { router, native } = await harness()
- const id = await native.beginInteractive('push', '/b')
- expect(id).not.toBeNull()
- expect(router.currentRoute.value.path).toBe('/a')
- expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(true)
- await native.cancelInteractive()
- expect(router.currentRoute.value.path).toBe('/a')
- expect(native.transaction.value).toBeNull()
- })
+ it("preloads a target without changing URL history", async () => {
+ const { router, native } = await harness();
+ const id = await native.beginInteractive("push", "/b");
+ expect(id).not.toBeNull();
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(
+ native.entries.value.some((entry) => entry.status === "preview"),
+ ).toBe(true);
+ await native.cancelInteractive();
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(native.transaction.value).toBeNull();
+ });
- it('commits a loaded preview through Vue Router', async () => {
- const { router, native } = await harness()
- await native.beginInteractive('push', '/b')
- native.updateInteractive(0.55, 0.1)
- expect(await native.finishInteractive()).toBe(true)
- expect(router.currentRoute.value.path).toBe('/b')
- expect(native.entries.value.filter((entry) => entry.status === 'active')).toHaveLength(1)
- })
+ it("commits a loaded preview through Vue Router", async () => {
+ const { router, native } = await harness();
+ await native.beginInteractive("push", "/b");
+ native.updateInteractive(0.55, 0.1);
+ expect(await native.finishInteractive()).toBe(true);
+ expect(router.currentRoute.value.path).toBe("/b");
+ expect(
+ native.entries.value.filter((entry) => entry.status === "active"),
+ ).toHaveLength(1);
+ });
- it('snaps back and removes the preview when a guard rejects commit', async () => {
- const { router, native } = await harness(true)
- await native.beginInteractive('push', '/b')
- native.updateInteractive(0.8, 0)
- expect(await native.finishInteractive()).toBe(false)
- expect(router.currentRoute.value.path).toBe('/a')
- expect(native.entries.value.some((entry) => entry.route.path === '/b')).toBe(false)
- })
+ it("snaps back and removes the preview when a guard rejects commit", async () => {
+ const { router, native } = await harness(true);
+ await native.beginInteractive("push", "/b");
+ native.updateInteractive(0.8, 0);
+ expect(await native.finishInteractive()).toBe(false);
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(
+ native.entries.value.some((entry) => entry.route.path === "/b"),
+ ).toBe(false);
+ });
- it('uses declared parents for cold-start predictive back', async () => {
- const { router, native } = await harness()
- await native.replace('/b', { presentation: 'none' })
- const transaction = await native.beginInteractive('pop')
- expect(transaction).not.toBeNull()
- expect(native.transaction.value?.direction).toBe('back')
- await native.cancelInteractive()
- expect(router.currentRoute.value.path).toBe('/b')
- })
+ it("uses declared parents for cold-start predictive back", async () => {
+ const { router, native } = await harness();
+ await native.replace("/b", { presentation: "none" });
+ const transaction = await native.beginInteractive("pop");
+ expect(transaction).not.toBeNull();
+ expect(native.transaction.value?.direction).toBe("back");
+ await native.cancelInteractive();
+ expect(router.currentRoute.value.path).toBe("/b");
+ });
- it('discards the stale preview when Vue Router redirects a commit', async () => {
- const { router, native } = await harness('redirect')
- await native.beginInteractive('push', '/b')
- expect(await native.finishInteractive(true)).toBe(true)
- expect(router.currentRoute.value.path).toBe('/modal')
- expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(false)
- })
+ it("discards the stale preview when Vue Router redirects a commit", async () => {
+ const { router, native } = await harness("redirect");
+ await native.beginInteractive("push", "/b");
+ expect(await native.finishInteractive(true)).toBe(true);
+ expect(router.currentRoute.value.path).toBe("/modal");
+ expect(
+ native.entries.value.some((entry) => entry.status === "preview"),
+ ).toBe(false);
+ });
- it('registers application-defined presentations', async () => {
- const { native } = await harness()
- const presentation = definePresentation({ name: 'flip', axis: 'x', layerStyle: () => ({ opacity: 0.5 }) })
- native.registerPresentation(presentation)
- expect(native.presentationFor('flip')).toBe(presentation)
- })
+ it("registers application-defined presentations", async () => {
+ const { native } = await harness();
+ const presentation = definePresentation({
+ name: "flip",
+ 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 () => {
- const { router, native } = await harness()
- const entries = [...native.entries.value]
- expect(await native.push('/a')).toBe(false)
- expect(await native.replace('/a')).toBe(false)
- expect(router.currentRoute.value.path).toBe('/a')
- expect(native.transaction.value).toBeNull()
- expect(native.entries.value).toEqual(entries)
- })
+ it("treats navigation to the active route as a strict no-op", async () => {
+ const { router, native } = await harness();
+ const entries = [...native.entries.value];
+ expect(await native.push("/a")).toBe(false);
+ expect(await native.replace("/a")).toBe(false);
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(native.transaction.value).toBeNull();
+ expect(native.entries.value).toEqual(entries);
+ });
- it('derives sibling direction from route order and uses adjacent-page motion', async () => {
- const { native } = await harness()
- await native.replace('/middle', { presentation: 'none' })
+ it("derives sibling direction from route order and uses adjacent-page motion", async () => {
+ const { native } = await harness();
+ await native.replace("/middle", { presentation: "none" });
- await native.beginInteractive('sibling', '/left', { replace: true })
- expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'slide' })
- await native.cancelInteractive()
+ await native.beginInteractive("sibling", "/left", { replace: true });
+ expect(native.transaction.value).toMatchObject({
+ direction: "back",
+ presentation: "slide",
+ });
+ await native.cancelInteractive();
- await native.beginInteractive('sibling', '/right', { replace: true })
- expect(native.transaction.value).toMatchObject({ direction: 'forward', presentation: 'slide' })
- await native.cancelInteractive()
- })
+ await native.beginInteractive("sibling", "/right", { replace: true });
+ expect(native.transaction.value).toMatchObject({
+ direction: "forward",
+ presentation: "slide",
+ });
+ await native.cancelInteractive();
+ });
- it('keeps replaced sibling views cached but out of the back stack', async () => {
- const { native } = await harness()
- await native.replace('/left', { presentation: 'none' })
- await native.sibling('/middle', { replace: true })
- await native.push('/c')
+ it("keeps replaced sibling views cached but out of the back stack", async () => {
+ const { native } = await harness();
+ await native.replace("/left", { presentation: "none" });
+ await native.sibling("/middle", { replace: true });
+ await native.push("/c");
- await native.beginInteractive('pop')
- const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
- expect(target?.route.path).toBe('/middle')
- expect(target?.route.path).not.toBe('/left')
- await native.cancelInteractive()
- })
+ await native.beginInteractive("pop");
+ const target = native.entries.value.find(
+ (entry) => entry.key === native.transaction.value?.toKey,
+ );
+ 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 () => {
- const { native } = await harness()
- expect(native.entries.value.map((entry) => entry.route.path)).toEqual(['/a'])
+ it("creates sibling views lazily and retains visited replace-style siblings", async () => {
+ const { native } = await harness();
+ expect(native.entries.value.map((entry) => entry.route.path)).toEqual([
+ "/a",
+ ]);
- await native.replace('/left', { presentation: 'none' })
- expect(native.entries.value.some((entry) => entry.route.path === '/middle')).toBe(false)
- await native.sibling('/middle', { replace: true })
+ await native.replace("/left", { presentation: "none" });
+ expect(
+ 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(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)
- })
+ expect(
+ native.entries.value.find((entry) => entry.route.path === "/left"),
+ ).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 () => {
- vi.mocked(window.matchMedia).mockReturnValue({ matches: false } as MediaQueryList)
- let paint: FrameRequestCallback | undefined
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- paint = callback
- return 1
- })
- const { native } = await harness()
+ it("prepares a newly mounted destination for a paint before animation can begin", async () => {
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: false,
+ } as MediaQueryList);
+ let paint: FrameRequestCallback | undefined;
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
+ paint = callback;
+ return 1;
+ });
+ const { native } = await harness();
- let resolved = false
- const beginning = native.beginInteractive('push', '/b').then((id) => {
- resolved = true
- return id
- })
- await vi.waitFor(() => expect(paint).toBeTypeOf('function'))
+ let resolved = false;
+ const beginning = native.beginInteractive("push", "/b").then((id) => {
+ resolved = true;
+ return id;
+ });
+ await vi.waitFor(() => expect(paint).toBeTypeOf("function"));
- expect(resolved).toBe(false)
- expect(native.entries.value.find((entry) => entry.route.path === '/b')?.mounted).toBe(true)
- paint?.(performance.now())
- expect(await beginning).not.toBeNull()
- vi.mocked(window.matchMedia).mockReturnValue({ matches: true } as MediaQueryList)
- await native.cancelInteractive()
- })
+ expect(resolved).toBe(false);
+ expect(
+ native.entries.value.find((entry) => entry.route.path === "/b")?.mounted,
+ ).toBe(true);
+ paint?.(performance.now());
+ 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 () => {
- const { router, native } = await harness()
- await native.push('/b')
- expect(native.canGoBack.value).toBe(true)
+ it("collapses pushed history when a tab replaces it with an existing root", async () => {
+ const { router, native } = await harness();
+ await native.push("/b");
+ 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(native.canGoBack.value).toBe(false)
- expect(await native.beginInteractive('pop')).toBeNull()
- })
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(native.canGoBack.value).toBe(false);
+ expect(await native.beginInteractive("pop")).toBeNull();
+ });
- it('manually unloads inactive route instances but never the active view', async () => {
- const { native } = await harness()
- await native.replace('/left', { presentation: 'none' })
- await native.sibling('/middle', { replace: true })
+ it("manually unloads inactive route instances but never the active view", async () => {
+ const { native } = await harness();
+ await native.replace("/left", { presentation: "none" });
+ await native.sibling("/middle", { replace: true });
- expect(native.unload('/left')).toBe(1)
- expect(native.entries.value.find((entry) => entry.route.path === '/left')).toMatchObject({
+ expect(native.unload("/left")).toBe(1);
+ expect(
+ native.entries.value.find((entry) => entry.route.path === "/left"),
+ ).toMatchObject({
mounted: false,
- evictionReason: 'manual',
- })
- expect(native.unload('/middle')).toBe(0)
- expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true)
- })
+ evictionReason: "manual",
+ });
+ expect(native.unload("/middle")).toBe(0);
+ 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 () => {
- let clock = 0
- vi.spyOn(performance, 'now').mockImplementation(() => ++clock)
- const { native } = await harness()
- await native.replace('/left', { presentation: 'none' })
- await native.sibling('/middle', { replace: true })
- await native.sibling('/right', { replace: true })
- await native.sibling('/a', { replace: true })
+ it("evicts the least-recently-used inactive view when the cache limit is exceeded", async () => {
+ let clock = 0;
+ vi.spyOn(performance, "now").mockImplementation(() => ++clock);
+ const { native } = await harness();
+ await native.replace("/left", { presentation: "none" });
+ await native.sibling("/middle", { replace: true });
+ await native.sibling("/right", { 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,
- evictionReason: 'cache-limit',
- })
- expect(native.entries.value.find((entry) => entry.route.path === '/middle')?.mounted).toBe(true)
- expect(native.entries.value.find((entry) => entry.route.path === '/right')?.mounted).toBe(true)
- expect(native.cacheStats.value.inactive).toBe(2)
- })
+ evictionReason: "cache-limit",
+ });
+ expect(
+ native.entries.value.find((entry) => entry.route.path === "/middle")
+ ?.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 () => {
- const { native } = await harness()
- await native.push('/b')
- const pushedKey = native.activeKey.value
- await native.pop()
+ it("evicts a pushed route after it is popped out of history", async () => {
+ const { native } = await harness();
+ await native.push("/b");
+ const pushedKey = native.activeKey.value;
+ 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,
- status: 'evicted',
- evictionReason: 'popped',
- })
- })
+ status: "evicted",
+ evictionReason: "popped",
+ });
+ });
- it('honors cache opt-out even for a route that remains in back history', async () => {
- const { native } = await harness()
- await native.push('/no-cache')
- const noCacheKey = native.activeKey.value
- await native.push('/c')
+ it("honors cache opt-out even for a route that remains in back history", async () => {
+ const { native } = await harness();
+ await native.push("/no-cache");
+ const noCacheKey = native.activeKey.value;
+ 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,
- evictionReason: 'cache-disabled',
- })
- expect(await native.beginInteractive('pop')).not.toBeNull()
- expect(native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted).toBe(true)
- await native.cancelInteractive()
- })
+ evictionReason: "cache-disabled",
+ });
+ expect(await native.beginInteractive("pop")).not.toBeNull();
+ expect(
+ 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 () => {
- const { native } = await harness()
- await native.replace('/pinned', { presentation: 'none' })
- const pinnedKey = native.activeKey.value
- await native.push('/c')
+ it("keeps pinned views during normal trims and releases them when requested", async () => {
+ const { native } = await harness();
+ await native.replace("/pinned", { presentation: "none" });
+ const pinnedKey = native.activeKey.value;
+ await native.push("/c");
- native.trimCache()
- expect(native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted).toBe(true)
- native.trimCache({ includePinned: true })
- expect(native.entries.value.find((entry) => entry.key === pinnedKey)).toMatchObject({
+ native.trimCache();
+ expect(
+ native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted,
+ ).toBe(true);
+ native.trimCache({ includePinned: true });
+ expect(
+ native.entries.value.find((entry) => entry.key === pinnedKey),
+ ).toMatchObject({
mounted: false,
- evictionReason: 'trimmed',
- })
- })
+ evictionReason: "trimmed",
+ });
+ });
- it('evicts a previously cached target when its guard rejects re-entry', async () => {
- const { router, native } = await harness()
- await native.replace('/left', { presentation: 'none' })
- await native.sibling('/middle', { replace: true })
- const cachedLeft = native.entries.value.find((entry) => entry.route.path === '/left')
- expect(cachedLeft?.mounted).toBe(true)
- const removeGuard = router.beforeEach((to) => to.path === '/left' ? false : undefined)
+ it("evicts a previously cached target when its guard rejects re-entry", async () => {
+ const { router, native } = await harness();
+ await native.replace("/left", { presentation: "none" });
+ await native.sibling("/middle", { replace: true });
+ const cachedLeft = native.entries.value.find(
+ (entry) => entry.route.path === "/left",
+ );
+ 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(native.entries.value.find((entry) => entry.key === cachedLeft?.key)).toMatchObject({
+ expect(await native.sibling("/left", { replace: true })).toBe(false);
+ expect(
+ native.entries.value.find((entry) => entry.key === cachedLeft?.key),
+ ).toMatchObject({
mounted: false,
- evictionReason: 'navigation-rejected',
- })
- expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0)
- removeGuard()
- })
+ evictionReason: "navigation-rejected",
+ });
+ expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0);
+ removeGuard();
+ });
- it('does not preview a stale forward entry after pop then push', async () => {
- const { native } = await harness()
- await native.push('/b')
- await native.pop()
- await native.push('/c')
+ it("does not preview a stale forward entry after pop then push", async () => {
+ const { native } = await harness();
+ await native.push("/b");
+ await native.pop();
+ await native.push("/c");
- await native.beginInteractive('pop')
- const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
- expect(target?.route.path).toBe('/a')
- await native.cancelInteractive()
- })
+ await native.beginInteractive("pop");
+ const target = native.entries.value.find(
+ (entry) => entry.key === native.transaction.value?.toKey,
+ );
+ expect(target?.route.path).toBe("/a");
+ await native.cancelInteractive();
+ });
- it('dismisses with the presented route animation regardless of the route below it', async () => {
- const { native } = await harness()
- await native.push('/b')
- await native.present('/modal', 'sheet')
+ it("dismisses with the presented route animation regardless of the route below it", async () => {
+ const { native } = await harness();
+ await native.push("/b");
+ await native.present("/modal", "sheet");
- await native.beginInteractive('dismiss')
- expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'sheet' })
- await native.cancelInteractive()
- })
+ await native.beginInteractive("dismiss");
+ expect(native.transaction.value).toMatchObject({
+ direction: "back",
+ presentation: "sheet",
+ });
+ await native.cancelInteractive();
+ });
- it('refuses to overlap a second transaction with an active gesture', async () => {
- const { native } = await harness()
- const first = await native.beginInteractive('push', '/b')
- expect(await native.beginInteractive('push', '/c')).toBeNull()
- expect(native.transaction.value?.id).toBe(first)
- await native.cancelInteractive()
- })
+ it("refuses to overlap a second transaction with an active gesture", async () => {
+ const { native } = await harness();
+ const first = await native.beginInteractive("push", "/b");
+ expect(await native.beginInteractive("push", "/c")).toBeNull();
+ expect(native.transaction.value?.id).toBe(first);
+ await native.cancelInteractive();
+ });
- it('accepts imperative navigation as the previous transition finalizes', async () => {
- const { router, native } = await harness()
- await native.beginInteractive('push', '/b')
- const finishing = native.finishInteractive(true)
- const queued = native.push('/c')
- expect(await finishing).toBe(true)
- expect(await queued).toBe(true)
- expect(router.currentRoute.value.path).toBe('/c')
- expect(native.transaction.value).toBeNull()
- })
+ it("accepts imperative navigation as the previous transition finalizes", async () => {
+ const { router, native } = await harness();
+ await native.beginInteractive("push", "/b");
+ const finishing = native.finishInteractive(true);
+ const queued = native.push("/c");
+ expect(await finishing).toBe(true);
+ expect(await queued).toBe(true);
+ expect(router.currentRoute.value.path).toBe("/c");
+ expect(native.transaction.value).toBeNull();
+ });
- it('reconciles direct browser back navigation with the native stack', async () => {
- const { router, native } = await harness()
- await native.push('/b')
+ it("reconciles direct browser back navigation with the native stack", async () => {
+ const { router, native } = await harness();
+ await native.push("/b");
const navigated = new Promise((resolve) => {
const remove = router.afterEach(() => {
- remove()
- resolve()
- })
- })
- router.back()
- await navigated
+ remove();
+ resolve();
+ });
+ });
+ router.back();
+ await navigated;
- expect(router.currentRoute.value.path).toBe('/a')
- expect(native.canGoBack.value).toBe(false)
- expect(await native.beginInteractive('pop')).toBeNull()
- })
-})
+ expect(router.currentRoute.value.path).toBe("/a");
+ expect(native.canGoBack.value).toBe(false);
+ expect(await native.beginInteractive("pop")).toBeNull();
+ });
+});
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index 4d3ad31..67a9544 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -5,7 +5,7 @@ import {
shallowRef,
type App,
type CSSProperties,
-} from 'vue'
+} from "vue";
import {
isNavigationFailure,
loadRouteLocation,
@@ -14,7 +14,7 @@ import {
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
type RouteLocationResolved,
-} from 'vue-router'
+} from "vue-router";
import type {
NativeDirection,
NativeDiagnosticEvent,
@@ -29,38 +29,48 @@ import type {
NativeRouterRuntime,
NativeTransaction,
NativeViewEntry,
-} from './types'
+} from "./types";
-export const nativeRouterKey = Symbol('native-vue-router')
+export const nativeRouterKey = Symbol("native-vue-router");
-let entrySequence = 0
+let entrySequence = 0;
function now() {
- return typeof performance === 'undefined' ? Date.now() : performance.now()
+ return typeof performance === "undefined" ? Date.now() : performance.now();
}
-function diagnosticRoute(route: RouteLocationNormalizedLoaded | RouteLocationResolved) {
+function diagnosticRoute(
+ route: RouteLocationNormalizedLoaded | RouteLocationResolved,
+) {
return route.name != null
? String(route.name)
- : route.matched.at(-1)?.path ?? route.path
+ : (route.matched.at(-1)?.path ?? route.path);
}
-function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry['status'], synthetic = false): NativeViewEntry {
+function entryFor(
+ route: RouteLocationNormalizedLoaded,
+ status: NativeViewEntry["status"],
+ synthetic = false,
+): NativeViewEntry {
return {
key: `${route.fullPath}::${++entrySequence}`,
route,
status,
mounted: true,
synthetic,
- committed: status !== 'preview',
+ committed: status !== "preview",
lastUsed: now(),
scrollX: 0,
scrollY: 0,
- }
+ };
}
-export function shouldCommitGesture(progress: number, velocity: number, threshold = 0.36) {
- return progress >= threshold || (progress >= 0.08 && velocity >= 1.1)
+export function shouldCommitGesture(
+ progress: number,
+ velocity: number,
+ threshold = 0.36,
+) {
+ return progress >= threshold || (progress >= 0.08 && velocity >= 1.1);
}
/**
@@ -69,155 +79,189 @@ export function shouldCommitGesture(progress: number, velocity: number, threshol
* three times faster while a stationary release keeps the baseline spring.
*/
export function springTimeScaleForVelocity(velocity: number) {
- return 1 + Math.min(2, Math.abs(velocity) * 0.3)
+ return 1 + Math.min(2, Math.abs(velocity) * 0.3);
}
export function definePresentation(definition: NativePresentationDefinition) {
- return definition
+ return definition;
}
const builtinPresentations: NativePresentationDefinition[] = [
- { name: 'push', axis: 'x' },
- { name: 'reveal', axis: 'x' },
- { name: 'slide', axis: 'x' },
- { name: 'fade', axis: 'x' },
- { name: 'modal', axis: 'y' },
- { name: 'sheet', axis: 'y' },
- { name: 'none', axis: 'x' },
-]
+ { name: "push", axis: "x" },
+ { name: "reveal", axis: "x" },
+ { name: "slide", axis: "x" },
+ { name: "fade", axis: "x" },
+ { name: "modal", axis: "y" },
+ { name: "sheet", axis: "y" },
+ { name: "none", axis: "x" },
+];
class NativeRouterRuntimeImpl implements NativeRouterRuntime {
- readonly router
- readonly entries
- readonly activeKey
- readonly transaction
- readonly canGoBack
- readonly cacheStats
+ readonly router;
+ readonly entries;
+ readonly activeKey;
+ readonly transaction;
+ readonly canGoBack;
+ readonly cacheStats;
- private readonly mutableEntries = shallowRef([])
- private readonly mutableActiveKey = ref('')
- private readonly mutableTransaction = shallowRef(null)
- private readonly mutableHistoryKeys = shallowRef([])
- private readonly maxInactive: number
- private readonly platform?: NativePlatformAdapter
- private readonly presentations = new Map()
- private transactionSequence = 0
- private beginAttemptSequence = 0
- private readonly pendingNavigations = new Map>()
- private removeAfterEach?: () => void
- private platformCleanup?: () => void
- private pendingPop?: (failure?: NavigationFailure | void) => void
- private totalEvictions = 0
- private lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
- private memoryPressureCleanup?: () => void
- private readonly diagnosticListeners = new Set<(event: NativeDiagnosticEvent) => void>()
+ private readonly mutableEntries = shallowRef([]);
+ private readonly mutableActiveKey = ref("");
+ private readonly mutableTransaction = shallowRef(
+ null,
+ );
+ private readonly mutableHistoryKeys = shallowRef([]);
+ private readonly maxInactive: number;
+ private readonly platform?: NativePlatformAdapter;
+ private readonly presentations = new Map<
+ NativePresentationName,
+ NativePresentationDefinition
+ >();
+ private transactionSequence = 0;
+ private beginAttemptSequence = 0;
+ private readonly pendingNavigations = new Map<
+ number,
+ Promise
+ >();
+ private removeAfterEach?: () => void;
+ private platformCleanup?: () => void;
+ private pendingPop?: (failure?: NavigationFailure | void) => void;
+ private totalEvictions = 0;
+ private lastEviction?: {
+ key: string;
+ route: string;
+ reason: NativeEvictionReason;
+ };
+ private memoryPressureCleanup?: () => void;
+ private readonly diagnosticListeners = new Set<
+ (event: NativeDiagnosticEvent) => void
+ >();
constructor(options: NativeRouterOptions) {
- this.router = options.router
- this.maxInactive = Math.max(0, options.cache?.maxInactive ?? 4)
- this.platform = options.platform
- this.entries = computed(() => this.mutableEntries.value)
- this.activeKey = computed(() => this.mutableActiveKey.value)
- this.transaction = computed(() => this.mutableTransaction.value)
+ this.router = options.router;
+ this.maxInactive = Math.max(0, options.cache?.maxInactive ?? 4);
+ this.platform = options.platform;
+ this.entries = computed(() => this.mutableEntries.value);
+ this.activeKey = computed(() => this.mutableActiveKey.value);
+ this.transaction = computed(() => this.mutableTransaction.value);
this.cacheStats = computed(() => {
- const entries = this.mutableEntries.value
- const mounted = entries.filter((entry) => entry.mounted)
- const inactive = mounted.filter((entry) => entry.key !== this.mutableActiveKey.value)
+ const entries = this.mutableEntries.value;
+ const mounted = entries.filter((entry) => entry.mounted);
+ const inactive = mounted.filter(
+ (entry) => entry.key !== this.mutableActiveKey.value,
+ );
return {
maxInactive: this.maxInactive,
descriptors: entries.length,
mounted: mounted.length,
inactive: inactive.length,
- pinned: inactive.filter((entry) => entry.route.meta.native?.cache === 'pin').length,
+ pinned: inactive.filter(
+ (entry) => entry.route.meta.native?.cache === "pin",
+ ).length,
evicted: entries.filter((entry) => !entry.mounted).length,
totalEvictions: this.totalEvictions,
lastEviction: this.lastEviction,
- }
- })
+ };
+ });
this.canGoBack = computed(() => {
- const transaction = this.mutableTransaction.value
- const committingForwardEntry = transaction
- && transaction.phase !== 'interactive'
- && transaction.kind !== 'pop'
- && transaction.kind !== 'dismiss'
- && !transaction.replace
- return Boolean(committingForwardEntry)
- || this.mutableHistoryKeys.value.length > 1
- || Boolean(this.activeEntry()?.route.meta.native?.parent)
- })
+ const transaction = this.mutableTransaction.value;
+ const committingForwardEntry =
+ transaction &&
+ transaction.phase !== "interactive" &&
+ transaction.kind !== "pop" &&
+ transaction.kind !== "dismiss" &&
+ !transaction.replace;
+ return (
+ Boolean(committingForwardEntry) ||
+ this.mutableHistoryKeys.value.length > 1 ||
+ Boolean(this.activeEntry()?.route.meta.native?.parent)
+ );
+ });
- for (const definition of builtinPresentations) this.registerPresentation(definition)
- for (const definition of options.presentations ?? []) this.registerPresentation(definition)
+ for (const definition of builtinPresentations)
+ this.registerPresentation(definition);
+ for (const definition of options.presentations ?? [])
+ this.registerPresentation(definition);
this.removeAfterEach = this.router.afterEach((to, from, failure) => {
- if (!failure) this.acceptRoute(to, from)
- this.pendingPop?.(failure)
- this.pendingPop = undefined
- })
+ if (!failure) this.acceptRoute(to, from);
+ this.pendingPop?.(failure);
+ this.pendingPop = undefined;
+ });
}
install(app: App) {
- app.provide(nativeRouterKey, this)
- app.config.globalProperties.$nativeRouter = this
+ app.provide(nativeRouterKey, this);
+ app.config.globalProperties.$nativeRouter = this;
void this.router.isReady().then(() => {
- if (this.mutableEntries.value.length === 0 && this.router.currentRoute.value !== START_LOCATION) {
- const initial = entryFor(this.router.currentRoute.value, 'active')
- this.mutableEntries.value = [initial]
- this.mutableActiveKey.value = initial.key
- this.mutableHistoryKeys.value = [initial.key]
+ if (
+ this.mutableEntries.value.length === 0 &&
+ this.router.currentRoute.value !== START_LOCATION
+ ) {
+ const initial = entryFor(this.router.currentRoute.value, "active");
+ this.mutableEntries.value = [initial];
+ this.mutableActiveKey.value = initial.key;
+ this.mutableHistoryKeys.value = [initial.key];
}
- })
+ });
if (this.platform?.install) {
void Promise.resolve(this.platform.install(this)).then((cleanup) => {
- if (cleanup) this.platformCleanup = cleanup
- })
+ if (cleanup) this.platformCleanup = cleanup;
+ });
}
- if (typeof window !== 'undefined') {
- const trimForPressure = () => this.trimCache({ reason: 'memory-pressure' })
- window.addEventListener('memorypressure', trimForPressure)
- this.memoryPressureCleanup = () => window.removeEventListener('memorypressure', trimForPressure)
+ if (typeof window !== "undefined") {
+ const trimForPressure = () =>
+ this.trimCache({ reason: "memory-pressure" });
+ window.addEventListener("memorypressure", trimForPressure);
+ this.memoryPressureCleanup = () =>
+ window.removeEventListener("memorypressure", trimForPressure);
}
}
async preload(to: RouteLocationRaw) {
- const resolved = this.router.resolve(to)
- return await this.loadResolvedRoute(resolved)
+ const resolved = this.router.resolve(to);
+ return await this.loadResolvedRoute(resolved);
}
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
- const id = await this.beginInteractive('push', to, options)
- if (id === null) return false
- return await this.finishInteractive(true)
+ const id = await this.beginInteractive("push", to, options);
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
- const id = await this.beginInteractive('push', to, { ...options, replace: true })
- if (id === null) return false
- return await this.finishInteractive(true)
+ const id = await this.beginInteractive("push", to, {
+ ...options,
+ replace: true,
+ });
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
- const id = await this.beginInteractive('sibling', to, options)
- if (id === null) return false
- return await this.finishInteractive(true)
+ const id = await this.beginInteractive("sibling", to, options);
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
async pop() {
- const id = await this.beginInteractive('pop')
- if (id === null) return false
- return await this.finishInteractive(true)
+ const id = await this.beginInteractive("pop");
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
- async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
- const id = await this.beginInteractive('present', to, { presentation })
- if (id === null) return false
- return await this.finishInteractive(true)
+ async present(
+ to: RouteLocationRaw,
+ presentation: NativePresentationName = "modal",
+ ) {
+ const id = await this.beginInteractive("present", to, { presentation });
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
async dismiss() {
- const id = await this.beginInteractive('dismiss')
- if (id === null) return false
- return await this.finishInteractive(true)
+ const id = await this.beginInteractive("dismiss");
+ if (id === null) return false;
+ return await this.finishInteractive(true);
}
async beginInteractive(
@@ -225,82 +269,115 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
to?: RouteLocationRaw,
options: NativeNavigationOptions = {},
) {
- const attempt = ++this.beginAttemptSequence
- const live = this.mutableTransaction.value
+ const attempt = ++this.beginAttemptSequence;
+ const live = this.mutableTransaction.value;
if (live) {
- if (live.phase === 'interactive') return null
- await this.interruptSettling(live)
- if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
+ if (live.phase === "interactive") return null;
+ await this.interruptSettling(live);
+ if (
+ attempt !== this.beginAttemptSequence ||
+ this.mutableTransaction.value
+ )
+ return null;
}
- const from = this.activeEntry()
- if (!from) return null
+ const from = this.activeEntry();
+ if (!from) return null;
- let target: NativeViewEntry | undefined
- let synthetic = false
- let needsMount = false
- const isBack = kind === 'pop' || kind === 'dismiss'
+ let target: NativeViewEntry | undefined;
+ let synthetic = false;
+ let needsMount = false;
+ const isBack = kind === "pop" || kind === "dismiss";
if (isBack) {
- const history = this.mutableHistoryKeys.value
- target = history.length > 1 ? this.entryByKey(history[history.length - 2]) : undefined
+ const history = this.mutableHistoryKeys.value;
+ target =
+ history.length > 1
+ ? this.entryByKey(history[history.length - 2])
+ : undefined;
if (!target) {
- const parent = from.route.meta.native?.parent
- if (!parent) return null
- const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
- const route = await this.loadResolvedRoute(this.router.resolve(parentLocation), attempt)
- if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
- target = entryFor(route, 'preview', true)
- synthetic = true
- needsMount = true
- this.mutableEntries.value = [...this.mutableEntries.value, target]
+ const parent = from.route.meta.native?.parent;
+ if (!parent) return null;
+ const parentLocation =
+ typeof parent === "function" ? parent(from.route) : parent;
+ const route = await this.loadResolvedRoute(
+ this.router.resolve(parentLocation),
+ attempt,
+ );
+ if (
+ attempt !== this.beginAttemptSequence ||
+ this.mutableTransaction.value
+ )
+ return null;
+ target = entryFor(route, "preview", true);
+ synthetic = true;
+ needsMount = true;
+ this.mutableEntries.value = [...this.mutableEntries.value, target];
} else if (!target.mounted) {
- const route = await this.loadResolvedRoute(this.router.resolve(target.route.fullPath), attempt)
- if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
- target.route = route
- target.mounted = true
- target.status = 'inactive'
- target.evictionReason = undefined
- needsMount = true
- this.touchEntries()
+ const route = await this.loadResolvedRoute(
+ this.router.resolve(target.route.fullPath),
+ attempt,
+ );
+ if (
+ attempt !== this.beginAttemptSequence ||
+ this.mutableTransaction.value
+ )
+ return null;
+ target.route = route;
+ target.mounted = true;
+ target.status = "inactive";
+ target.evictionReason = undefined;
+ needsMount = true;
+ this.touchEntries();
}
} else {
- if (!to) return null
- const resolved = this.router.resolve(to)
- if (resolved.fullPath === from.route.fullPath) return null
- const route = await this.loadResolvedRoute(resolved, attempt)
- if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
- const replace = options.replace ?? (route.meta.native?.siblingHistory === 'replace')
- target = replace ? this.findHistoryEntry(route.fullPath) : undefined
- target ??= this.findReusable(route.fullPath)
+ if (!to) return null;
+ const resolved = this.router.resolve(to);
+ if (resolved.fullPath === from.route.fullPath) return null;
+ const route = await this.loadResolvedRoute(resolved, attempt);
+ if (
+ attempt !== this.beginAttemptSequence ||
+ this.mutableTransaction.value
+ )
+ return null;
+ const replace =
+ options.replace ?? route.meta.native?.siblingHistory === "replace";
+ target = replace ? this.findHistoryEntry(route.fullPath) : undefined;
+ target ??= this.findReusable(route.fullPath);
if (target) {
- needsMount = !target.mounted
- target.route = route
- target.mounted = true
- target.status = 'preview'
- target.synthetic = false
- target.lastUsed = now()
- target.evictionReason = undefined
- this.touchEntries()
+ needsMount = !target.mounted;
+ target.route = route;
+ target.mounted = true;
+ target.status = "preview";
+ target.synthetic = false;
+ target.lastUsed = now();
+ target.evictionReason = undefined;
+ this.touchEntries();
} else {
- target = entryFor(route, 'preview')
- needsMount = true
- this.mutableEntries.value = [...this.mutableEntries.value, target]
+ target = entryFor(route, "preview");
+ needsMount = true;
+ this.mutableEntries.value = [...this.mutableEntries.value, target];
}
}
- const presentationRoute = isBack ? from.route : target.route
- const presentation = options.presentation
- ?? presentationRoute.meta.native?.presentation
- ?? presentationRoute.meta.native?.transition
- ?? (kind === 'present' || kind === 'dismiss' ? 'modal' : kind === 'sibling' ? 'slide' : 'push')
- const direction: NativeDirection = options.direction
- ?? (kind === 'pop' || kind === 'dismiss'
- ? 'back'
- : kind === 'present'
- ? 'up'
- : kind === 'sibling'
+ const presentationRoute = isBack ? from.route : target.route;
+ const presentation =
+ options.presentation ??
+ presentationRoute.meta.native?.presentation ??
+ presentationRoute.meta.native?.transition ??
+ (kind === "present" || kind === "dismiss"
+ ? "modal"
+ : kind === "sibling"
+ ? "slide"
+ : "push");
+ const direction: NativeDirection =
+ options.direction ??
+ (kind === "pop" || kind === "dismiss"
+ ? "back"
+ : kind === "present"
+ ? "up"
+ : kind === "sibling"
? this.siblingDirection(from.route, target.route)
- : 'forward')
+ : "forward");
const transaction: NativeTransaction = {
id: ++this.transactionSequence,
@@ -311,13 +388,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
toKey: target.key,
progress: 0,
velocity: 0,
- phase: 'interactive',
- replace: options.replace ?? (target.route.meta.native?.siblingHistory === 'replace'),
+ phase: "interactive",
+ replace:
+ options.replace ??
+ target.route.meta.native?.siblingHistory === "replace",
sourceRect: options.sourceRect,
- }
- if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
- this.mutableTransaction.value = transaction
- this.emitDiagnostic('transaction-start', {
+ };
+ if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value)
+ return null;
+ this.mutableTransaction.value = transaction;
+ this.emitDiagnostic("transaction-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
@@ -327,490 +407,577 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
presentation: transaction.presentation,
cold: needsMount,
},
- })
- if (synthetic) target.synthetic = true
+ });
+ if (synthetic) target.synthetic = true;
if (needsMount) {
- const prepareStarted = now()
- this.emitDiagnostic('view-prepare-start', {
+ const prepareStarted = now();
+ this.emitDiagnostic("view-prepare-start", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
- })
- await this.prepareMountedView()
- this.emitDiagnostic('view-prepare-end', {
+ });
+ await this.prepareMountedView();
+ this.emitDiagnostic("view-prepare-end", {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
duration: now() - prepareStarted,
- })
- if (!this.isCurrent(transaction.id)) return null
+ });
+ if (!this.isCurrent(transaction.id)) return null;
}
- return transaction.id
+ return transaction.id;
}
updateInteractive(progress: number, velocity = 0) {
- const current = this.mutableTransaction.value
- if (!current || current.phase !== 'interactive') return
+ const current = this.mutableTransaction.value;
+ if (!current || current.phase !== "interactive") return;
this.mutableTransaction.value = {
...current,
progress: Math.max(0, Math.min(1, progress)),
velocity,
- }
+ };
}
async finishInteractive(forceCommit?: boolean) {
- const current = this.mutableTransaction.value
- if (!current) return false
- const commit = forceCommit ?? shouldCommitGesture(current.progress, current.velocity)
+ const current = this.mutableTransaction.value;
+ if (!current) return false;
+ const commit =
+ forceCommit ?? shouldCommitGesture(current.progress, current.velocity);
if (!commit) {
- await this.cancelInteractive()
- return false
+ await this.cancelInteractive();
+ return false;
}
- this.mutableTransaction.value = { ...current, phase: 'committing' }
- this.emitDiagnostic('commit-start', {
+ this.mutableTransaction.value = { ...current, phase: "committing" };
+ this.emitDiagnostic("commit-start", {
transactionId: current.id,
- route: this.entryByKey(current.toKey) ? diagnosticRoute(this.entryByKey(current.toKey)!.route) : undefined,
- })
- void this.platform?.haptic?.('commit')
- const navigation = this.commitRoute(current)
- this.pendingNavigations.set(current.id, navigation)
+ route: this.entryByKey(current.toKey)
+ ? diagnosticRoute(this.entryByKey(current.toKey)!.route)
+ : undefined,
+ });
+ void this.platform?.haptic?.("commit");
+ const navigation = this.commitRoute(current);
+ this.pendingNavigations.set(current.id, navigation);
const result = navigation.then(
(failure) => ({ failed: Boolean(failure) }),
() => ({ failed: true }),
- )
- await this.animateProgress(1, current.velocity)
- const { failed } = await result
- this.pendingNavigations.delete(current.id)
- if (!this.isCurrent(current.id)) return !failed
+ );
+ await this.animateProgress(1, current.velocity);
+ const { failed } = await result;
+ this.pendingNavigations.delete(current.id);
+ if (!this.isCurrent(current.id)) return !failed;
if (failed) {
- await this.animateProgress(0, 0)
- if (this.isCurrent(current.id)) this.finalizeCancelled(current, true)
- return false
+ await this.animateProgress(0, 0);
+ if (this.isCurrent(current.id)) this.finalizeCancelled(current, true);
+ return false;
}
- this.finalizeCommitted(current)
- return true
+ this.finalizeCommitted(current);
+ return true;
}
async cancelInteractive() {
- const current = this.mutableTransaction.value
- if (!current || current.phase !== 'interactive') return
- this.mutableTransaction.value = { ...current, phase: 'cancelled' }
- await this.animateProgress(0, 0)
- if (!this.isCurrent(current.id)) return
- void this.platform?.haptic?.('cancel')
- this.finalizeCancelled(current)
+ const current = this.mutableTransaction.value;
+ if (!current || current.phase !== "interactive") return;
+ this.mutableTransaction.value = { ...current, phase: "cancelled" };
+ await this.animateProgress(0, 0);
+ if (!this.isCurrent(current.id)) return;
+ void this.platform?.haptic?.("cancel");
+ this.finalizeCancelled(current);
}
unload(to: RouteLocationRaw) {
- const fullPath = this.router.resolve(to).fullPath
- const transaction = this.mutableTransaction.value
- let unloaded = 0
+ const fullPath = this.router.resolve(to).fullPath;
+ const transaction = this.mutableTransaction.value;
+ let unloaded = 0;
for (const entry of this.mutableEntries.value) {
- if (entry.route.fullPath !== fullPath || !entry.mounted) continue
- if (entry.key === this.mutableActiveKey.value) continue
- if (entry.key === transaction?.fromKey || entry.key === transaction?.toKey) continue
- this.evictEntry(entry, 'manual')
- unloaded += 1
+ if (entry.route.fullPath !== fullPath || !entry.mounted) continue;
+ if (entry.key === this.mutableActiveKey.value) continue;
+ if (
+ entry.key === transaction?.fromKey ||
+ entry.key === transaction?.toKey
+ )
+ continue;
+ this.evictEntry(entry, "manual");
+ unloaded += 1;
}
- if (unloaded) this.touchEntries()
- return unloaded
+ if (unloaded) this.touchEntries();
+ return unloaded;
}
- trimCache(options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}) {
- const reason = options.reason ?? 'trimmed'
+ trimCache(
+ options: { includePinned?: boolean; reason?: NativeEvictionReason } = {},
+ ) {
+ const reason = options.reason ?? "trimmed";
for (const entry of this.mutableEntries.value) {
- if (entry.key === this.mutableActiveKey.value || !entry.mounted || entry.status !== 'inactive') continue
- if (!options.includePinned && entry.route.meta.native?.cache === 'pin') continue
- this.evictEntry(entry, reason)
+ if (
+ entry.key === this.mutableActiveKey.value ||
+ !entry.mounted ||
+ entry.status !== "inactive"
+ )
+ continue;
+ if (!options.includePinned && entry.route.meta.native?.cache === "pin")
+ continue;
+ this.evictEntry(entry, reason);
}
- this.touchEntries()
+ this.touchEntries();
}
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) {
- this.diagnosticListeners.add(listener)
- return () => this.diagnosticListeners.delete(listener)
+ this.diagnosticListeners.add(listener);
+ return () => this.diagnosticListeners.delete(listener);
}
registerPresentation(definition: NativePresentationDefinition) {
- this.presentations.set(definition.name, definition)
+ this.presentations.set(definition.name, definition);
}
presentationFor(name: NativePresentationName) {
- return this.presentations.get(name)
+ return this.presentations.get(name);
}
layerStyle(entry: NativeViewEntry): CSSProperties | undefined {
- const transaction = this.mutableTransaction.value
- if (!transaction) return undefined
- const role = entry.key === transaction.fromKey ? 'from' : entry.key === transaction.toKey ? 'to' : undefined
- if (!role) return undefined
+ const transaction = this.mutableTransaction.value;
+ if (!transaction) return undefined;
+ const role =
+ entry.key === transaction.fromKey
+ ? "from"
+ : entry.key === transaction.toKey
+ ? "to"
+ : undefined;
+ if (!role) return undefined;
return this.presentationFor(transaction.presentation)?.layerStyle?.({
progress: transaction.progress,
role,
direction: transaction.direction,
sourceRect: transaction.sourceRect,
- })
+ });
}
dispose() {
- this.removeAfterEach?.()
- this.platformCleanup?.()
- this.memoryPressureCleanup?.()
+ this.removeAfterEach?.();
+ this.platformCleanup?.();
+ this.memoryPressureCleanup?.();
}
private activeEntry() {
- return this.entryByKey(this.mutableActiveKey.value)
+ return this.entryByKey(this.mutableActiveKey.value);
}
private activeIndex() {
- return this.mutableEntries.value.findIndex((entry) => entry.key === this.mutableActiveKey.value)
+ return this.mutableEntries.value.findIndex(
+ (entry) => entry.key === this.mutableActiveKey.value,
+ );
}
private entryByKey(key: string) {
- return this.mutableEntries.value.find((entry) => entry.key === key)
+ return this.mutableEntries.value.find((entry) => entry.key === key);
}
private findReusable(fullPath: string) {
- return [...this.mutableEntries.value].reverse().find((entry) =>
- entry.route.fullPath === fullPath
- && entry.key !== this.mutableActiveKey.value
- && !this.mutableHistoryKeys.value.includes(entry.key),
- )
+ return [...this.mutableEntries.value]
+ .reverse()
+ .find(
+ (entry) =>
+ entry.route.fullPath === fullPath &&
+ entry.key !== this.mutableActiveKey.value &&
+ !this.mutableHistoryKeys.value.includes(entry.key),
+ );
}
private findHistoryEntry(fullPath: string) {
for (const key of [...this.mutableHistoryKeys.value].reverse()) {
- const entry = this.entryByKey(key)
- if (entry?.route.fullPath === fullPath) return entry
+ const entry = this.entryByKey(key);
+ if (entry?.route.fullPath === fullPath) return entry;
}
- return undefined
+ return undefined;
}
- private siblingDirection(from: RouteLocationNormalizedLoaded, to: RouteLocationNormalizedLoaded): NativeDirection {
- const fromOrder = from.meta.native?.siblingOrder
- const toOrder = to.meta.native?.siblingOrder
- return typeof fromOrder === 'number' && typeof toOrder === 'number' && toOrder < fromOrder ? 'back' : 'forward'
+ private siblingDirection(
+ from: RouteLocationNormalizedLoaded,
+ to: RouteLocationNormalizedLoaded,
+ ): NativeDirection {
+ const fromOrder = from.meta.native?.siblingOrder;
+ const toOrder = to.meta.native?.siblingOrder;
+ return typeof fromOrder === "number" &&
+ typeof toOrder === "number" &&
+ toOrder < fromOrder
+ ? "back"
+ : "forward";
}
private touchEntries() {
- this.mutableEntries.value = [...this.mutableEntries.value]
+ this.mutableEntries.value = [...this.mutableEntries.value];
}
private clearTransaction() {
- this.mutableTransaction.value = null
+ this.mutableTransaction.value = null;
}
private isCurrent(id: number) {
- return this.mutableTransaction.value?.id === id
+ return this.mutableTransaction.value?.id === id;
}
private async interruptSettling(transaction: NativeTransaction) {
- if (!this.isCurrent(transaction.id) || transaction.phase === 'interactive') return
- const navigation = this.pendingNavigations.get(transaction.id)
+ if (!this.isCurrent(transaction.id) || transaction.phase === "interactive")
+ return;
+ const navigation = this.pendingNavigations.get(transaction.id);
if (!navigation) {
- this.finalizeCancelled(transaction)
- return
+ this.finalizeCancelled(transaction);
+ return;
}
- const failed = await navigation.then((failure) => Boolean(failure), () => true)
- this.pendingNavigations.delete(transaction.id)
- if (!this.isCurrent(transaction.id)) return
+ const failed = await navigation.then(
+ (failure) => Boolean(failure),
+ () => true,
+ );
+ this.pendingNavigations.delete(transaction.id);
+ if (!this.isCurrent(transaction.id)) return;
this.mutableTransaction.value = {
...this.mutableTransaction.value!,
progress: failed ? 0 : 1,
velocity: 0,
- phase: 'settling',
- }
- if (failed) this.finalizeCancelled(transaction, true)
- else this.finalizeCommitted(transaction)
+ phase: "settling",
+ };
+ if (failed) this.finalizeCancelled(transaction, true);
+ else this.finalizeCommitted(transaction);
}
- private finalizeCancelled(transaction: NativeTransaction, evictTarget = false) {
- if (!this.isCurrent(transaction.id)) return
- const target = this.entryByKey(transaction.toKey)
- this.emitDiagnostic('transaction-end', {
+ private finalizeCancelled(
+ transaction: NativeTransaction,
+ evictTarget = false,
+ ) {
+ if (!this.isCurrent(transaction.id)) return;
+ const target = this.entryByKey(transaction.toKey);
+ this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
- details: { outcome: evictTarget ? 'rejected' : 'cancelled' },
- })
- this.clearTransaction()
- if (evictTarget) this.discardTarget(transaction.toKey, 'navigation-rejected')
- else this.removePreview(transaction.toKey)
- this.markStatuses()
- this.enforceCache()
+ details: { outcome: evictTarget ? "rejected" : "cancelled" },
+ });
+ this.clearTransaction();
+ if (evictTarget)
+ this.discardTarget(transaction.toKey, "navigation-rejected");
+ else this.removePreview(transaction.toKey);
+ this.markStatuses();
+ this.enforceCache();
}
private finalizeCommitted(transaction: NativeTransaction) {
- if (!this.isCurrent(transaction.id)) return
- const target = this.entryByKey(transaction.toKey)
- if (target && this.router.currentRoute.value.fullPath !== target.route.fullPath) {
+ if (!this.isCurrent(transaction.id)) return;
+ const target = this.entryByKey(transaction.toKey);
+ if (
+ target &&
+ this.router.currentRoute.value.fullPath !== target.route.fullPath
+ ) {
// A redirect is already authoritative; discard only the stale preview.
- this.emitDiagnostic('transaction-end', {
+ this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: diagnosticRoute(target.route),
- details: { outcome: 'redirected' },
- })
- this.clearTransaction()
- this.discardTarget(transaction.toKey, 'navigation-rejected')
- this.markStatuses()
- this.enforceCache()
- return
+ details: { outcome: "redirected" },
+ });
+ this.clearTransaction();
+ this.discardTarget(transaction.toKey, "navigation-rejected");
+ this.markStatuses();
+ this.enforceCache();
+ return;
}
if (target) {
- target.status = 'active'
- target.synthetic = false
- target.committed = true
- target.lastUsed = now()
- this.mutableActiveKey.value = target.key
+ target.status = "active";
+ target.synthetic = false;
+ target.committed = true;
+ target.lastUsed = now();
+ this.mutableActiveKey.value = target.key;
}
- this.emitDiagnostic('transaction-end', {
+ this.emitDiagnostic("transaction-end", {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
- details: { outcome: 'committed' },
- })
- this.clearTransaction()
- this.markStatuses()
- this.enforceCache()
+ details: { outcome: "committed" },
+ });
+ this.clearTransaction();
+ this.markStatuses();
+ this.enforceCache();
}
private async commitRoute(transaction: NativeTransaction) {
- const target = this.entryByKey(transaction.toKey)
- if (!target) return true
- if (transaction.kind === 'pop' || transaction.kind === 'dismiss') {
- if (target.synthetic) return await this.router.replace(target.route.fullPath)
- return await this.navigateHistory(-1)
+ const target = this.entryByKey(transaction.toKey);
+ if (!target) return true;
+ if (transaction.kind === "pop" || transaction.kind === "dismiss") {
+ if (target.synthetic)
+ return await this.router.replace(target.route.fullPath);
+ return await this.navigateHistory(-1);
}
if (transaction.replace) {
- const history = this.mutableHistoryKeys.value
- const targetIndex = history.lastIndexOf(target.key)
+ const history = this.mutableHistoryKeys.value;
+ const targetIndex = history.lastIndexOf(target.key);
if (targetIndex >= 0 && targetIndex < history.length - 1) {
- return await this.navigateHistory(targetIndex - (history.length - 1))
+ return await this.navigateHistory(targetIndex - (history.length - 1));
}
}
return transaction.replace
? await this.router.replace(target.route.fullPath)
- : await this.router.push(target.route.fullPath)
+ : await this.router.push(target.route.fullPath);
}
private async navigateHistory(delta: number) {
return await new Promise((resolve) => {
- this.pendingPop = resolve
- this.router.go(delta)
+ this.pendingPop = resolve;
+ this.router.go(delta);
window.setTimeout(() => {
if (this.pendingPop === resolve) {
- this.pendingPop = undefined
- resolve()
+ this.pendingPop = undefined;
+ resolve();
}
- }, 1200)
- })
+ }, 1200);
+ });
}
- private acceptRoute(to: RouteLocationNormalizedLoaded, _from: RouteLocationNormalizedLoaded) {
- const transaction = this.mutableTransaction.value
- let target = transaction ? this.entryByKey(transaction.toKey) : undefined
- if (target && target.route.fullPath !== to.fullPath) target = undefined
- if (!transaction) target ??= this.findHistoryEntry(to.fullPath)
- target ??= this.findReusable(to.fullPath)
- if (!target && this.activeEntry()?.route.fullPath === to.fullPath) target = this.activeEntry()
+ private acceptRoute(
+ to: RouteLocationNormalizedLoaded,
+ _from: RouteLocationNormalizedLoaded,
+ ) {
+ const transaction = this.mutableTransaction.value;
+ let target = transaction ? this.entryByKey(transaction.toKey) : undefined;
+ if (target && target.route.fullPath !== to.fullPath) target = undefined;
+ if (!transaction) target ??= this.findHistoryEntry(to.fullPath);
+ target ??= this.findReusable(to.fullPath);
+ if (!target && this.activeEntry()?.route.fullPath === to.fullPath)
+ target = this.activeEntry();
if (!target) {
- target = entryFor(to, 'active')
- const activeIndex = this.activeIndex()
- const head = activeIndex >= 0 ? this.mutableEntries.value.slice(0, activeIndex + 1) : this.mutableEntries.value
- this.mutableEntries.value = [...head, target]
+ target = entryFor(to, "active");
+ const activeIndex = this.activeIndex();
+ const head =
+ activeIndex >= 0
+ ? this.mutableEntries.value.slice(0, activeIndex + 1)
+ : this.mutableEntries.value;
+ this.mutableEntries.value = [...head, target];
} else {
- target.route = to
- target.mounted = true
- target.status = 'active'
- target.committed = true
- target.lastUsed = now()
- target.evictionReason = undefined
- this.touchEntries()
+ target.route = to;
+ target.mounted = true;
+ target.status = "active";
+ target.committed = true;
+ target.lastUsed = now();
+ target.evictionReason = undefined;
+ this.touchEntries();
}
- this.mutableActiveKey.value = target.key
- this.acceptHistory(target, transaction)
- this.markStatuses()
- if (!transaction) this.enforceCache()
+ this.mutableActiveKey.value = target.key;
+ this.acceptHistory(target, transaction);
+ this.markStatuses();
+ if (!transaction) this.enforceCache();
}
- private acceptHistory(target: NativeViewEntry, transaction: NativeTransaction | null) {
- const history = this.mutableHistoryKeys.value
+ private acceptHistory(
+ target: NativeViewEntry,
+ transaction: NativeTransaction | null,
+ ) {
+ const history = this.mutableHistoryKeys.value;
if (!history.length) {
- this.mutableHistoryKeys.value = [target.key]
- return
+ this.mutableHistoryKeys.value = [target.key];
+ return;
}
- if (transaction?.kind === 'pop' || transaction?.kind === 'dismiss') {
- const targetIndex = history.lastIndexOf(target.key)
- this.mutableHistoryKeys.value = targetIndex >= 0
- ? history.slice(0, targetIndex + 1)
- : [...history.slice(0, -1), target.key]
- return
+ if (transaction?.kind === "pop" || transaction?.kind === "dismiss") {
+ const targetIndex = history.lastIndexOf(target.key);
+ this.mutableHistoryKeys.value =
+ targetIndex >= 0
+ ? history.slice(0, targetIndex + 1)
+ : [...history.slice(0, -1), target.key];
+ return;
}
if (transaction?.replace) {
- const targetIndex = history.lastIndexOf(target.key)
- this.mutableHistoryKeys.value = targetIndex >= 0
- ? history.slice(0, targetIndex + 1)
- : [...history.slice(0, -1), target.key]
- return
+ const targetIndex = history.lastIndexOf(target.key);
+ this.mutableHistoryKeys.value =
+ targetIndex >= 0
+ ? history.slice(0, targetIndex + 1)
+ : [...history.slice(0, -1), target.key];
+ return;
}
- const existingIndex = history.lastIndexOf(target.key)
+ const existingIndex = history.lastIndexOf(target.key);
this.mutableHistoryKeys.value = transaction
? [...history, target.key]
: existingIndex >= 0
? history.slice(0, existingIndex + 1)
- : [...history, target.key]
+ : [...history, target.key];
}
private markStatuses() {
- const transaction = this.mutableTransaction.value
+ const transaction = this.mutableTransaction.value;
for (const entry of this.mutableEntries.value) {
- if (entry.key === this.mutableActiveKey.value) entry.status = 'active'
- else if (transaction?.toKey === entry.key && entry.status === 'preview') entry.status = 'preview'
- else if (entry.mounted) entry.status = 'inactive'
- else entry.status = 'evicted'
+ if (entry.key === this.mutableActiveKey.value) entry.status = "active";
+ else if (transaction?.toKey === entry.key && entry.status === "preview")
+ entry.status = "preview";
+ else if (entry.mounted) entry.status = "inactive";
+ else entry.status = "evicted";
}
- this.touchEntries()
+ this.touchEntries();
}
private removePreview(key: string) {
- const entry = this.entryByKey(key)
- if (!entry || entry.key === this.mutableActiveKey.value) return
- if (entry.status === 'preview' || entry.synthetic) {
+ const entry = this.entryByKey(key);
+ if (!entry || entry.key === this.mutableActiveKey.value) return;
+ if (entry.status === "preview" || entry.synthetic) {
if (entry.synthetic || !entry.committed) {
- this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
+ this.mutableEntries.value = this.mutableEntries.value.filter(
+ (candidate) => candidate.key !== key,
+ );
} else {
- entry.status = 'inactive'
- this.touchEntries()
+ entry.status = "inactive";
+ this.touchEntries();
}
}
}
private discardTarget(key: string, reason: NativeEvictionReason) {
- const entry = this.entryByKey(key)
- if (!entry || entry.key === this.mutableActiveKey.value) return
+ const entry = this.entryByKey(key);
+ if (!entry || entry.key === this.mutableActiveKey.value) return;
if (!entry.committed || entry.synthetic) {
- this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
- return
+ this.mutableEntries.value = this.mutableEntries.value.filter(
+ (candidate) => candidate.key !== key,
+ );
+ return;
}
- this.evictEntry(entry, reason)
+ this.evictEntry(entry, reason);
}
private evictEntry(entry: NativeViewEntry, reason: NativeEvictionReason) {
- if (!entry.mounted || entry.key === this.mutableActiveKey.value) return
- entry.mounted = false
- entry.status = 'evicted'
- entry.evictionReason = reason
- this.totalEvictions += 1
- this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason }
- this.emitDiagnostic('view-evicted', {
+ if (!entry.mounted || entry.key === this.mutableActiveKey.value) return;
+ entry.mounted = false;
+ entry.status = "evicted";
+ entry.evictionReason = reason;
+ this.totalEvictions += 1;
+ this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason };
+ this.emitDiagnostic("view-evicted", {
route: diagnosticRoute(entry.route),
details: { reason },
- })
+ });
}
private shouldRetainInactive(entry: NativeViewEntry) {
- const policy = entry.route.meta.native?.cache
- if (policy === false) return false
- if (policy === 'pin') return true
- if (this.mutableHistoryKeys.value.includes(entry.key)) return true
- return entry.route.meta.native?.siblingHistory === 'replace'
+ const policy = entry.route.meta.native?.cache;
+ if (policy === false) return false;
+ if (policy === "pin") return true;
+ if (this.mutableHistoryKeys.value.includes(entry.key)) return true;
+ return entry.route.meta.native?.siblingHistory === "replace";
}
private enforceCache() {
const inactive = this.mutableEntries.value
- .filter((entry) => entry.key !== this.mutableActiveKey.value && entry.mounted && entry.status === 'inactive')
- .sort((a, b) => b.lastUsed - a.lastUsed)
+ .filter(
+ (entry) =>
+ entry.key !== this.mutableActiveKey.value &&
+ entry.mounted &&
+ entry.status === "inactive",
+ )
+ .sort((a, b) => b.lastUsed - a.lastUsed);
for (const entry of inactive) {
if (!this.shouldRetainInactive(entry)) {
- const reason: NativeEvictionReason = entry.route.meta.native?.cache === false
- ? 'cache-disabled'
- : 'popped'
- this.evictEntry(entry, reason)
+ const reason: NativeEvictionReason =
+ entry.route.meta.native?.cache === false
+ ? "cache-disabled"
+ : "popped";
+ this.evictEntry(entry, reason);
}
}
- const retained = inactive.filter((entry) => entry.mounted && entry.route.meta.native?.cache !== 'pin')
+ const retained = inactive.filter(
+ (entry) => entry.mounted && entry.route.meta.native?.cache !== "pin",
+ );
for (const entry of retained.slice(this.maxInactive)) {
- this.evictEntry(entry, 'cache-limit')
+ this.evictEntry(entry, "cache-limit");
}
- this.touchEntries()
+ this.touchEntries();
}
private async prepareMountedView() {
- await nextTick()
- if (typeof window === 'undefined') return
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
- await new Promise((resolve) => window.requestAnimationFrame(() => resolve()))
+ await nextTick();
+ if (typeof window === "undefined") return;
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ await new Promise((resolve) =>
+ window.requestAnimationFrame(() => resolve()),
+ );
}
- private async loadResolvedRoute(route: RouteLocationResolved, attempt?: number) {
- const started = now()
- const label = diagnosticRoute(route)
- this.emitDiagnostic('route-load-start', { attempt, route: label })
+ private async loadResolvedRoute(
+ route: RouteLocationResolved,
+ attempt?: number,
+ ) {
+ const started = now();
+ const label = diagnosticRoute(route);
+ this.emitDiagnostic("route-load-start", { attempt, route: label });
try {
- return await loadRouteLocation(route)
+ return await loadRouteLocation(route);
} finally {
- this.emitDiagnostic('route-load-end', {
+ this.emitDiagnostic("route-load-end", {
attempt,
route: label,
duration: now() - started,
- })
+ });
}
}
private emitDiagnostic(
type: NativeDiagnosticEventType,
- event: Omit = {},
+ event: Omit = {},
) {
- if (!this.diagnosticListeners.size) return
- const diagnostic = { type, timestamp: now(), ...event }
- for (const listener of this.diagnosticListeners) listener(diagnostic)
+ if (!this.diagnosticListeners.size) return;
+ const diagnostic = { type, timestamp: now(), ...event };
+ for (const listener of this.diagnosticListeners) listener(diagnostic);
}
private animateProgress(target: number, initialVelocity: number) {
- const transaction = this.mutableTransaction.value
- if (!transaction) return Promise.resolve()
- if (typeof window === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- this.mutableTransaction.value = { ...transaction, progress: target, velocity: 0, phase: 'settling' }
- return Promise.resolve()
+ const transaction = this.mutableTransaction.value;
+ if (!transaction) return Promise.resolve();
+ if (
+ typeof window === "undefined" ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ this.mutableTransaction.value = {
+ ...transaction,
+ progress: target,
+ velocity: 0,
+ phase: "settling",
+ };
+ return Promise.resolve();
}
return new Promise((resolve) => {
- let position = transaction.progress
- let velocity = Math.max(-12, Math.min(12, initialVelocity))
- const timeScale = springTimeScaleForVelocity(initialVelocity)
- let previous = now()
+ let position = transaction.progress;
+ let velocity = Math.max(-12, Math.min(12, initialVelocity));
+ const timeScale = springTimeScaleForVelocity(initialVelocity);
+ let previous = now();
const step = (time: number) => {
- const live = this.mutableTransaction.value
- if (!live || live.id !== transaction.id) return resolve()
- const elapsed = Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) * timeScale
- previous = time
+ const live = this.mutableTransaction.value;
+ if (!live || live.id !== transaction.id) return resolve();
+ const elapsed =
+ Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) *
+ timeScale;
+ previous = time;
// Substeps keep the spring stable when a high-velocity flick advances
// several frames of simulated time in one display frame.
- const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)))
- const dt = elapsed / iterations
+ const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
+ const dt = elapsed / iterations;
for (let iteration = 0; iteration < iterations; iteration += 1) {
- const displacement = target - position
- const acceleration = displacement * 280 - velocity * 30
- velocity += acceleration * dt
- position += velocity * dt
+ const displacement = target - position;
+ const acceleration = displacement * 280 - velocity * 30;
+ velocity += acceleration * dt;
+ position += velocity * dt;
}
- const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
+ const done =
+ Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02;
this.mutableTransaction.value = {
...live,
progress: done ? target : Math.max(0, Math.min(1, position)),
velocity,
- phase: 'settling',
- }
- if (done) resolve()
- else requestAnimationFrame(step)
- }
- requestAnimationFrame(step)
- })
+ phase: "settling",
+ };
+ if (done) resolve();
+ else requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ });
}
}
-export function createNativeRouter(options: NativeRouterOptions): NativeRouterRuntime {
- return new NativeRouterRuntimeImpl(options)
+export function createNativeRouter(
+ options: NativeRouterOptions,
+): NativeRouterRuntime {
+ return new NativeRouterRuntimeImpl(options);
}
export function isFailedNavigation(value: unknown) {
- return Boolean(value && isNavigationFailure(value))
+ return Boolean(value && isNavigationFailure(value));
}
diff --git a/packages/core/src/style.css b/packages/core/src/style.css
index c4676eb..80b8737 100644
--- a/packages/core/src/style.css
+++ b/packages/core/src/style.css
@@ -44,7 +44,7 @@ body,
z-index: 2147483646;
inset: 0;
background: #000;
- content: '';
+ content: "";
opacity: 0;
pointer-events: none;
will-change: opacity;
@@ -65,58 +65,85 @@ body,
visibility: visible;
}
-.nvr-view--from { z-index: 3; }
-.nvr-view--to { z-index: 2; }
+.nvr-view--from {
+ 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="reveal"][data-native-direction="forward"] .nvr-view--from {
+.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
+ .nvr-view--from,
+.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
+ .nvr-view--from {
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="reveal"][data-native-direction="forward"] .nvr-view--from::after {
- opacity: calc(var(--native-progress) * .12);
+.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
+ .nvr-view--from::after,
+.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="reveal"][data-native-direction="forward"] .nvr-view--to {
+.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
+ .nvr-view--to,
+.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
+ .nvr-view--to {
z-index: 4;
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;
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);
}
-.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to::after {
- opacity: calc((1 - var(--native-progress)) * .12);
+.nvr-router-view:is(
+ [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. */
-.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);
}
-.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);
}
-.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);
}
-.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);
}
-.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;
box-shadow: none;
}
@@ -125,19 +152,20 @@ body,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
z-index: 4;
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;
- box-shadow: 0 -24px 60px rgba(0, 0, 0, .34);
+ border-radius: calc((1 - var(--native-progress)) * 24px)
+ calc((1 - var(--native-progress)) * 24px) 0 0;
+ box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
}
.nvr-router-view[data-native-presentation="modal"] .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);
}
.nvr-router-view[data-native-presentation="modal"] .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 {
@@ -158,25 +186,35 @@ body,
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="sheet"][data-native-direction="back"] .nvr-view--from {
+.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
+ .nvr-view--from,
+.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
+ .nvr-view--from {
z-index: 4;
transform: translate3d(0, calc(var(--native-progress) * 100%), 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="sheet"][data-native-direction="back"] .nvr-view--to {
+.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
+ .nvr-view--to,
+.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
+ .nvr-view--to {
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);
box-shadow: none;
}
-.nvr-router-view:is([data-native-presentation="modal"], [data-native-presentation="sheet"])[data-native-direction="back"] .nvr-view--to::after {
- opacity: calc((1 - var(--native-progress)) * .24);
+.nvr-router-view:is(
+ [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) {
- .nvr-view { will-change: auto; }
+ .nvr-view {
+ will-change: auto;
+ }
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index c052388..b3b3a73 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,196 +1,224 @@
-import type { App, CSSProperties } from 'vue'
+import type { App, CSSProperties } from "vue";
import type {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
Router,
-} from 'vue-router'
+} from "vue-router";
export type NativePresentationName =
- | 'push'
- | 'reveal'
- | 'slide'
- | 'fade'
- | 'modal'
- | 'sheet'
- | 'none'
- | (string & {})
+ | "push"
+ | "reveal"
+ | "slide"
+ | "fade"
+ | "modal"
+ | "sheet"
+ | "none"
+ | (string & {});
-export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss'
-export type NativeDirection = 'forward' | 'back' | 'up' | 'down'
-export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted'
-export type NativeViewRole = 'active' | 'inactive' | 'from' | 'to'
-export type NativeCachePolicy = boolean | 'pin'
+export type NativeGestureKind =
+ "push" | "pop" | "sibling" | "present" | "dismiss";
+export type NativeDirection = "forward" | "back" | "up" | "down";
+export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
+export type NativeViewRole = "active" | "inactive" | "from" | "to";
+export type NativeCachePolicy = boolean | "pin";
export type NativeEvictionReason =
- | 'cache-disabled'
- | 'cache-limit'
- | 'navigation-rejected'
- | 'popped'
- | 'manual'
- | 'trimmed'
- | 'memory-pressure'
+ | "cache-disabled"
+ | "cache-limit"
+ | "navigation-rejected"
+ | "popped"
+ | "manual"
+ | "trimmed"
+ | "memory-pressure";
export type NativeDiagnosticEventType =
- | 'route-load-start'
- | 'route-load-end'
- | 'transaction-start'
- | 'view-prepare-start'
- | 'view-prepare-end'
- | 'commit-start'
- | 'transaction-end'
- | 'view-evicted'
+ | "route-load-start"
+ | "route-load-end"
+ | "transaction-start"
+ | "view-prepare-start"
+ | "view-prepare-end"
+ | "commit-start"
+ | "transaction-end"
+ | "view-evicted";
export interface NativeDiagnosticEvent {
- type: NativeDiagnosticEventType
+ type: NativeDiagnosticEventType;
/** Monotonic `performance.now()` timestamp. */
- timestamp: number
- attempt?: number
- transactionId?: number
+ timestamp: number;
+ attempt?: number;
+ transactionId?: number;
/** Route record name or declared path pattern; params and query values are omitted. */
- route?: string
- duration?: number
- details?: Record
+ route?: string;
+ duration?: number;
+ details?: Record;
}
export interface NativeRouteOptions {
- navigator?: string
- presentation?: NativePresentationName
- transition?: NativePresentationName
- parent?: RouteLocationRaw | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw)
- siblingGroup?: string
- siblingOrder?: number
- siblingHistory?: 'push' | 'replace'
+ navigator?: string;
+ presentation?: NativePresentationName;
+ transition?: NativePresentationName;
+ parent?:
+ | RouteLocationRaw
+ | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw);
+ siblingGroup?: string;
+ siblingOrder?: number;
+ siblingHistory?: "push" | "replace";
/** `false` disables retention; `pin` exempts the route from LRU trimming. */
- cache?: NativeCachePolicy
- gesture?: boolean | 'edge' | 'full'
+ cache?: NativeCachePolicy;
+ gesture?: boolean | "edge" | "full";
}
-declare module 'vue-router' {
+declare module "vue-router" {
interface RouteMeta {
- native?: NativeRouteOptions
+ native?: NativeRouteOptions;
}
}
export interface NativeViewEntry {
- key: string
- route: RouteLocationNormalizedLoaded
- status: NativeViewStatus
- mounted: boolean
- synthetic: boolean
+ key: string;
+ route: RouteLocationNormalizedLoaded;
+ status: NativeViewStatus;
+ mounted: boolean;
+ synthetic: boolean;
/** True once Vue Router has made this route authoritative. */
- committed: boolean
- lastUsed: number
- scrollX: number
- scrollY: number
- evictionReason?: NativeEvictionReason
+ committed: boolean;
+ lastUsed: number;
+ scrollX: number;
+ scrollY: number;
+ evictionReason?: NativeEvictionReason;
}
export interface NativeCacheStats {
- maxInactive: number
- descriptors: number
- mounted: number
- inactive: number
- pinned: number
- evicted: number
- totalEvictions: number
- lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
+ maxInactive: number;
+ descriptors: number;
+ mounted: number;
+ inactive: number;
+ pinned: number;
+ evicted: number;
+ totalEvictions: number;
+ lastEviction?: { key: string; route: string; reason: NativeEvictionReason };
}
export interface NativeViewLifecycle {
- readonly key: string
- readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>
- readonly status: Readonly<{ value: NativeViewStatus }>
- readonly role: Readonly<{ value: NativeViewRole }>
- readonly isActive: Readonly<{ value: boolean }>
- readonly isVisible: Readonly<{ value: boolean }>
- readonly isPreview: Readonly<{ value: boolean }>
- readonly isCached: Readonly<{ value: boolean }>
- readonly evictionReason: Readonly<{ value: NativeEvictionReason | undefined }>
+ readonly key: string;
+ readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>;
+ readonly status: Readonly<{ value: NativeViewStatus }>;
+ readonly role: Readonly<{ value: NativeViewRole }>;
+ readonly isActive: Readonly<{ value: boolean }>;
+ readonly isVisible: Readonly<{ value: boolean }>;
+ readonly isPreview: Readonly<{ value: boolean }>;
+ readonly isCached: Readonly<{ value: boolean }>;
+ readonly evictionReason: Readonly<{
+ value: NativeEvictionReason | undefined;
+ }>;
}
export interface NativeSourceRect {
- top: number
- left: number
- width: number
- height: number
- viewportWidth: number
- viewportHeight: number
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+ viewportWidth: number;
+ viewportHeight: number;
}
export interface NativeTransaction {
- id: number
- kind: NativeGestureKind
- direction: NativeDirection
- presentation: NativePresentationName
- fromKey: string
- toKey: string
- progress: number
- velocity: number
- phase: 'candidate' | 'interactive' | 'settling' | 'committing' | 'cancelled'
- replace: boolean
- sourceRect?: NativeSourceRect
+ id: number;
+ kind: NativeGestureKind;
+ direction: NativeDirection;
+ presentation: NativePresentationName;
+ fromKey: string;
+ toKey: string;
+ progress: number;
+ velocity: number;
+ phase: "candidate" | "interactive" | "settling" | "committing" | "cancelled";
+ replace: boolean;
+ sourceRect?: NativeSourceRect;
}
export interface NativePresentationContext {
- progress: number
- role: 'from' | 'to'
- direction: NativeDirection
- sourceRect?: NativeSourceRect
+ progress: number;
+ role: "from" | "to";
+ direction: NativeDirection;
+ sourceRect?: NativeSourceRect;
}
export interface NativePresentationDefinition {
- name: NativePresentationName
- axis?: 'x' | 'y'
- layerStyle?: (context: NativePresentationContext) => CSSProperties
+ name: NativePresentationName;
+ axis?: "x" | "y";
+ layerStyle?: (context: NativePresentationContext) => CSSProperties;
}
export interface NativePlatformAdapter {
- name: string
- install?: (runtime: NativeRouterRuntime) => void | (() => void) | Promise void)>
- haptic?: (event: 'selection' | 'commit' | 'cancel') => void | Promise
- exitAtRoot?: () => void | Promise
+ name: string;
+ install?: (
+ runtime: NativeRouterRuntime,
+ ) => void | (() => void) | Promise void)>;
+ haptic?: (event: "selection" | "commit" | "cancel") => void | Promise;
+ exitAtRoot?: () => void | Promise;
}
export interface NativeRouterOptions {
- router: Router
- cache?: { maxInactive?: number }
- edgeWidth?: number
- platform?: NativePlatformAdapter
- presentations?: NativePresentationDefinition[]
+ router: Router;
+ cache?: { maxInactive?: number };
+ edgeWidth?: number;
+ platform?: NativePlatformAdapter;
+ presentations?: NativePresentationDefinition[];
}
export interface NativeNavigationOptions {
- presentation?: NativePresentationName
- replace?: boolean
- direction?: NativeDirection
- sourceRect?: NativeSourceRect
+ presentation?: NativePresentationName;
+ replace?: boolean;
+ direction?: NativeDirection;
+ sourceRect?: NativeSourceRect;
}
export interface NativeRouterRuntime {
- readonly router: Router
- readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>
- readonly activeKey: Readonly<{ value: string }>
- readonly transaction: Readonly<{ value: NativeTransaction | null }>
- readonly canGoBack: Readonly<{ value: boolean }>
- readonly cacheStats: Readonly<{ value: NativeCacheStats }>
- install(app: App): void
- push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise
- replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise
- sibling(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise
- pop(): Promise
- present(to: RouteLocationRaw, presentation?: NativePresentationName): Promise
- dismiss(): Promise
- preload(to: RouteLocationRaw): Promise
- beginInteractive(kind: NativeGestureKind, to?: RouteLocationRaw, options?: NativeNavigationOptions): Promise
- updateInteractive(progress: number, velocity?: number): void
- finishInteractive(forceCommit?: boolean): Promise
- cancelInteractive(): Promise
+ readonly router: Router;
+ readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>;
+ readonly activeKey: Readonly<{ value: string }>;
+ readonly transaction: Readonly<{ value: NativeTransaction | null }>;
+ readonly canGoBack: Readonly<{ value: boolean }>;
+ readonly cacheStats: Readonly<{ value: NativeCacheStats }>;
+ install(app: App): void;
+ push(
+ to: RouteLocationRaw,
+ options?: NativeNavigationOptions,
+ ): Promise;
+ replace(
+ to: RouteLocationRaw,
+ options?: NativeNavigationOptions,
+ ): Promise;
+ sibling(
+ to: RouteLocationRaw,
+ options?: NativeNavigationOptions,
+ ): Promise;
+ pop(): Promise;
+ present(
+ to: RouteLocationRaw,
+ presentation?: NativePresentationName,
+ ): Promise;
+ dismiss(): Promise;
+ preload(to: RouteLocationRaw): Promise;
+ beginInteractive(
+ kind: NativeGestureKind,
+ to?: RouteLocationRaw,
+ options?: NativeNavigationOptions,
+ ): Promise;
+ updateInteractive(progress: number, velocity?: number): void;
+ finishInteractive(forceCommit?: boolean): Promise;
+ cancelInteractive(): Promise;
/** 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. */
- 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. */
- onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void
- registerPresentation(definition: NativePresentationDefinition): void
- presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined
- dispose(): void
+ onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void;
+ registerPresentation(definition: NativePresentationDefinition): void;
+ presentationFor(
+ name: NativePresentationName,
+ ): NativePresentationDefinition | undefined;
+ dispose(): void;
}
diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts
index 8fdc55c..aee02b4 100644
--- a/packages/core/vite.config.ts
+++ b/packages/core/vite.config.ts
@@ -1,10 +1,16 @@
-import { defineConfig } from 'vite'
-import { resolve } from 'node:path'
+import { defineConfig } from "vite";
+import { resolve } from "node:path";
export default defineConfig({
build: {
- outDir: 'dist', emptyOutDir: true,
- lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' },
- rollupOptions: { external: ['vue', 'vue-router'] },
+ outDir: "dist",
+ emptyOutDir: true,
+ lib: {
+ entry: resolve(__dirname, "src/index.ts"),
+ formats: ["es"],
+ fileName: "index",
+ cssFileName: "style",
+ },
+ rollupOptions: { external: ["vue", "vue-router"] },
},
-})
+});
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 9e31f2e..1b7ebd5 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -3,8 +3,19 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
- "files": ["dist"],
- "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" }
+ "files": [
+ "dist"
+ ],
+ "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"
+ }
}
diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts
index 9079824..e329735 100644
--- a/packages/electron/src/index.ts
+++ b/packages/electron/src/index.ts
@@ -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 {
- appendSwitch(name: string, value?: string): void
+ appendSwitch(name: string, value?: string): void;
}
-export function disableElectronHistoryGestures(commandLine: ElectronCommandLine) {
- commandLine.appendSwitch('disable-features', 'OverscrollHistoryNavigation')
+export function disableElectronHistoryGestures(
+ commandLine: ElectronCommandLine,
+) {
+ commandLine.appendSwitch("disable-features", "OverscrollHistoryNavigation");
}
declare global {
interface Window {
nativeVueHost?: {
- onBack(callback: () => void): () => void
- onForward?(callback: () => void): () => void
- onMemoryPressure?(callback: () => void): () => void
- }
+ onBack(callback: () => void): () => void;
+ onForward?(callback: () => void): () => void;
+ onMemoryPressure?(callback: () => void): () => void;
+ };
}
}
export function createElectronRendererAdapter(): NativePlatformAdapter {
return {
- name: 'electron',
+ name: "electron",
install(runtime: NativeRouterRuntime) {
const removeBack = window.nativeVueHost?.onBack(() => {
- if (runtime.canGoBack.value) void runtime.pop()
- })
- const removeForward = window.nativeVueHost?.onForward?.(() => runtime.router.forward())
- const removeMemoryPressure = window.nativeVueHost?.onMemoryPressure?.(() => {
- runtime.trimCache({ reason: 'memory-pressure' })
- })
+ if (runtime.canGoBack.value) void runtime.pop();
+ });
+ const removeForward = window.nativeVueHost?.onForward?.(() =>
+ runtime.router.forward(),
+ );
+ const removeMemoryPressure = window.nativeVueHost?.onMemoryPressure?.(
+ () => {
+ runtime.trimCache({ reason: "memory-pressure" });
+ },
+ );
return () => {
- removeBack?.()
- removeForward?.()
- removeMemoryPressure?.()
- }
+ removeBack?.();
+ removeForward?.();
+ removeMemoryPressure?.();
+ };
},
- }
+ };
}
diff --git a/packages/electron/vite.config.ts b/packages/electron/vite.config.ts
index 51603b7..02aa477 100644
--- a/packages/electron/vite.config.ts
+++ b/packages/electron/vite.config.ts
@@ -1,10 +1,15 @@
-import { defineConfig } from 'vite'
-import { resolve } from 'node:path'
+import { defineConfig } from "vite";
+import { resolve } from "node:path";
export default defineConfig({
build: {
- outDir: 'dist', emptyOutDir: true,
- lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' },
- rollupOptions: { external: ['@native-vue-router/core'] },
+ outDir: "dist",
+ emptyOutDir: true,
+ lib: {
+ entry: resolve(__dirname, "src/index.ts"),
+ formats: ["es"],
+ fileName: "index",
+ },
+ rollupOptions: { external: ["@native-vue-router/core"] },
},
-})
+});
diff --git a/packages/preset-native/package.json b/packages/preset-native/package.json
index 62ca666..7022d08 100644
--- a/packages/preset-native/package.json
+++ b/packages/preset-native/package.json
@@ -3,11 +3,20 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
- "files": ["dist"],
- "sideEffects": ["./dist/style.css"],
- "scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
+ "files": [
+ "dist"
+ ],
+ "sideEffects": [
+ "./dist/style.css"
+ ],
+ "scripts": {
+ "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
+ },
"exports": {
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
"./style.css": "./dist/style.css"
},
"peerDependencies": {
diff --git a/packages/preset-native/src/index.ts b/packages/preset-native/src/index.ts
index a31d044..ebc74f5 100644
--- a/packages/preset-native/src/index.ts
+++ b/packages/preset-native/src/index.ts
@@ -1,71 +1,103 @@
-import { defineComponent, h, type PropType } from 'vue'
-import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router'
-import { useNativeRouter } from '@native-vue-router/core'
-import './style.css'
+import { defineComponent, h, type PropType } from "vue";
+import type {
+ RouteLocationNormalizedLoaded,
+ 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 {
- if (typeof navigator === 'undefined') return 'desktop'
- if (/iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent) && ('ontouchend' in document)) return 'ios'
- if (/Android/.test(navigator.userAgent)) return 'android'
- return 'desktop'
+ if (typeof navigator === "undefined") return "desktop";
+ if (
+ /iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent) &&
+ "ontouchend" in document
+ )
+ return "ios";
+ if (/Android/.test(navigator.userAgent)) return "android";
+ return "desktop";
}
export const NativeBackButton = defineComponent({
- name: 'NativeBackButton',
- props: { label: { type: String, default: 'Back' } },
+ name: "NativeBackButton",
+ props: { label: { type: String, default: "Back" } },
setup(props) {
- const native = useNativeRouter()
- return () => h('button', {
- type: 'button',
- class: 'nvr-native-back',
- 'aria-label': props.label,
- onClick: () => void native.pop(),
- }, [h('span', { 'aria-hidden': 'true' }, '‹'), h('span', props.label)])
+ const native = useNativeRouter();
+ return () =>
+ h(
+ "button",
+ {
+ type: "button",
+ class: "nvr-native-back",
+ "aria-label": props.label,
+ onClick: () => void native.pop(),
+ },
+ [h("span", { "aria-hidden": "true" }, "‹"), h("span", props.label)],
+ );
},
-})
+});
export interface NativeTabItem {
- label: string
- to: RouteLocationRaw
- icon?: string
- activeWhen?: (current: RouteLocationNormalizedLoaded) => boolean
+ label: string;
+ to: RouteLocationRaw;
+ icon?: string;
+ activeWhen?: (current: RouteLocationNormalizedLoaded) => boolean;
}
export const NativeTabBar = defineComponent({
- name: 'NativeTabBar',
+ name: "NativeTabBar",
props: {
items: { type: Array as PropType, required: true },
},
setup(props) {
- const native = useNativeRouter()
- return () => h('nav', { class: 'nvr-native-tabs', 'aria-label': 'Primary navigation' },
- props.items.map((item) => {
- const current = native.router.currentRoute.value
- const exact = current.path === native.router.resolve(item.to).path
- const active = exact || Boolean(item.activeWhen?.(current))
- const href = native.router.resolve(item.to).href
- return h('a', {
- href,
- class: ['nvr-native-tab', active && 'nvr-native-tab--active'],
- 'aria-current': active ? 'page' : undefined,
- onClick: (event: MouseEvent) => {
- if (event.defaultPrevented || 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),
- ])
- }))
+ const native = useNativeRouter();
+ return () =>
+ h(
+ "nav",
+ { class: "nvr-native-tabs", "aria-label": "Primary navigation" },
+ props.items.map((item) => {
+ const current = native.router.currentRoute.value;
+ const exact = current.path === native.router.resolve(item.to).path;
+ const active = exact || Boolean(item.activeWhen?.(current));
+ const href = native.router.resolve(item.to).href;
+ return h(
+ "a",
+ {
+ href,
+ class: ["nvr-native-tab", active && "nvr-native-tab--active"],
+ "aria-current": active ? "page" : undefined,
+ onClick: (event: MouseEvent) => {
+ if (
+ event.defaultPrevented ||
+ 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 = {
ios: { edgeWidth: 28, commitThreshold: 0.36 },
android: { edgeWidth: 24, commitThreshold: 0.32 },
desktop: { edgeWidth: 18, commitThreshold: 0.4 },
-} as const
+} as const;
diff --git a/packages/preset-native/src/style.css b/packages/preset-native/src/style.css
index fffa08e..5c01a74 100644
--- a/packages/preset-native/src/style.css
+++ b/packages/preset-native/src/style.css
@@ -30,9 +30,14 @@
display: grid;
grid-auto-flow: column;
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);
- 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);
}
@@ -48,9 +53,16 @@
font-weight: 600;
}
-.nvr-native-tab--active { color: var(--nvr-accent); }
-.nvr-native-tab__icon { font-size: 19px; line-height: 1.2; }
+.nvr-native-tab--active {
+ color: var(--nvr-accent);
+}
+.nvr-native-tab__icon {
+ font-size: 19px;
+ line-height: 1.2;
+}
@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;
+ }
}
diff --git a/packages/preset-native/vite.config.ts b/packages/preset-native/vite.config.ts
index 339f6d6..3b323af 100644
--- a/packages/preset-native/vite.config.ts
+++ b/packages/preset-native/vite.config.ts
@@ -1,10 +1,18 @@
-import { defineConfig } from 'vite'
-import { resolve } from 'node:path'
+import { defineConfig } from "vite";
+import { resolve } from "node:path";
export default defineConfig({
build: {
- outDir: 'dist', emptyOutDir: true,
- lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' },
- rollupOptions: { external: ['vue', 'vue-router', '@native-vue-router/core'] },
+ outDir: "dist",
+ emptyOutDir: true,
+ lib: {
+ entry: resolve(__dirname, "src/index.ts"),
+ formats: ["es"],
+ fileName: "index",
+ cssFileName: "style",
+ },
+ rollupOptions: {
+ external: ["vue", "vue-router", "@native-vue-router/core"],
+ },
},
-})
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 9b5686f..a8cbd4d 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,16 +1,16 @@
-import { defineConfig, devices } from '@playwright/test'
+import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
- testDir: './apps/demo/e2e',
- use: { baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure' },
+ testDir: "./apps/demo/e2e",
+ use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" },
projects: [
- { name: 'mobile-chromium', use: { ...devices['Pixel 7'] } },
- { name: 'mobile-webkit', use: { ...devices['iPhone 15'] } },
- { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
+ { name: "mobile-chromium", use: { ...devices["Pixel 7"] } },
+ { name: "mobile-webkit", use: { ...devices["iPhone 15"] } },
+ { name: "desktop", use: { ...devices["Desktop Chrome"] } },
],
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,
reuseExistingServer: true,
},
-})
+});
diff --git a/tsconfig.app.json b/tsconfig.app.json
index d16b754..9bd245d 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -12,7 +12,9 @@
"paths": {
"@/*": ["./apps/demo/src/*"],
"@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/electron": ["./packages/electron/src/index.ts"]
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 3780d6c..c045f90 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -20,5 +20,10 @@
"erasableSyntaxOnly": 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"
+ ]
}
diff --git a/vite.config.ts b/vite.config.ts
index 7fd7b84..65a63e9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,75 +1,120 @@
-import path from 'node:path'
-import { defineConfig } from 'vite'
-import tailwindcss from '@tailwindcss/vite'
-import vue from '@vitejs/plugin-vue'
-import { VitePWA } from 'vite-plugin-pwa'
+import path from "node:path";
+import { defineConfig } from "vite";
+import tailwindcss from "@tailwindcss/vite";
+import vue from "@vitejs/plugin-vue";
+import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
- base: './',
- root: path.resolve(__dirname, 'apps/demo'),
- publicDir: path.resolve(__dirname, 'public'),
+ base: "./",
+ root: path.resolve(__dirname, "apps/demo"),
+ publicDir: path.resolve(__dirname, "public"),
define: {
__NVR_BUILD_ID__: JSON.stringify(new Date().toISOString()),
},
server: {
// iOS Safari can otherwise retain a tunnelled development response after
// 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: {
// Production hosts should apply the same policy at least to index.html and
// sw.js. Fingerprinted assets can use immutable caching when deployed.
- headers: { 'Cache-Control': 'no-cache' },
+ headers: { "Cache-Control": "no-cache" },
},
plugins: [
vue(),
tailwindcss(),
VitePWA({
- registerType: 'autoUpdate',
+ registerType: "autoUpdate",
devOptions: {
enabled: true,
- navigateFallback: 'index.html',
+ navigateFallback: "index.html",
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: {
- id: '/',
- name: 'Native Vue Messenger',
- short_name: 'NVR Messenger',
- description: 'Gesture-first navigation for Vue applications',
- theme_color: '#0b0d12',
- background_color: '#0b0d12',
- display: 'standalone',
- scope: '/',
- orientation: 'any',
- start_url: '/',
+ id: "/",
+ name: "Native Vue Messenger",
+ short_name: "NVR Messenger",
+ description: "Gesture-first navigation for Vue applications",
+ theme_color: "#0b0d12",
+ background_color: "#0b0d12",
+ display: "standalone",
+ scope: "/",
+ orientation: "any",
+ start_url: "/",
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-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
- { src: '/app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
+ {
+ src: "/pwa-192.png",
+ sizes: "192x192",
+ 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: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
- navigateFallback: '/index.html',
- globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
+ navigateFallback: "/index.html",
+ globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),
],
resolve: {
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: /^@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') },
+ {
+ find: /^@\/(.*)$/,
+ replacement: `${path.resolve(__dirname, "apps/demo/src")}/$1`,
+ },
+ {
+ 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: {
- outDir: path.resolve(__dirname, 'apps/demo/dist'),
+ outDir: path.resolve(__dirname, "apps/demo/dist"),
emptyOutDir: true,
},
-})
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index efc84ce..6a4149e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,9 +1,9 @@
-import { defineConfig } from 'vitest/config'
+import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
- environment: 'happy-dom',
- include: ['packages/**/*.test.ts'],
- coverage: { reporter: ['text', 'html'] },
+ environment: "happy-dom",
+ include: ["packages/**/*.test.ts"],
+ coverage: { reporter: ["text", "html"] },
},
-})
+});