npm.io
0.1.0 • Published 12h ago

vue-suspense-route

Licence
MIT
Version
0.1.0
Deps
0
Size
44 kB
Vulns
0
Weekly
0

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
  • 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.

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.

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.
  • 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.
  • 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.

License

MIT

Keywords