diff --git a/README.md b/README.md index f5e2b62..5dec5be 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ 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. +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 its mount lifetime, and renders a one-second async child through ``. Backing out evicts this pushed screen after its exit; browser Forward reconstructs it and shows the fallback again. To exercise a guard against an already-mounted destination, visit **Stories**, switch to **You**, enable **Block cached Stories re-entry**, and try returning to Stories. The guard rejects and evicts the cached view. 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. @@ -89,6 +89,8 @@ Import `@native-vue-router/core/style.css` for the built-in presentation layers. Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is derived from `siblingOrder`, repeated navigation to the active route is a no-op, and `siblingHistory: 'replace'` keeps cached tab views out of the back stack. +Sibling views are lazy rather than pre-mounted: only the initial route exists on startup, and a sibling joins the bounded cache on its first visit or interactive preview. Route metadata accepts `cache: false` to opt out or `cache: 'pin'` for views that must survive ordinary trimming. `useNativeViewLifecycle()`, the `onNativeView*` hooks, and `useNativeViewActiveEffect()` let cached screens pause polling, media, or subscriptions while retaining their local UI state. + ## Packages - `@native-vue-router/core` — transactions, route ledger, concurrent views, gestures, caching, and public components/composables. diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts index c96586d..8603964 100644 --- a/apps/demo/e2e/navigation.spec.ts +++ b/apps/demo/e2e/navigation.spec.ts @@ -169,7 +169,7 @@ 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 }) => { +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() @@ -179,49 +179,71 @@ test('renders a suspended deep sibling and keeps its pushed history entry', asyn 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')) + 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) - // 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) + // 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) // 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() + 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('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() +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) - 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') + 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: /Runtime stress lab/ }).click() - await expect(cachedLab.getByTestId('lab-guard-status')).toHaveText('blocked') + await page.getByRole('link', { name: /Stories/ }).click() + await expect(page).toHaveURL(/\/stories$/) 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 expect(stories).toHaveAttribute('data-mount-id', mountId!) + await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(hiddenTicks) +}) - 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('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) + + 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 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('opens and dismisses the compose sheet', async ({ page }) => { diff --git a/apps/demo/src/guard-state.ts b/apps/demo/src/guard-state.ts new file mode 100644 index 0000000..34abd68 --- /dev/null +++ b/apps/demo/src/guard-state.ts @@ -0,0 +1,50 @@ +import { readonly, reactive } from 'vue' + +function createGuardState() { + return reactive({ + blockEntry: false, + checks: 0, + status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked', + }) +} + +const storyState = createGuardState() +const labState = createGuardState() + +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' +} + +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 +} + +/** Dynamic guard used to reject a sibling that may already be cached. */ +export function evaluateStoryEntry() { + storyState.checks += 1 + if (!storyState.blockEntry) { + storyState.status = 'allowed' + return true + } + storyState.status = 'checking' + return new Promise((resolve) => { + window.setTimeout(() => { + storyState.status = 'blocked' + resolve(false) + }, 320) + }) +} + +/** Always-allowing asynchronous guard for the deeper stress-lab route. */ +export function evaluateRuntimeLabEntry() { + return evaluate(labState) +} diff --git a/apps/demo/src/lab-state.ts b/apps/demo/src/lab-state.ts deleted file mode 100644 index acb3d1b..0000000 --- a/apps/demo/src/lab-state.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/main.ts b/apps/demo/src/main.ts index 91ab17c..36ad01d 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -17,7 +17,7 @@ const platform = isElectron const nativeRouter = createNativeRouter({ router, - cache: { maxInactive: 8 }, + cache: { maxInactive: 4 }, platform, }) diff --git a/apps/demo/src/router.ts b/apps/demo/src/router.ts index a78e894..5d41113 100644 --- a/apps/demo/src/router.ts +++ b/apps/demo/src/router.ts @@ -1,5 +1,5 @@ import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router' -import { evaluateRuntimeLabEntry } from './lab-state' +import { evaluateRuntimeLabEntry, evaluateStoryEntry } from './guard-state' const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/inbox' }, @@ -9,6 +9,7 @@ const routes: RouteRecordRaw[] = [ }, { path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'), + beforeEnter: evaluateStoryEntry, meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } }, }, { diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index 0250bbb..3650e96 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -260,6 +260,7 @@ html[data-pwa-edge-guard="active"] body { .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); } .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; } diff --git a/apps/demo/src/views/ProfileView.vue b/apps/demo/src/views/ProfileView.vue index 8822f6f..b867c1b 100644 --- a/apps/demo/src/views/ProfileView.vue +++ b/apps/demo/src/views/ProfileView.vue @@ -1,12 +1,12 @@ @@ -25,8 +25,8 @@ function toggleLabGuard() {
Runtime stress lab - ⚙︎Navigation lab Project source diff --git a/apps/demo/src/views/RuntimeLabView.vue b/apps/demo/src/views/RuntimeLabView.vue index d81e682..85795fb 100644 --- a/apps/demo/src/views/RuntimeLabView.vue +++ b/apps/demo/src/views/RuntimeLabView.vue @@ -1,27 +1,26 @@ diff --git a/apps/demo/src/views/SettingsView.vue b/apps/demo/src/views/SettingsView.vue index e8cb6d7..a6adc28 100644 --- a/apps/demo/src/views/SettingsView.vue +++ b/apps/demo/src/views/SettingsView.vue @@ -1,9 +1,11 @@