From 92c61abcfe56d927e69f19c79abd249ec39cb5a5 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Tue, 21 Jul 2026 17:56:58 +1000 Subject: [PATCH] Add docs and further stress tests to the demo --- README.md | 10 +- apps/demo/e2e/navigation.spec.ts | 76 +++++++- apps/demo/e2e/pwa.spec.ts | 1 + apps/demo/src/App.vue | 4 +- apps/demo/src/components/AsyncLabData.vue | 16 ++ apps/demo/src/lab-state.ts | 25 +++ apps/demo/src/router.ts | 6 + apps/demo/src/style.css | 9 + apps/demo/src/views/ProfileView.vue | 13 +- apps/demo/src/views/RuntimeLabView.vue | 67 +++++++ docs/architecture.md | 2 + docs/challenges-and-tradeoffs.md | 202 ++++++++++++++++++++++ docs/how-it-works.md | 125 +++++++++++++ docs/principles-and-scalability.md | 201 +++++++++++++++++++++ packages/preset-native/src/index.ts | 9 +- 15 files changed, 752 insertions(+), 14 deletions(-) create mode 100644 apps/demo/src/components/AsyncLabData.vue create mode 100644 apps/demo/src/lab-state.ts create mode 100644 apps/demo/src/views/RuntimeLabView.vue create mode 100644 docs/challenges-and-tradeoffs.md create mode 100644 docs/how-it-works.md create mode 100644 docs/principles-and-scalability.md diff --git a/README.md b/README.md index 45b2f65..f5e2b62 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Run the normal `npm run dev` command, expose its printed network address through The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active, and shows the exact build ID plus update-check count. Production builds check for updates whenever the app starts, returns to the foreground, regains connectivity, or has been open for a minute; activation reload waits for any live gesture to finish. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view. +For more aggressive lifecycle testing, open **You → Runtime stress lab**. It is a deeper route that keeps the primary tab bar, opts into push-style sibling history, exposes how long its route component has remained mounted, and renders a one-second async child through ``. Return to You, enable **Block cached lab re-entry**, and try opening it again to exercise an asynchronous route guard against an already-mounted cached destination. + An installed web app cannot access `WKWebView.allowsBackForwardNavigationGestures`. The demo therefore reserves leading-edge touch sequences at the web-content boundary as an iOS standalone-only safeguard. Capacitor remains the deterministic option when native-level gesture suppression is required. ## Minimal integration @@ -94,7 +96,13 @@ Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is de - `@native-vue-router/capacitor` — hardware back, deep links, pause cancellation, root exit, and haptics. - `@native-vue-router/electron` — Chromium history-gesture suppression and renderer back/forward bridging. -See [architecture](docs/architecture.md) and [platform integration](docs/platforms.md) for the transaction lifecycle and host-specific behavior. +Design and engineering documentation: + +- [How it works and why the pattern is uncommon](docs/how-it-works.md) +- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md) +- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md) +- [Architecture reference](docs/architecture.md) +- [Platform integration reference](docs/platforms.md) ## Support contract diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts index 2e746c3..c96586d 100644 --- a/apps/demo/e2e/navigation.spec.ts +++ b/apps/demo/e2e/navigation.spec.ts @@ -32,14 +32,19 @@ async function waitForTransition(page: Page) { await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/) } -async function flickToNextTab(page: Page) { +async function flickToNextTab(page: Page, leaveSlowSpring = false) { // Start on the route header, outside conversation-owned drag targets. - const frame = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox() - if (!frame) throw new Error('Active route header did not render') - const y = frame.y + frame.height * 0.5 - await page.mouse.move(frame.x + frame.width * 0.68, y) + 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() - await page.mouse.move(frame.x + frame.width * 0.48, y) + 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.27, y) await page.mouse.up() } @@ -146,7 +151,9 @@ test('accepts a second fast tab flick while the first spring is still settling', await page.goto('/inbox') const routerView = page.locator('.nvr-router-view') - await flickToNextTab(page) + // 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/) @@ -162,6 +169,61 @@ test('accepts a second fast tab flick while the first spring is still settling', await expect(routerView).not.toHaveAttribute('data-native-transaction') }) +test('renders a suspended deep sibling and keeps its pushed history entry', 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 }) + + const counter = page.locator('[data-native-route="/profile/runtime-lab"] [data-testid="mounted-seconds"]') + const before = Number(await counter.getAttribute('data-seconds')) + await page.getByRole('button', { name: 'Back' }).click() + await expect(page).toHaveURL(/\/profile$/) + await waitForTransition(page) + + // The route is out of view but still mounted, so its local interval advances. + await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(before) + + // 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-ready')).toBeVisible() +}) + +test('a dynamic guard rejects an already-cached route without destroying it', async ({ page }) => { + await page.goto('/profile') + await page.getByRole('link', { name: /Runtime stress lab/ }).click() + await expect(page).toHaveURL(/\/profile\/runtime-lab$/) + await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 }) + await page.getByRole('button', { name: 'Back' }).click() + await expect(page).toHaveURL(/\/profile$/) + await waitForTransition(page) + + const cachedLab = page.locator('[data-native-route="/profile/runtime-lab"]') + const counter = cachedLab.getByTestId('mounted-seconds') + const beforeBlockedAttempt = Number(await counter.getAttribute('data-seconds')) + await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click() + await expect(page.getByRole('button', { name: 'Block Runtime Lab re-entry' })).toHaveAttribute('aria-pressed', 'true') + + await page.getByRole('link', { name: /Runtime stress lab/ }).click() + await expect(cachedLab.getByTestId('lab-guard-status')).toHaveText('blocked') + await waitForTransition(page) + await expect(page).toHaveURL(/\/profile$/) + await expect(page.getByRole('heading', { name: 'You' })).toBeVisible() + await expect(cachedLab).toHaveCount(1) + await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(beforeBlockedAttempt) + + await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click() + await page.getByRole('link', { name: /Runtime stress lab/ }).click() + await expect(page).toHaveURL(/\/profile\/runtime-lab$/) + await expect(page.getByTestId('async-data-ready')).toBeVisible() + await expect(page.getByTestId('mounted-seconds')).toHaveAttribute('data-seconds', /[2-9]|[1-9]\d+/) +}) + test('opens and dismisses the compose sheet', async ({ page }) => { await page.goto('/inbox') await page.getByRole('button', { name: 'Compose' }).click() diff --git a/apps/demo/e2e/pwa.spec.ts b/apps/demo/e2e/pwa.spec.ts index 26d8c03..8eed930 100644 --- a/apps/demo/e2e/pwa.spec.ts +++ b/apps/demo/e2e/pwa.spec.ts @@ -88,4 +88,5 @@ test('precaches lazily split routes for offline navigation', async ({ page }) => }) 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/App.vue b/apps/demo/src/App.vue index d8045b3..3bf09c0 100644 --- a/apps/demo/src/App.vue +++ b/apps/demo/src/App.vue @@ -6,12 +6,12 @@ import { useRoute } from 'vue-router' import PwaUpdate from './components/PwaUpdate.vue' const route = useRoute() -const siblingRoutes = ['/inbox', '/stories', '/profile'] +const siblingRoutes = ['/inbox', '/stories', '/profile', '/profile/runtime-lab'] const showTabs = computed(() => Boolean(route.meta.tab)) const tabs: NativeTabItem[] = [ { label: 'Inbox', to: '/inbox', icon: '◉' }, { label: 'Stories', to: '/stories', icon: '◎' }, - { label: 'You', to: '/profile', icon: '◇' }, + { label: 'You', to: '/profile', icon: '◇', activeWhen: (current) => current.path.startsWith('/profile') }, ] diff --git a/apps/demo/src/components/AsyncLabData.vue b/apps/demo/src/components/AsyncLabData.vue new file mode 100644 index 0000000..21ef469 --- /dev/null +++ b/apps/demo/src/components/AsyncLabData.vue @@ -0,0 +1,16 @@ + + + + diff --git a/apps/demo/src/lab-state.ts b/apps/demo/src/lab-state.ts new file mode 100644 index 0000000..acb3d1b --- /dev/null +++ b/apps/demo/src/lab-state.ts @@ -0,0 +1,25 @@ +import { readonly, reactive } from 'vue' + +const state = reactive({ + blockEntry: false, + checks: 0, + status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked', +}) + +export const runtimeLabGuard = readonly(state) + +export function setRuntimeLabBlocked(blocked: boolean) { + state.blockEntry = blocked + if (state.status !== 'checking') state.status = 'idle' +} + +/** An intentionally asynchronous, stateful guard used by the stress demo. */ +export async function evaluateRuntimeLabEntry() { + 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 +} + diff --git a/apps/demo/src/router.ts b/apps/demo/src/router.ts index 5853dea..a78e894 100644 --- a/apps/demo/src/router.ts +++ b/apps/demo/src/router.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { evaluateRuntimeLabEntry } from './lab-state' const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/inbox' }, @@ -14,6 +15,11 @@ const routes: RouteRecordRaw[] = [ 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'), + beforeEnter: evaluateRuntimeLabEntry, + 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' } }, diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index 32cb004..0250bbb 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -241,6 +241,15 @@ html[data-pwa-edge-guard="active"] body { .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); } diff --git a/apps/demo/src/views/ProfileView.vue b/apps/demo/src/views/ProfileView.vue index 6e3a76b..8822f6f 100644 --- a/apps/demo/src/views/ProfileView.vue +++ b/apps/demo/src/views/ProfileView.vue @@ -1,6 +1,13 @@