vue-leave-guards
v1.0.0
Published
Nested unsaved-changes guards for Vue 3: scopes that nest, so a dirty form inside a modal inside a drawer collapses into one route guard and one beforeunload.
Maintainers
Readme
vue-leave-guards
Register an unsaved-changes guard anywhere in a Vue app and it is asked before the user leaves — however deeply it is nested, and whatever "leave" means at that depth.

Install
npm i vue-leave-guards// main.ts
import { createLeaveGuards } from 'vue-leave-guards/router'
app.use(router).use(createLeaveGuards({ router, confirm: () => myConfirmDialog() }))<script setup lang="ts">
import { ref } from 'vue'
import { useLeaveGuard } from 'vue-leave-guards'
const draft = ref('')
useLeaveGuard({ isDirty: () => draft.value !== '' })
</script>That is the whole contract. The field says only whether it is dirty; the dialog belongs to whichever scope encloses it, so a form full of fields asks once. The component does not know whether it sits on a route, in a modal, or three overlays deep.
Scopes nest
onBeforeRouteLeave only fires for components a route names, so a dirty form
inside a modal inside a drawer is invisible to it. Here, any host that can
dismiss itself opens a scope:
<script setup lang="ts">
import { provideLeaveGuards } from 'vue-leave-guards'
const { confirmLeave, dirty } = provideLeaveGuards()
async function close() {
if (!(await confirmLeave())) return
emit('close')
}
</script>- Guards inside reach the nearest scope, by injection.
- Each scope registers with its parent as one composite guard, so a tree of any depth collapses into a single entry at the root.
- Closing a modal asks only that modal's subtree. Navigating asks everything.
- The app ends up with one route guard and one
beforeunloadlistener, regardless of how many forms are mounted.
That last point is not a micro-optimisation: window.onbeforeunload is a
singleton, so per-form listeners overwrite each other and null each other out on
unmount.
One dialog, or one each
Where you put confirm decides how many prompts a leave produces.
On the scope — the usual shape for a form. Each field reports dirtiness and nothing else; the host owns the one dialog, and leaving with six unsaved fields asks once:
// the host
provideLeaveGuards({ confirm: () => askOnce() })
// each field, anywhere below it
useLeaveGuard({ isDirty: () => draft.value !== saved.value })On the guard — for a form that genuinely needs its own wording. Guards that
bring a confirm are asked individually, in turn, bailing at the first refusal
so the dialogs never overlap:
useLeaveGuard({ isDirty, confirm: () => askAboutThisOne() })The two mix: a scope's prompt covers everything below it that did not bring one,
and is asked first. Two different confirm functions cannot be merged into a
single dialog — to ask once, hand the question up rather than defining both.
A scope opened purely for structure, holding only reporters, defers to whichever ancestor can actually ask.
isDirty is separate, and synchronous, because beforeunload cannot await a
dialog — browsers only accept a synchronous preventDefault(), and ignore any
message you pass. It is also what dirty reads, so a header can show an unsaved
marker for everything below it. A guard with isDirty and no confirm anywhere
above it warns on tab close and permits in-app navigation, which is the right
behaviour for a draft the server already holds.
Narrowing a guard
shouldGuard decides which navigations a guard cares about. It is consulted
only for navigations; a host closing itself asks everything.
useLeaveGuard<RouteLocationNormalized>({
confirm: askTheUser,
shouldGuard: (to, from) => to.name !== from.name,
})Without a router
The core imports nothing but vue. Skip the plugin and own the scope yourself:
import { createReactiveLeaveGuardScope, leaveGuardsKey } from 'vue-leave-guards'
const scope = createReactiveLeaveGuardScope()
app.provide(leaveGuardsKey, scope.registry)createLeaveGuards({ beforeUnload: true }) with no router is the same thing
with the unload listener already wired.
API
vue-leave-guards
| Export | Description |
| --- | --- |
| useLeaveGuard(guard) | Registers a guard with the nearest scope, for as long as the calling scope lives. A bare function guards every navigation and reports nothing to beforeunload. Returns { unregister, registered }. |
| provideLeaveGuards(options?) | Opens a scope at this component and returns it. Takes the confirm that speaks for everything below it. Registers with the enclosing scope, if any. |
| createReactiveLeaveGuardScope(options?) | A scope with no component attached, for a plugin or a store. |
| createLeaveGuardScope(options?) | The same, without Vue reactivity — no framework import at all. |
| leaveGuardsKey | The injection key, for providing a registry by hand. |
LeaveGuardEntry
| Field | Type | Description |
| --- | --- | --- |
| confirm | (ctx?) => boolean \| Promise<boolean> | This guard's own prompt; false aborts the leave. Omit to defer to the scope's. |
| isDirty | () => boolean | Synchronous. Feeds beforeunload, dirty, and whether the scope's prompt is asked. Absent means never dirty. |
| shouldGuard | (to, from) => boolean | Absent means every navigation. |
Scope
| Field | Type | Description |
| --- | --- | --- |
| confirmLeave(ctx?) | Promise<boolean> | Asks the subtree. Without a context, asks everything. |
| dirty | ComputedRef<boolean> | True while anything below reports unsaved changes. |
| size | ComputedRef<number> | Live entry count; a nested scope counts as one. |
| isDirty() | boolean | The non-reactive read, for guards whose state Vue cannot see. |
| registry | LeaveGuardRegistry | What gets provided to descendants. |
| composite | LeaveGuardEntry | This scope as one entry, for a parent. |
vue-leave-guards/router
createLeaveGuards(options?) returns a Vue plugin carrying its root scope.
| Option | Default | Description |
| --- | --- | --- |
| router | — | Guards navigation. Omit to guard only beforeunload. |
| confirm | — | The application's one prompt, for guards that bring none. |
| beforeUnload | true | Warns before reload and tab close while anything is dirty. |
| window | globalThis.window | For iframes, tests and SSR. |
Also exported: RouteLeaveGuard, RouteLeaveGuardEntry, RouteLeaveGuardScope,
RouteNavigationContext — the core types with vue-router's route filled in.
Notes
vue-routeris an optional peer. The core never imports it, not even for a type.useLeaveGuardwarns rather than throwing when it finds no scope. A guard that silently no-ops is how an unguarded form ships.dirtytracks guards mounting and unmounting, and the reactive state each one reads. A guard reading something Vue cannot see needsisDirty().- ESM only.
Contributing
See CONTRIBUTING.md.
Licence
MIT
