Adapters

Vue Router

Connect Vue Router to Ginjou's router contract, and how the route blocker behaves with nested routes, aliases, and KeepAlive.

@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

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.

App.vue
<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.

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:

NavigationWhat Vue Router doesHold it?
/posts/1/edit/posts/2/editReuses the component, but everything derived from the id refetches and refills the form.Yes
/posts/1/edit/posts/1/edit/previewEnters 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 under aliasOf, 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.

Copyright © 2026