leave - greydragon888/real-router GitHub Wiki

router.subscribeLeave()

Subscribe to confirmed route departures. Fires after canDeactivate guards pass but before canActivate guards run — guaranteeing the callback only executes when the user has actually left.

API

router.subscribeLeave(listener: LeaveFn): Unsubscribe

type LeaveFn = (state: LeaveState) => void | Promise<void>
type LeaveState = { route: State; nextRoute: State; signal: AbortSignal }
  • route — the route being departed (current route at the time of the callback)
  • nextRoute — the destination route
  • signalAbortSignal for cooperative cancellation (aborted on concurrent navigation, router.stop(), or router.dispose())
  • Returns an Unsubscribe function
  • Returning a Promise<void> blocks the navigation pipeline until resolved — activation guards run only after all leave listeners complete

When It Fires

Scenario Fires?
canDeactivate returns true ✅ Yes
canDeactivate returns false ❌ No
No canDeactivate guard (zero-guard path) ✅ Yes
First navigation (no previous route) ❌ No
navigateToNotFound() ❌ No

When to Use

Use subscribeLeave() for side-effects that must only run when a route departure is confirmed:

  • Exit animations (async) — animate out the current page before the new one mounts
  • View Transitions API (async) — coordinate CSS transitions with route changes
  • Scroll position preservation (sync)
  • Form draft saving (sync)
  • Fetch request abort (sync)
  • Analytics / time-on-page tracking (sync)

Contract: async subscribeLeave is intended for short-lived navigation side-effects (exit animations, View Transitions). For server requests use guards; for data fetching use plugins. An async listener blocks the navigation pipeline — a long-running listener means a stuck navigation.

Comparison with subscribe()

subscribeLeave() subscribe()
Fires Before state changes After state changes
Timing LEAVE_APPROVED phase TRANSITION_SUCCESS
router.getState() Still returns current route Returns new route
Guaranteed Only when departure confirmed On every successful navigation
Use for Side-effects on leaving Side-effects on arriving

Framework Examples

React / Preact

import { useEffect } from "react";
import { useNavigator } from "@real-router/react";

function SettingsPage() {
  const navigator = useNavigator();
  const [draft, setDraft] = useState("");

  useEffect(
    () =>
      navigator.subscribeLeave(({ route }) => {
        if (route.name === "settings" && draft) {
          localStorage.setItem("settings:draft", draft);
        }
      }),
    [navigator], // subscribe once — draft read via closure ref pattern
  );
}

Vue

<script setup>
import { watchEffect } from "vue";
import { useNavigator } from "@real-router/vue";

const navigator = useNavigator();

watchEffect((onCleanup) => {
  const unsub = navigator.subscribeLeave(({ route }) => {
    if (route.name === "settings") {
      sessionStorage.setItem("scroll", String(window.scrollY));
    }
  });
  onCleanup(unsub);
});
</script>

Solid

import { createEffect, onCleanup } from "solid-js";
import { useNavigator } from "@real-router/solid";

function Page() {
  const navigator = useNavigator();
  createEffect(() => {
    const unsub = navigator.subscribeLeave(({ route }) => {
      if (route.name === "reports") {
        sessionStorage.setItem("scroll", String(window.scrollY));
      }
    });
    onCleanup(unsub);
  });
}

Svelte 5

<script>
  import { useNavigator } from "@real-router/svelte";
  const navigator = useNavigator();

  $effect(() => {
    return navigator.subscribeLeave(({ route }) => {
      if (route.name === "settings") {
        localStorage.setItem("draft", displayName);
      }
    });
  });
</script>

router.isLeaveApproved()

Query whether the FSM is currently in the LEAVE_APPROVED phase:

router.isLeaveApproved(): boolean

Returns true only between deactivation and activation guard execution. Useful in plugins that need to distinguish sub-phases of a navigation.

Async Examples

Exit animation

router.subscribeLeave(async ({ signal }) => {
  await animateOut(document.querySelector(".page"), { signal });
});

View Transitions API

router.subscribeLeave(async () => {
  await document.startViewTransition(() => {
    /* ... */
  }).finished;
});

With timeout protection

router.subscribeLeave(async ({ signal }) => {
  const timeoutSignal = AbortSignal.timeout(500);
  const combined = AbortSignal.any([signal, timeoutSignal]);
  await animateOut(element, { signal: combined });
});

Error Handling

Errors in subscribeLeave() callbacks propagate as TRANSITION_ERROR. Each listener runs independently — a failure in one does not prevent others from executing (Promise.allSettled semantics). The first error is re-thrown after all listeners complete.

router.subscribeLeave(({ route }) => {
  try {
    analytics.track("page_leave", { from: route.name });
  } catch {
    // analytics failure must not block navigation
  }
});

Signal cancellation

When a concurrent navigation starts during an async leave listener, the signal is aborted. Well-behaved listeners should check signal.aborted or pass signal to cancellable APIs:

router.subscribeLeave(async ({ signal }) => {
  if (signal.aborted) return;
  await fadeOut(element, { signal }); // throws AbortError if signal fires
});

See Also

⚠️ **GitHub.com Fallback** ⚠️