npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

vue-leave-guards

CI npm license

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.

Try it →

Three nested hosts, each holding unsaved work, and the single dialog they produce between them

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 beforeunload listener, 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-router is an optional peer. The core never imports it, not even for a type.
  • useLeaveGuard warns rather than throwing when it finds no scope. A guard that silently no-ops is how an unguarded form ships.
  • dirty tracks guards mounting and unmounting, and the reactive state each one reads. A guard reading something Vue cannot see needs isDirty().
  • ESM only.

Contributing

See CONTRIBUTING.md.

Licence

MIT