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-suspense-route

v0.4.0

Published

Suspense-aware routing for Vue 3: a drop-in <SuspenseRouterView> and useSuspenseRoute() that keep the visible page on the route it was rendered for during <Suspense> transitions. Inspired by Nuxt's RouteProvider.

Readme

vue-suspense-route

Suspense-aware routing for Vue 3.

A drop-in <SuspenseRouterView> and useSuspenseRoute() that keep the visible page on the route it was rendered for during <Suspense> transitions — inspired by Nuxt's RouteProvider, packaged for plain Vue + Vue Router apps.

npm install vue-suspense-route
  • Zero dependencies (peer deps: vue >= 3.3, vue-router >= 4.4)
  • No spinners by default: the previous page stays fully interactive until the next page's async setup() has resolved (YouTube-style navigation)
  • No undefined params, ever: a component sees the route it was rendered for, for its entire lifetime
  • Layouts persist: nested layouts are keyed by their own route segment, so navigating between sibling pages doesn't remount (or refetch) the layouts above them
  • Query is in-page state: query/hash changes never remount — they update the visible page in place, so a ?search= input keeps focus and nothing refetches (opt into reload-on-query per view when you want it)
  • Writable query state that doesn't glitch: useSuspenseRouteQuery() (a drop-in for @vueuse/router's useRouteQuery) reads the frozen route and writes the live one, so a leaving page's filters don't flash to defaults mid-transition
  • Optional error boundary: a failed page renders your #error slot instead of hanging navigation
  • Composes with <Transition> / <KeepAlive>: a default scoped slot hands you the keyed page so you can wrap it, RouterView-style
  • Typed routes: useSuspenseRoute('/projects/[projectId]') mirrors useRoute's own typing when you use vue-router's typed routes / unplugin-vue-router
  • No browser globals touched; tree-shakeable, ESM + CJS, TypeScript-first

The problem

When you combine <RouterView> with <Suspense> and pages with async setup():

  1. Vue Router updates route.params the instant navigation commits.
  2. <Suspense> keeps the old page on screen until the new page's async setup() resolves.
  3. During that window the still-visible old page observes the new params — often undefined. RouterLinks throw (Missing required param), param-dependent computeds crash, and a thrown error can hang navigation entirely.
Navigation: /projects/proj-1 → /projects

BEFORE:    URL=/projects/proj-1 | Visible: ProjectPage      | route.params.projectId = 'proj-1'
PENDING:   URL=/projects        | Visible: ProjectPage (!)  | route.params.projectId = undefined  ← crash
RESOLVED:  URL=/projects        | Visible: ProjectsList     |

The fix (instance isolation)

<SuspenseRouterView> wraps each page in a RouteProvider keyed per navigation. Every navigation therefore mounts a brand-new provider instance, and <Suspense> retains the previous instance — untouched — until the new page resolves. A retained instance never receives prop updates, so it keeps pointing at exactly the route it was created with.

That retained reference is the freeze: no pending flags, nothing to flush — and therefore no first-render window where stale content can flash. useSuspenseRoute() simply reads the route its nearest provider captured.

Navigation: /projects/proj-1 → /projects

BEFORE:    Visible: instance A | useSuspenseRoute().params.projectId = 'proj-1'
PENDING:   Visible: instance A | useSuspenseRoute().params.projectId = 'proj-1'  ← frozen
           (B mounting hidden) | useRoute().params.projectId = undefined         ← live route moved on
RESOLVED:  Visible: instance B | useSuspenseRoute().params = {}

Quick start

Replace your <RouterView>/<Suspense> combination:

<!-- Before -->
<router-view v-slot="{ Component }">
  <Suspense>
    <component :is="Component" />
    <template #fallback>Loading…</template>
  </Suspense>
</router-view>

<!-- After -->
<SuspenseRouterView>
  <template #fallback>Loading…</template>
</SuspenseRouterView>
<script setup lang="ts">
import { SuspenseRouterView } from 'vue-suspense-route'
</script>

And in pages/layouts, read the route through the suspense-aware composables:

// Before — crashes during transitions:
import { useRoute } from 'vue-router'
const route = useRoute()
const projectId = computed(() => route.params.projectId) // undefined mid-transition!

// After — stable for the component's lifetime:
import { useSuspenseParam } from 'vue-suspense-route'
const projectId = useSuspenseParam('projectId')

Nested layouts nest naturally — each layout that renders children adds its own boundary:

<!-- ProjectLayout.vue -->
<template>
  <ProjectHeader :project-id="projectId" />
  <SuspenseRouterView />
</template>

A child SuspenseRouterView resolves against its parent's (possibly frozen) route, so the whole visible tree stays consistent during a transition.

Keying: what remounts, when

Every page wrapper is keyed, and the key is always scoped to the matched route record — navigating across records always remounts (and therefore always freezes). Within a record, every level — leaf pages and ancestor layouts alike — is keyed by its own interpolated path segment (/projects/:projectId/projects/proj-1):

  • A level only remounts when a param it matches on changes.
  • Query and hash are never part of the key. A ?search=/#section change updates the retained instance in place — no remount, no re-run of async setup(), no lost focus, scroll, or layout state. The visible page sees the new query reactively via useSuspenseRoute().

So for /projects/proj-1/workspaces/a → /projects/proj-1/workspaces/b, only the workspace page remounts; both layouts above it stay mounted and their useSuspenseRoute() follows the new location reactively. And /list?search=a → /list?search=b never remounts the leaf at all — a search input bound to ?search= keeps focus.

route-key — overriding the within-record key

Pass route-key (the equivalent of Nuxt's pageKey) to control remounts within a record:

<!-- Opt into reload-on-query: include the query/hash so the leaf remounts
     (and freezes) on query changes — the "load everything, then swap" model -->
<SuspenseRouterView :route-key="route => route.fullPath" />

<!-- Static key: one instance per route record, all changes applied in place -->
<SuspenseRouterView route-key="page" />

When the key doesn't change across a navigation, the instance is reused and the route returned by useSuspenseRoute() updates reactively in place — no suspension, no remount. When the key does change (always, across route records), you get the full freeze-and-swap behavior.

In-component guards (onBeforeRouteUpdate)

Vue Router reuses the route record across a param change, so onBeforeRouteUpdate still fires — but under the default keying a param change remounts the page, and the guard runs on the outgoing instance, which is about to be replaced. The common "refetch on param change" guard therefore fetches into an instance that's on its way out, while the fresh instance re-runs its async setup() anyway.

  • Default keying (remount on param change): put param-dependent fetching in async setup() — it re-runs per navigation by design. Don't fetch in onBeforeRouteUpdate.
  • Static route-key (in-place updates): the instance is reused across param changes, so onBeforeRouteUpdate is the natural hook again — or simply watch the route from useSuspenseRoute(), which follows the new location in place.

onBeforeRouteLeave is unaffected either way.

API

<SuspenseRouterView>

<SuspenseRouterView
  name="default"                    <!-- RouterView name, for named views -->
  :timeout="-1"                     <!-- Suspense timeout (-1 keeps the current view until ready) -->
  :suspensible="true"               <!-- Let a parent Suspense await this one -->
  :route-key="route => route.fullPath" <!-- Optional: within-record remount key; this opts into reload-on-query (see above) -->
  @pending="onPending"              <!-- A transition started -->
  @resolve="onResolve"              <!-- The incoming page resolved (also fires when the error view swaps in) -->
  @fallback="onFallback"            <!-- The fallback was shown -->
  @error="(err, info, reset) => …"  <!-- A descendant setup/render threw (always emitted) -->
>
  <template #fallback>
    <LoadingSpinner />
  </template>

  <!-- Optional: React-style error boundary. The #error SLOT is what activates
       the boundary — with it, a failed page renders this instead of hanging
       navigation, and the boundary auto-recovers when its route changes.
       Without it, errors propagate normally (to an ancestor boundary or
       app.config.errorHandler). -->
  <template #error="{ error, info, reset }">
    <ErrorState :error="error" @retry="reset" />
  </template>
</SuspenseRouterView>

Error semantics. @error is a notification: it always fires when a descendant's setup/render throws, whether or not this view is a boundary — safe for telemetry. The #error slot is what makes the view a boundary: the failed page is replaced by the slot content (rendered inside the same Suspense, so suspensible parents resolve normally and isPending ends up false), the error stops propagating, and reset() — or any navigation that changes this view's route — re-attempts.

Default scoped slot: <Transition> and <KeepAlive>

Like RouterView, the default slot receives the page to render so you can wrap it:

<SuspenseRouterView v-slot="{ Component, route }">
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="Component" />
    </KeepAlive>
  </Transition>
</SuspenseRouterView>

Component here is the Suspense-wrapped, keyed page (undefined when nothing matched), and route is the route this view resolved against. Wrappers must sit outside the Suspense — which is exactly what this slot gives you. Vue's <Transition> and <KeepAlive> both unwrap a <Suspense> child automatically (the same mechanism behind the Vue docs' RouterView → Transition → KeepAlive → Suspense pattern), so transitions and caching key off the page inside, not the Suspense wrapper. Don't wrap the page inside your own Suspense instead: a stable wrapper vnode there would make Suspense patch in place rather than retain the old page, silently disabling the freeze.

When combining the default slot with other slots, use explicit <template> blocks:

<SuspenseRouterView>
  <template #default="{ Component }">
    <Transition name="fade"><component :is="Component" /></Transition>
  </template>
  <template #fallback>Loading…</template>
</SuspenseRouterView>

useSuspenseRoute(name?)

Drop-in replacement for useRoute(). Returns the route the current component was rendered for — stable across Suspense transitions. Outside a SuspenseRouterView it falls back to the live useRoute().

const route = useSuspenseRoute()

// With typed routes (vue-router / unplugin-vue-router), the name argument is
// type-only and the return type is derived from useRoute itself:
const route = useSuspenseRoute('/projects/[projectId]')
route.params.projectId // ← typed

The returned route is a stable wrapper object, not the vue-router route itself. Compare locations by value (route.fullPath === router.currentRoute.value.fullPath), not by object identity. (Nuxt's useRoute has the same property.)

useSuspenseParam(name) / useSuspenseParams(names)

const projectId = useSuspenseParam('projectId')
// ComputedRef<string | undefined> — stable during transitions,
// repeatable params ( /:id+ ) are coerced to their first element

const { projectId, workspaceId } = useSuspenseParams(['projectId', 'workspaceId'])
// each ComputedRef<string | undefined>

useSuspenseRouteQuery(name, default?, options?)

Writable, suspense-aware query state — a drop-in for @vueuse/router's useRouteQuery that reads from the frozen route but writes to the live router.

const search = useSuspenseRouteQuery<string>('search', '')
const page = useSuspenseRouteQuery('page', 1, { transform: Number }) // Ref<number>

search.value = 'Kelley' // navigates (router.replace by default), preserving other query params

Why it exists: vueuse's useRouteQuery reads the live useRoute(), which the freeze doesn't cover. So when you navigate away from a page that holds query state, the live query empties while the leaving page is still visible, and its filter/input flashes to the default right before the new page mounts:

/suppliers?search=Kelley → /categories   (incoming page is async)

useRouteQuery('search')        → flips to '' on the still-visible suppliers list  ← glitch
useSuspenseRouteQuery('search') → stays 'Kelley' until the suppliers page unmounts ← fixed

It reads through the frozen route (like useSuspenseParam does for path params), so the leaving page stays put. Same-page edits stay fully reactive — a query-only change doesn't alter the page key (query is excluded as of 0.2.0), so the instance is reused and the value updates in place with focus preserved.

  • Mirrors vueuse's signature: name, defaultValue, and { transform, mode }. Use transform: Number (or { get, set }) for typed query state; mode is 'replace' (default) or 'push'.
  • Writes spread the live current query, so concurrent params aren't clobbered.
  • Writes from several refs in the same tick coalesce into one navigation; if any of them asked for mode: 'push', the batch pushes (a requested history entry is never silently dropped).
  • Defaults are matched by value, arrays included — writing [] back to a ref whose default is [] removes the param from the URL, same as a scalar default.
  • Outside a SuspenseRouterView it falls back to the live route, so it's safe to use anywhere.

Pass an optional type parameter to narrow a required param — a drop-in for vueuse's useRouteParams<string>('id') that removes the as ComputedRef<string> cast at the call site. The default stays string | undefined, so optional params remain honest about being absent mid-transition; you opt into narrowing deliberately:

const fileId = useSuspenseParam<string>('fileId')
// ComputedRef<string> — no cast

// For useSuspenseParams, specify the value type alongside the name union:
const { fileId, folderId } = useSuspenseParams<'fileId' | 'folderId', string>([
  'fileId',
  'folderId',
])
// each ComputedRef<string>

This is a type-level assertion only (like useRouteParams<T>): it trusts the type you pass and doesn't change the runtime value.

useSuspenseRouteContext()

const {
  route,      // the stable route (frozen during transitions)
  liveRoute,  // the actual current route (updates the instant navigation commits)
  isPending,  // Readonly<Ref<boolean>> — whether a transition is in progress
} = useSuspenseRouteContext()

liveRoute + isPending are what you want for chrome that should track navigation immediately — progress bars, breadcrumbs, analytics.

Recipes

Top loading bar (YouTube-style):

<SuspenseRouterView
  @pending="bar.start()"
  @resolve="bar.finish()"
  @error="bar.fail()"
/>

Error reporting without per-page try/catch:

<SuspenseRouterView @error="(err, info) => report(err, info)">
  <template #error="{ error, reset }">
    <ErrorPage :error="error" @retry="reset" />
  </template>
</SuspenseRouterView>

Telemetry only (no boundary): an @error listener without an #error slot reports the error and lets it propagate — this view never swallows errors into a blank screen.

Guarantees, assumptions & trade-offs

  • Assumption: Vue Router replaces the route object on navigation rather than mutating it (true of vue-router@4; verified by this package's test suite).
  • Identity: useSuspenseRoute() returns a stable wrapper, so identity comparisons against router.currentRoute.value are always false — compare fullPath instead.
  • Default: every level is keyed by its own interpolated path segment, so query/hash changes update in place rather than remounting (page identity = path + params, never the query). Opt into reload-on-query with :route-key="route => route.fullPath".
  • Depth detection uses vue-router's viewDepthKey, the same mechanism RouterView itself uses, so it stays correct even with route records that have no component at intermediate levels.
  • Dev warnings (dropped from production builds): a once-per-session warning when useSuspenseRoute() (or a composable built on it) runs outside a SuspenseRouterView — the live-route fallback is fine for app chrome, but easy to hit by accident — and one when writing a property on the returned route, which writes through to the shared vue-router route object.
  • SSR: the package touches no browser globals; server rendering and hydration behave the way Vue's <Suspense> does (still flagged experimental by Vue). The freeze itself is only observable client-side, during navigations.
  • The freeze applies to components rendered under a SuspenseRouterView. App chrome outside it (nav bars, breadcrumbs) sees the live route — usually what you want.

Comparison with Nuxt

Nuxt's <NuxtPage> solves this same problem with an internal RouteProvider. If you're on Nuxt, use Nuxt. This package extracts the load-bearing parts of that design — record-scoped keyed providers, per-depth layout keys, Suspense retention — for apps on plain Vue + Vue Router, and adds an opt-in error boundary and transition state on top.

Related reading

License

MIT