Skip to content

Navigation Plugin

olegivanov edited this page Apr 22, 2026 · 8 revisions

@real-router/navigation-plugin

1. Overview

  • Name: Navigation Plugin
  • Package: @real-router/navigation-plugin
  • Purpose: Integrates Real Router with the browser Navigation API, synchronizing router state with browser URL and providing route-level history access unavailable in the History API.
  • Typical scenarios: SPAs targeting modern browsers where you need to inspect session history entries, check which routes a user has visited, or jump directly to a past history entry without stepping through intermediate pages.

2. Installation and Setup

npm install @real-router/navigation-plugin
# or
pnpm add @real-router/navigation-plugin
import { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";

const router = createRouter(routes);
router.usePlugin(navigationPluginFactory(options));

Peer dependency: @real-router/core

3. Configuration Options

Option Type Default Description
base string "" Base path for all routes (e.g., "/app")
forceDeactivate boolean true Force deactivation of current route even if canDeactivate returned false

Same two options as browser-plugin. The plugins are interchangeable at the options level.

Looking for hash-based routing? Use @real-router/hash-plugin instead.

Configuration Examples

// Minimal configuration
navigationPluginFactory();

// With base path
navigationPluginFactory({ base: "/app" });
// URL: example.com/app/users

// Disable force deactivation (respect canDeactivate guards on back/forward)
navigationPluginFactory({ forceDeactivate: false });

Options Validation

The plugin performs runtime validation at factory time and throws a plain Error with a descriptive message on any violation.

Type checks

  • Validates option types against defaults (string for base, boolean for forceDeactivate).

base rules (via safeBaseRule from the shared browser-env)

  • Must not contain control characters (\u0000-\u001F, \u007F).
  • Must not contain .. path segments (e.g., /app/../evil).
  • Normalised to canonical form via normalizeBase: leading /, no trailing /, no // runs. "app""/app", "/app/""/app", "//app//""/app", "/""".
navigationPluginFactory({ base: "../evil" });   // throws: must not contain '..' segments
navigationPluginFactory({ base: "/app\nX" });   // throws: must not contain control characters

4. Why Navigation API?

The Navigation API (~89% global coverage as of 2026) gives you access to the full session history as structured data. Unlike the History API, you can inspect every entry, check what routes the user has visited, and traverse directly to a specific past entry.

// Not possible with browser-plugin:
router.peekBack(); // what's one step back?
router.hasVisited("checkout"); // did the user visit checkout?
router.getVisitedRoutes(); // all routes in this session
router.traverseToLast("users.list"); // jump back to the last users list

Browser support: Chrome 102+, Firefox 147+, Safari 26.2+, Opera 88+. For broader support, use @real-router/browser-plugin as a fallback (see Feature Detection).

Desktop compatibility

navigation-plugin runs in desktop WebViews too, but availability depends on the host:

  • Electron — always supported (Chromium ships the Navigation API since Chrome 102, 2022).
  • Tauri Windows / Android — supported (Chromium-based WebView).
  • Tauri macOS / iOS / Linux — depends on the WebKit version. See the full matrix in the Desktop Integration Guide.

On an unsupported WebView the factory throws immediately:

Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.

This is intentional (fail-fast) — the exclusive methods (peekBack, hasVisited, traverseToLast, etc.) cannot be emulated without the API.

5. Lifecycle Hooks

The plugin implements the following hooks:

Hook Implemented Description
onStart Subscribes to Navigation API navigate events
onStop Unsubscribes from navigate events
onTransitionStart Writes captured NavigationMeta to state.context.navigation
onTransitionSuccess Updates NavigationHistoryEntry state and browser URL
onTransitionError Clears capturedMeta and pendingTraverseKey
onTransitionCancel Clears capturedMeta and pendingTraverseKey
teardown Cleans up listeners, removes router extensions

Hook Implementation Details

onStart

  • Subscribes to the Navigation API navigate event
  • Removes existing listener if plugin is restarted

onTransitionSuccess

  • Determines: push (new entry) or replace (replacement) via shouldReplaceHistory
  • replace is used when:
    • First navigation (fromState is absent) and replace is not explicitly false
    • replace: true option
    • reload: true option with equal states
  • Preserves hash fragment when navigating to the same path (or on the first navigation with no fromState)
  • Note: passing replace: false on the first navigation forces push — this is an explicit user override

teardown

  • Removes event listeners
  • Removes start interceptor
  • Removes router extensions (buildUrl, matchUrl, replaceHistoryState, and all exclusive extensions) via extendRouter unsubscribe
  • Releases "navigation" context namespace claim

6. Router Extensions

Compatible Extensions (same as browser-plugin)

The plugin adds the following methods to the router instance via extendRouter():

Method Returns Description
buildUrl(name, params?) string Build full URL with base path
matchUrl(url) State | undefined Parse URL to router state
replaceHistoryState(name, params?) void Update browser URL without triggering navigation. Preserves location.hash — symmetric with onTransitionSuccess
router.buildUrl("users", { id: "123" });
// => "/app/users/123" (with base "/app")

router.matchUrl("/app/users/123");
// => { name: "users", params: { id: "123" }, path: "/users/123" }

// Update URL silently (no transition, no guards)
router.replaceHistoryState("users", { id: "456" });

buildUrl vs buildPath

router.buildPath("users", { id: 1 }); // "/users/1"       — core, no base
router.buildUrl("users", { id: 1 }); // "/app/users/1"   — plugin, with base

replaceHistoryState vs navigate({ replace: true })

router.replaceHistoryState(name, params); // URL only, no transition
router.navigate(name, params, { replace: true }); // Full transition + URL update

Exclusive Extensions (Navigation API only)

These methods are only available with navigation-plugin. They have no equivalent in browser-plugin.

Method Returns Description
peekBack() State | undefined State of the previous history entry
peekForward() State | undefined State of the next history entry
hasVisited(routeName) boolean Whether any history entry matches the route
getVisitedRoutes() string[] Unique route names across all history entries
getRouteVisitCount(routeName) number How many history entries match the route
traverseToLast(routeName) Promise<State> Navigate to the last history entry for a route
canGoBack() boolean Whether there's a previous history entry
canGoForward() boolean Whether there's a next history entry
canGoBackTo(routeName) boolean Whether any previous entry matches the route

peekBack / peekForward

Preview where back/forward would take the user without navigating:

const prev = router.peekBack();
if (prev) {
  console.log(`Back goes to: ${prev.name}`);
}

const next = router.peekForward();
if (next) {
  console.log(`Forward goes to: ${next.name}`);
}

hasVisited / getVisitedRoutes / getRouteVisitCount

Inspect the session history to understand where the user has been:

// Check if the user has been to a route in this session
if (router.hasVisited("checkout")) {
  showResumeCheckoutBanner();
}

// Get all routes visited in this session
const visited = router.getVisitedRoutes();
// => ["home", "users.list", "users.view", "checkout"]

// How many times did the user visit the product page?
const count = router.getRouteVisitCount("products.view");

traverseToLast

Jump directly to the last time the user was on a given route. Skips intermediate entries without stepping through them one by one:

await router.traverseToLast("users.list");

canGoBack / canGoForward / canGoBackTo

Drive UI state for navigation controls:

// Disable back/forward buttons when there's nowhere to go
const backDisabled = !router.canGoBack();
const forwardDisabled = !router.canGoForward();

// Show "back to list" only if the user actually came from the list
if (router.canGoBackTo("users.list")) {
  showBackToListButton();
}

6a. State Context: state.context.navigation

The navigation plugin writes navigation metadata to state.context.navigation on every transition. This replaces the former getNavigationMeta() router extension -- metadata is now directly on the state object, available in subscribe callbacks and components without calling a separate method.

NavigationMeta Type

interface NavigationMeta {
  /** Type of navigation: push, replace, traverse, or reload */
  navigationType: "push" | "replace" | "traverse" | "reload";
  /** Whether the navigation was initiated by the user (back/forward button, link click) */
  userInitiated: boolean;
  /** Ephemeral info passed via navigation.navigate({ info }) -- lost on page reload */
  info?: unknown;
  /** Direction of navigation in the history stack */
  direction: "forward" | "back" | "unknown";
  /** The DOM element that initiated the navigation (e.g., anchor tag), or null for programmatic */
  sourceElement: Element | null;
}

Module Augmentation

The plugin augments StateContext from @real-router/types:

declare module "@real-router/types" {
  interface StateContext {
    navigation?: NavigationMeta;
  }
}

Import the plugin package to activate type inference:

import "@real-router/navigation-plugin";

Usage Examples

// In subscribe callback
router.subscribe((state) => {
  const nav = state.context.navigation;
  if (nav) {
    console.log(nav.navigationType); // "push" | "replace" | "traverse" | "reload"
    console.log(nav.userInitiated);  // true if user clicked back/forward/link
    console.log(nav.direction);      // "forward" | "back" | "unknown"
    console.log(nav.info);           // data passed via navigation.navigate({ info })
  }
});
// In a React component
import { useRoute } from "@real-router/react";
import "@real-router/navigation-plugin";

function NavigationIndicator() {
  const { route } = useRoute();
  const nav = route?.context.navigation;

  if (nav?.direction === "back") {
    return <SlideRightTransition />;
  }
  return <SlideLeftTransition />;
}

Timing

The plugin writes context in two hooks:

  • onTransitionStart: Writes captured metadata from the Navigation API navigate event (browser-initiated navigations)
  • onTransitionSuccess: Writes or overwrites with the final metadata including derived navigationType and direction (all navigations)

Both writes are visible in subscribe() callbacks.

7. Feature Detection

Use navigationPluginFactory when the Navigation API is available, fall back to browserPluginFactory otherwise:

import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";

const plugin =
  "navigation" in globalThis
    ? navigationPluginFactory({ base })
    : browserPluginFactory({ base });

router.usePlugin(plugin);

If "navigation" in globalThis is false and no custom browser is injected, the factory throws immediately:

Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.

8. Navigation Flow

Router Navigation:
  navigate() → Promise<State> → onTransitionSuccess → navigation.navigate()

Browser Navigate Event:
  navigate event → handler → router.navigate() / navigateToNotFound() / navigateToDefault()

The Navigation API serializes navigation via event.intercept() — only one navigation runs at a time. There's no deferred queue needed (unlike browser-plugin's popstate handling, which requires race condition protection).

Promise-Based API

All navigation methods return Promise<State>:

  • router.start(path?) — path is optional; the plugin injects browser.getLocation() when no path is given
  • router.navigate() — used in the navigate event handler with await and try/catch
  • router.navigateToNotFound(path?) — called when allowNotFound: true and the URL doesn't match any route
  • strict mode unmatched URL (allowNotFound: false) — emits $$error with ROUTE_NOT_FOUND via api.emitTransitionError(), then rejects event.intercept() so the Navigation API auto-rolls back the URL. No silent navigateToDefault() fallback.

9. Behavior

Main Scenarios

  • URL Building: buildUrl correctly adds base path
  • URL Matching: matchUrl parses URL via URL API, supports IPv6, Unicode
  • Browser History: push for new transitions, replace for replacement
  • Navigate Event: Handles back/forward buttons with full lifecycle transition

Edge Cases

  • Hash preservation: Hash is always preserved when navigating to the same path. Navigating to a different route clears the hash.
  • SSR safety: In a non-browser environment, the plugin falls back to no-ops via createNavigationFallbackBrowser. No errors, methods return safe defaults.
  • CANNOT_DEACTIVATE auto-rollback: When a guard blocks navigation, the Navigation API automatically rolls back the URL via event.intercept() rejection. No manual replaceState needed (simpler than browser-plugin's manual recovery).
  • Error recovery: recoverFromNavigateError restores the URL on non-RouterError exceptions by calling browser.navigate(url, { history: "replace" }).
  • replaceHistoryState fires navigate event: The plugin sets isSyncingFromRouter = true before calling browser.replaceState to prevent the navigate handler from triggering a full navigation.
  • Missing state / unmatched URL: When allowNotFound: true — calls navigateToNotFound(browser.getLocation()) to preserve the URL. When allowNotFound: false — emits $$error with ROUTE_NOT_FOUND via api.emitTransitionError() and throws in the event.intercept() handler so the Navigation API auto-rolls back the URL. Router state is unchanged. defaultRoute is not consulted as an implicit fallback — see RouterOptions#allowNotFound for the userland migration snippet.
  • Defensive entry URL parsing: entryToState and the traverseToLast entry lookup delegate URL parsing to safeParseUrl (via extractPathFromAbsoluteUrl). Malformed entry URLs from mocks, extensions, or non-spec sources resolve to undefined instead of throwing from the navigate event handler — the Navigation API event proceeds through the "no matching route" branch.
  • nav.navigate options forwarding: browser.navigate(url, options) forwards the full options object to the Navigation API. Callers can pass info, downloadRequest, and any future Navigation API options transparently.

State in NavigationHistoryEntry

entry.getState() = {
  name: "users.view",
  params: { id: "123" },
  path: "/users/123",
};

Navigation metadata is available on state.context.navigation after each transition -- not stored in the history entry itself.

10. Browser Support

Browser Minimum Version Notes
Chrome 102+ Full support
Firefox 147+ Full support
Safari 26.2+ Full support
Opera 88+ Full support

~89% global coverage as of 2026. For the remaining ~11%, use the Feature Detection pattern to fall back to browser-plugin.

11. Migration from browser-plugin

Switching from browser-plugin to navigation-plugin is a one-line import change. Options are identical.

// Before
import { browserPluginFactory } from "@real-router/browser-plugin";
router.usePlugin(browserPluginFactory({ base: "/app" }));

// After
import { navigationPluginFactory } from "@real-router/navigation-plugin";
router.usePlugin(navigationPluginFactory({ base: "/app" }));

All existing behavior is preserved. The exclusive extensions (peekBack, hasVisited, etc.) become available immediately after the swap.

12. Form Protection

Set forceDeactivate: false to respect canDeactivate guards on back/forward:

router.usePlugin(navigationPluginFactory({ forceDeactivate: false }));

import { getLifecycleApi } from "@real-router/core/api";

const lifecycle = getLifecycleApi(router);
lifecycle.addDeactivateGuard(
  "checkout",
  (router, getDep) => (toState, fromState) => {
    return !hasUnsavedChanges(); // false blocks back/forward
  },
);

13. Usage Examples

Basic Example

import { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";

const routes = [
  { name: "home", path: "/" },
  { name: "users", path: "/users" },
  { name: "users.view", path: "/view/:id" },
];

const router = createRouter(routes, {
  defaultRoute: "home",
});

router.usePlugin(navigationPluginFactory());
await router.start();

// Navigation
await router.navigate("users.view", { id: "123" });

// Building URL
const url = router.buildUrl("users.view", { id: "123" });
// => "/users/view/123"

// Matching URL
const state = router.matchUrl("https://example.com/users/view/456");
// => { name: "users.view", params: { id: "456" }, ... }

// History inspection
const prev = router.peekBack();
const visited = router.getVisitedRoutes();

Advanced Example with Feature Detection

import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
import { loggerPluginFactory } from "@real-router/logger-plugin";
import { persistentParamsPluginFactory } from "@real-router/persistent-params-plugin";

const routes = [
  { name: "home", path: "/" },
  { name: "users", path: "/users?page&sort" },
];

const router = createRouter(routes, {
  defaultRoute: "home",
  queryParamsMode: "default",
});

const base = "/app";
const plugin =
  "navigation" in globalThis
    ? navigationPluginFactory({ base, forceDeactivate: false })
    : browserPluginFactory({ base, forceDeactivate: false });

// Plugin order matters!
router.usePlugin(loggerPluginFactory());
router.usePlugin(persistentParamsPluginFactory(["lang", "theme"]));
router.usePlugin(plugin);

await router.start();

See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

SSR-feature subpath — @real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.

  • Lazy (Svelte only — dynamic component import)
  • Await — read deferred SSR data by key
  • Streamed — Suspense-style boundary
  • ClientOnly — server fallback → client children swap
  • ServerOnly — server children, removed after hydration
  • HttpStatusCode — render-time HTTP status declaration
  • HttpStatusProvider — provides sink to descendant <HttpStatusCode>
  • useDeferred — read deferred Promise by key

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally