diff --git a/README.md b/README.md
index 5dec5be..1d1dca6 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,8 @@ Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is de
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.
+Call `nativeRouter.unload('/some-route')` to manually unmount inactive instances of one location while retaining their lightweight history descriptors. The active route and views participating in a transition are protected.
+
## 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 8603964..e927187 100644
--- a/apps/demo/e2e/navigation.spec.ts
+++ b/apps/demo/e2e/navigation.spec.ts
@@ -197,6 +197,19 @@ test('renders a suspended pushed sibling and evicts it after backing out', async
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$/)
+
+ 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)
+})
+
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')
@@ -246,6 +259,22 @@ test('evicts a cached sibling when its dynamic entry guard rejects it', async ({
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)
+
+ 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)
+})
+
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/src/style.css b/apps/demo/src/style.css
index 3650e96..a8548d9 100644
--- a/apps/demo/src/style.css
+++ b/apps/demo/src/style.css
@@ -52,6 +52,17 @@ html[data-pwa-edge-guard="active"] body {
left: 0;
}
+/* 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) {
+ backdrop-filter: none;
+}
+
+.app-frame:has(.nvr-router-view--interactive) .app-tabs {
+ background: rgba(11, 13, 18, .98);
+}
+
.screen {
width: 100%;
height: 100%;
diff --git a/apps/demo/src/views/SettingsView.vue b/apps/demo/src/views/SettingsView.vue
index a6adc28..1904416 100644
--- a/apps/demo/src/views/SettingsView.vue
+++ b/apps/demo/src/views/SettingsView.vue
@@ -1,4 +1,5 @@
@@ -32,6 +39,7 @@ const native = useNativeRouter()
Sibling tabs are created on first visit, then retained. Back-stack screens stay warm only while they remain useful as a predictive-back target.
+
Simulation
diff --git a/docs/architecture.md b/docs/architecture.md
index f04edaf..649737a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -16,7 +16,7 @@ Transactions move through `interactive`, `committing`, `settling`, and cancellat
The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.
-Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. Reduced-motion mode settles immediately.
+Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. When a destination needs a new component tree, the runtime resolves its lazy route, mounts it, and gives the browser a preparation frame before motion starts; cached destinations skip that wait. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. Reduced-motion mode settles immediately.
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
@@ -59,7 +59,9 @@ Applications can call `beginInteractive()`, `updateInteractive()`, and `finishIn
The cache is lazy: application startup mounts the current route, not every sibling. A replace-style sibling is created when it is first visited or previewed and can then remain mounted without becoming a browser-back entry. Recent history targets can also stay warm so predictive Back restores component-local state such as a scrolled list immediately.
-The default limit is four inactive views per runtime. `cache: false` always unmounts an inactive route, while `cache: 'pin'` exempts it from ordinary LRU and manual trimming. A pushed detail route that is popped or dismissed is unmounted after its exit animation unless it is explicitly pinned. If a guard rejects a cached destination, that component tree is evicted because it is no longer a valid navigation target. Older entries keep lightweight route descriptors and are lazily reconstructed if history reaches them again.
+The default limit is four inactive views per runtime. `cache: false` always unmounts an inactive route, while `cache: 'pin'` exempts it from ordinary LRU and default bulk trimming. A pushed detail route that is popped or dismissed is unmounted after its exit animation unless it is explicitly pinned. If a guard rejects a cached destination, that component tree is evicted because it is no longer a valid navigation target. Older entries keep lightweight route descriptors and are lazily reconstructed if history reaches them again.
+
+Applications can explicitly release an inactive location with `nativeRouter.unload('/stories')`. It returns the number of component instances unmounted, retains history descriptors, and refuses to unload the active route or either side of an in-progress transition. `trimCache()` remains the bulk operation.
This is deliberately not implemented with a single Vue ``. An interactive transition must render the current and destination route instances concurrently, while one `` outlet normally activates one selected child. Separate temporary wrappers would themselves be removed and lose their caches. The runtime therefore owns the small multi-view cache and exposes equivalent route-aware lifecycle signals:
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index b08864b..88e247f 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -76,6 +76,8 @@ Built-in presentations include push, reveal, adjacent-page slide, fade, modal, s
The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry.
+First use has unavoidable setup work—downloading/evaluating a lazy chunk and mounting/layout of its Vue tree—but it does not need to compete with the transition. The runtime completes lazy resolution first, then gives newly mounted destinations a browser preparation frame before advancing progress. Full-surface dimming uses a composited opacity overlay rather than a changing CSS filter so WebKit does not repeatedly rasterize the route subtree.
+
## Gesture ownership
Gesture recognition uses Pointer Events and waits for clear directional intent before claiming a pointer. Vertical scrolling remains available through `touch-action`, while form controls, editable content, and elements marked with `data-native-gesture="ignore"` are excluded.
diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts
index 1d1b121..001dce0 100644
--- a/packages/core/src/runtime.test.ts
+++ b/packages/core/src/runtime.test.ts
@@ -160,6 +160,56 @@ describe('native router transactions', () => {
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()
+
+ 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()
+ })
+
+ 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 })
+
+ 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 })
+
+ 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)
+ })
+
it('evicts the least-recently-used inactive view when the cache limit is exceeded', async () => {
let clock = 0
vi.spyOn(performance, 'now').mockImplementation(() => ++clock)
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index 21b997e..eed2f7f 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -1,5 +1,6 @@
import {
computed,
+ nextTick,
ref,
shallowRef,
type App,
@@ -226,6 +227,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
let target: NativeViewEntry | undefined
let synthetic = false
+ let needsMount = false
const isBack = kind === 'pop' || kind === 'dismiss'
if (isBack) {
@@ -239,6 +241,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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.preload(target.route.fullPath)
@@ -247,6 +250,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
target.mounted = true
target.status = 'inactive'
target.evictionReason = undefined
+ needsMount = true
this.touchEntries()
}
} else {
@@ -255,8 +259,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (resolved.fullPath === from.route.fullPath) return null
const route = await loadRouteLocation(resolved)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
- target = this.findReusable(route.fullPath)
+ 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'
@@ -266,6 +273,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
this.touchEntries()
} else {
target = entryFor(route, 'preview')
+ needsMount = true
this.mutableEntries.value = [...this.mutableEntries.value, target]
}
}
@@ -300,6 +308,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
this.mutableTransaction.value = transaction
if (synthetic) target.synthetic = true
+ if (needsMount) {
+ await this.prepareMountedView()
+ if (!this.isCurrent(transaction.id)) return null
+ }
return transaction.id
}
@@ -353,6 +365,21 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
this.finalizeCancelled(current)
}
+ unload(to: RouteLocationRaw) {
+ 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 (unloaded) this.touchEntries()
+ return unloaded
+ }
+
trimCache(options: { includePinned?: boolean; reason?: NativeEvictionReason } = {}) {
const reason = options.reason ?? 'trimmed'
for (const entry of this.mutableEntries.value) {
@@ -493,22 +520,33 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (!target) return true
if (transaction.kind === 'pop' || transaction.kind === 'dismiss') {
if (target.synthetic) return await this.router.replace(target.route.fullPath)
- return await new Promise((resolve) => {
- this.pendingPop = resolve
- this.router.back()
- window.setTimeout(() => {
- if (this.pendingPop === resolve) {
- this.pendingPop = undefined
- resolve()
- }
- }, 1200)
- })
+ return await this.navigateHistory(-1)
+ }
+ if (transaction.replace) {
+ 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 transaction.replace
? await this.router.replace(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)
+ window.setTimeout(() => {
+ if (this.pendingPop === resolve) {
+ this.pendingPop = undefined
+ resolve()
+ }
+ }, 1200)
+ })
+ }
+
private acceptRoute(to: RouteLocationNormalizedLoaded, _from: RouteLocationNormalizedLoaded) {
const transaction = this.mutableTransaction.value
let target = transaction ? this.entryByKey(transaction.toKey) : undefined
@@ -550,7 +588,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
return
}
if (transaction?.replace) {
- this.mutableHistoryKeys.value = [...history.slice(0, -1), target.key]
+ 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)
@@ -631,6 +672,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
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()))
+ }
+
private animateProgress(target: number, initialVelocity: number) {
const transaction = this.mutableTransaction.value
if (!transaction) return Promise.resolve()
diff --git a/packages/core/src/style.css b/packages/core/src/style.css
index 62ff4d3..c4676eb 100644
--- a/packages/core/src/style.css
+++ b/packages/core/src/style.css
@@ -37,6 +37,19 @@ body,
will-change: transform, opacity, border-radius;
}
+/* A composited overlay avoids applying a changing CSS filter to an entire
+ route subtree, which is especially expensive in iOS WebKit. */
+.nvr-view::after {
+ position: absolute;
+ z-index: 2147483646;
+ inset: 0;
+ background: #000;
+ content: '';
+ opacity: 0;
+ pointer-events: none;
+ will-change: opacity;
+}
+
.nvr-view--active {
z-index: 2;
}
@@ -58,25 +71,32 @@ body,
.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);
- filter: brightness(calc(1 - 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) * .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 {
z-index: 4;
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
- box-shadow: -18px 0 42px rgba(0, 0, 0, calc(var(--native-progress) * .28));
+ box-shadow: -18px 0 42px rgba(0, 0, 0, .28);
}
.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, calc((1 - var(--native-progress)) * .25));
+ box-shadow: -18px 0 42px rgba(0, 0, 0, .25);
}
.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);
- filter: brightness(calc(.88 + 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)) * .12);
}
/* Sibling routes are adjacent pages, not a foreground/background stack. */
@@ -99,7 +119,6 @@ body,
.nvr-router-view[data-native-presentation="slide"] :is(.nvr-view--from, .nvr-view--to) {
z-index: 3;
box-shadow: none;
- filter: none;
}
.nvr-router-view[data-native-presentation="modal"] .nvr-view--to,
@@ -114,7 +133,11 @@ body,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from {
transform: scale(calc(1 - var(--native-progress) * .04));
border-radius: calc(var(--native-progress) * 18px);
- filter: brightness(calc(1 - var(--native-progress) * .24));
+}
+
+.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);
}
.nvr-router-view[data-native-presentation="fade"] .nvr-view--from {
@@ -140,7 +163,6 @@ body,
z-index: 4;
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
border-radius: 22px 22px 0 0;
- filter: none;
}
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--to,
@@ -148,10 +170,13 @@ body,
z-index: 2;
transform: scale(calc(.96 + var(--native-progress) * .04));
border-radius: calc((1 - var(--native-progress)) * 18px);
- filter: brightness(calc(.76 + var(--native-progress) * .24));
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);
+}
+
@media (prefers-reduced-motion: reduce) {
.nvr-view { will-change: auto; }
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 2294630..7740aaf 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -25,6 +25,7 @@ export type NativeEvictionReason =
| 'cache-limit'
| 'navigation-rejected'
| 'popped'
+ | 'manual'
| 'trimmed'
| 'memory-pressure'
@@ -161,6 +162,8 @@ export interface NativeRouterRuntime {
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
/** Unmount inactive cached views while retaining route/history descriptors. */
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void
registerPresentation(definition: NativePresentationDefinition): void