Skip to content

Advanced cases

Navigation guards

Cancel, allow, or redirect visits, reloads, and browser back/forward.

Guards run before an inflight visit is aborted, before any fetch, and before any history or DOM change. They run for every navigation source: visit, replace, reload, get/post/…, server redirect seeds, and popstate.

Register a guard with router.beforeEach. Guards are async-capable, awaited serially in registration order, and the returned function unsubscribes.

const stop = router.beforeEach((to, from) => {
  if (to.source === "reload") return true
  return isDirty() ? "/settings/account?leave=1" : true
})

stop()

Targets and results

to and from are NavigationTargets:

interface NavigationTarget {
  url: string
  method: "get" | "post" | "put" | "patch" | "delete"
  replace: boolean
  source: "visit" | "popstate" | "redirect" | "reload"
}

to is the URL the navigation will request; to.source says where it came from. from is best-effort: the current URL plus the method, replace, and source of the last seed applied to the page (defaults get/visit). At popstate time the browser URL has already moved, so from is the last applied target.

A guard returns:

Result Effect
true, void Allow.
false Cancel. Nothing fetches, no history or DOM change.
string or { redirect } Redirect to the target, resolved against the current location.
thrown / rejected Cancel and surface: a visit promise rejects; a blocked popstate restores and reports the error on blocked.

Redirect re-entry re-runs the registered guards for the target with source: "redirect". A cap of 10 consecutive redirects stops loops.

Ordering

For one navigation: the router.on("before") event fires first, then VisitOptions.onBefore (now async-capable), then beforeEach guards. A cancel or redirect at any stage skips the later stages. PendingVisit gained cancel() and redirect(href), so event-style guards work too.

VisitOptions.force skips the registered beforeEach guards for a navigation the app has already confirmed (for example after a native confirm dialog). The before event and onBefore still run; Link and Form forward both onBefore and force.

Popstate

Back/forward resolves the target seed from history.state and runs guards before the seed is applied. On cancel the pointer is walked back to the entry the mounted page belongs to (history.go(±1) with a skip flag), so no entry is duplicated and forward history is not truncated. A blocked event carries { to, from } so the page can highlight its alert.

router.on("blocked", ({ to, from }) => {
  pendingLeave = to
})

Svelte hooks

useNavigationGuard registers on mount and unregisters on destroy; useUnloadGuard adds a beforeunload prompt while its predicate is true. The other adapters expose the same two hooks under the same names (React, Vue, Solid, Preact), as controllers in Lit, and as injectNavigationGuard / injectUnloadGuard in Angular — see Framework adapters.

<script lang="ts">
  import { useForm, useNavigationGuard, useUnloadGuard } from "@kolektiv/keel-svelte"

  const form = useForm({ name: "" })

  useNavigationGuard((to) => {
    if (to.source === "reload") return true
    return form.isDirty ? confirm("Discard changes?") : true
  })
  useUnloadGuard(() => form.isDirty)
</script>

Exemptions

Prefetch never runs guards — including a prefetch with only / except, whose partial seed is never cached and so cannot satisfy a later navigation. bootstrap() renders the initial seed without running them.

A partial visit (only / except) is an ordinary navigation: guards run before the fetch and see the full target, not just the named data keys. See Partial reloads. The mounted page keeps whatever the guards allowed: canceled or failed popstate attempts restore the entry the mounted page belongs to, so history.state and the DOM stay in sync.