Vue Router
@ginjou/with-vue-router implements Ginjou's Router contract on top of Vue Router.
It covers the whole contract, including the optional blocker, so useRouteBlocker and useWarnUnsaved work with nothing extra to wire up.
Installation
pnpm add @ginjou/with-vue-router
yarn add @ginjou/with-vue-router
npm install @ginjou/with-vue-router
bun add @ginjou/with-vue-router
Vue Router is a peer dependency. The package works with Vue 2.7 and Vue 3 through vue-demi.
Setup
Call createRouter() inside setup, once, near the app root. It reads the active Vue Router instance with useRouter() and subscribes to it, so everything it sets up is cleaned up with the scope it was created in.
<script setup lang="ts">
import { defineRouterContext } from '@ginjou/vue'
import { createRouter } from '@ginjou/with-vue-router'
defineRouterContext(createRouter())
</script>
<template>
<RouterView />
</template>
That is everything. Navigation, location state, route sync, and the blocker all work from here.
Advanced usage
Nothing below is required. Each part is for a case the shared contract cannot express on its own.
Navigate by route name
go and resolve pass meta straight to Vue Router, so anything Vue Router's own options accept works:
router.go({
type: 'push',
meta: { name: 'post-edit', params: { id: 1 } },
})
resolve returns Vue Router's href, not a bare path, so it is ready for an <a href> and already carries whatever your history mode adds.
Read Vue Router's own location
Locations coming back carry Vue Router's normalized location under meta.location:
const location = router.getLocation()
location.meta.location.matched // the matched route records
location.meta.location.name // the route name
Use it for anything path, params, query, and hash cannot express. The blocker below is the main reason to reach for it.
Decide when the blocker holds
The blocker runs as a beforeEach guard, next to your own. shouldBlock sees every navigation, and comparing paths is not enough to tell them apart. The path changes in all three of these, and only the first takes the page away:
| Navigation | What Vue Router does | Hold it? |
|---|---|---|
/posts/1/edit → /posts/2/edit | Reuses the component, but everything derived from the id refetches and refills the form. | Yes |
/posts/1/edit → /posts/1/edit/preview | Enters a child route. The form stays mounted. | No, it is a tab switch |
/posts/1 → /p/1 (alias) | Same record and same params under a second path. | No |
meta.location tells them apart with two things a RouterLocation does not carry:
matched— the route records Vue Router resolved. Going deeper still matches everything the current route matches. An alias resolves to its own record, with the original underaliasOf, so compare through that.params— the records can stay the same while the record you show changes, which is the first row.
import type { RouterBlockShouldInput } from '@ginjou/core'
import type { RouteParsedMeta } from '@ginjou/with-vue-router'
import type { RouteRecordNormalized } from 'vue-router'
import { useRouteBlocker } from '@ginjou/vue'
const origin = (record: RouteRecordNormalized): RouteRecordNormalized => record.aliasOf ?? record
function isLeavingRoute(
{ currentLocation, nextLocation }: RouterBlockShouldInput<RouteParsedMeta>,
): boolean {
// An unload has no next location to compare, and leaving the site loses the work either way.
if (nextLocation == null)
return true
const current = currentLocation.meta.location
const next = nextLocation.meta.location
const nextRecords = next.matched.map(origin)
const stays = current.matched.map(origin).every(record => nextRecords.includes(record))
if (!stays)
return true
// Only the params this location already has: a child route introducing a new one is not a leave.
return Object.keys(current.params).some(
key => String(current.params[key]) !== String(next.params[key]),
)
}
const blocker = useRouteBlocker({
shouldBlock: input => isDirty.value && isLeavingRoute(input),
})
useWarnUnsaved compares paths only. It gets the first row right and the other two wrong, so a form with nested tabs or an aliased record wants its own useRouteBlocker with a check like the one above.Closing or reloading the tab is a leave too. While any enabled shouldBlock returns true, the browser shows its own confirmation.
Blocker inside KeepAlive
A cached component is deactivated, not unmounted, so it never gets a chance to clean up on the way out. The adapter handles this for you: a blocker inside a deactivated component is not asked, and is asked again once the component is activated.
The one thing to get right is where you call it. useRouteBlocker has to run during setup, not later, so the adapter can follow the component's activation.