browser plugin - greydragon888/real-router GitHub Wiki

@real-router/browser-plugin

1. Overview

  • Name: Browser Plugin
  • Package: @real-router/browser-plugin
  • Purpose: Integrates Real Router with Browser History API, synchronizing router state with browser URL and handling back/forward button navigation.
  • Typical scenarios: SPA applications where URL should reflect current router state; browser history support using the History API (pushState/replaceState).

Modern browsers: For browsers with Navigation API support (~89% global coverage), consider @real-router/navigation-plugin which provides the same functionality plus route-level history access (peekBack, hasVisited, traverseToLast, etc.).

2. Installation and Setup

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

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

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

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

Configuration Examples

// Minimal configuration
browserPluginFactory();

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

// Disable force deactivation (respect canDeactivate guards on back/forward)
browserPluginFactory({ 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", "/""".
browserPluginFactory({ base: "../evil" });   // throws: must not contain '..' segments
browserPluginFactory({ base: "/app\nX" });   // throws: must not contain control characters

4. Lifecycle Hooks

The plugin implements the following hooks:

Hook Implemented Description
onStart Subscribes to browser popstate events
onStop Unsubscribes from popstate events
onTransitionStart Not used
onTransitionSuccess Updates history.state and browser URL
onTransitionError Not used
onTransitionCancel Not used
teardown Cleans up listeners, removes router extensions

Hook Implementation Details

onStart

  • Subscribes to browser popstate event
  • Removes existing listener if plugin is restarted

onTransitionSuccess

  • Determines: pushState (new entry) or replaceState (replacement)
  • replaceState 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
  • Note: passing replace: false on the first navigation forces pushState — this is an explicit user override

teardown

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

5. Router Interaction

Router Interface Extension

The plugin adds the following methods to the router instance via extendRouter() and declare module augmentation:

Method/Property Signature Purpose
buildUrl (name: string, params?: Params) => string Builds full URL with base path
matchUrl (url: string) => State | undefined Matches URL to router state
replaceHistoryState (name: string, params?: Params) => void Replaces history.state without navigation. Preserves location.hash — symmetric with onTransitionSuccess

router.start Override

The plugin overrides router.start(path?) via module augmentation to make path optional:

  • If path is not specified, injects the current browser URL via browser.getLocation()
  • If path is provided, uses it directly (same as core behavior)

Used Router Methods

Method Purpose
buildPath Building path from route name
navigate Navigation triggered by popstate event
navigateToDefault Navigation to default route
navigateToNotFound Set not-found state on unmatched popstate
getState Getting current state (error recovery path)

Used Plugin API Methods (getPluginApi(router))

Method Purpose
buildState Building state from name and params
makeState Creating full state object
matchPath Matching path to route

5a. State Context: state.context.browser

The browser plugin writes navigation source metadata to state.context.browser on every successful transition. This tells you whether the navigation was triggered programmatically or by the browser's back/forward buttons.

BrowserContext Type

type BrowserSource = "popstate" | "navigate";

interface BrowserContext {
  source: BrowserSource;
}
Value Meaning
"popstate" Navigation triggered by browser back/forward buttons
"navigate" Navigation triggered programmatically via router.navigate()

Module Augmentation

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

Import the plugin package to activate type inference:

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

Usage

router.subscribe((state) => {
  if (state.context.browser?.source === "popstate") {
    console.log("User pressed back/forward");
  }
});

5b. Additional Exports

Type Guard Functions

Function Signature Description
isState (value: unknown) => value is State Returns true if value is a valid router State
import { isState } from "@real-router/browser-plugin";

if (isState(history.state)) {
  console.log("Valid router state in history:", history.state.name);
}

6. Side Effects

Browser APIs

  • History API: pushState, replaceState, history.state
  • Location: location.pathname, location.hash, location.search, location.origin
  • Events: addEventListener/removeEventListener for popstate

Console

  • Warnings: URL parsing errors
  • Errors: Critical errors in popstate handler

SSR Compatibility

The plugin is SSR-safe:

  • Automatically detects absence of window/history
  • In non-browser environment returns fallback implementation with no-op methods
  • Warns once on first method call in SSR

7. Integration with Other Plugins

Plugin Interaction
@real-router/logger-plugin Works correctly, logs transitions
@real-router/persistent-params-plugin Preserves persistent params in URL
Custom plugins All hooks execute in registration order

Execution Order

Plugins execute in registration order. If browser plugin is registered last:

  1. Other plugins process onTransitionSuccess
  2. Browser plugin updates URL with changes from other plugins

8. Behavior

Main Scenarios

  • URL Building: buildUrl correctly adds base path
  • URL Matching: matchUrl parses URL via URL API, supports IPv6, Unicode
  • Browser History: pushState for new transitions, replaceState for replacement
  • Popstate: Handling back/forward buttons with full lifecycle transition

Edge Cases

  • Concurrent transitions: Deferred popstate handling during active transition
  • CANNOT_DEACTIVATE: Browser state restoration on navigation cancellation
  • Missing state / unmatched URL: When allowNotFound: true — calls navigateToNotFound(browser.getLocation()) to preserve the URL. When allowNotFound: false — emits $$error with ROUTE_NOT_FOUND (observable via the onTransitionError plugin hook) and rolls the URL back to the last-known router state. Router state is unchanged. defaultRoute is not consulted as an implicit fallback — see RouterOptions#allowNotFound for the userland migration snippet.
  • Invalid URL protocols: Blocking javascript:, data:, vbscript: (only http/https)
  • Error recovery: Synchronizing browser state with router state on critical errors

Guarantees

  • Race protection: Popstate events are deferred during active transitions
  • URL encoding: Special characters are correctly encoded in URL
  • Cleanup: All listeners are correctly removed on stop/teardown
  • Security: URL protocol validation, prototype pollution protection

9. Limitations and Known Issues

  • Server configuration required: All paths must fallback to index.html. For deployments without server config, use @real-router/hash-plugin instead.
  • Circular references: Parameters with circular references cause error (not serializable in history.state)

10. Usage Examples

Basic Example

import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";

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

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

router.usePlugin(browserPluginFactory());
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" }, ... }

Advanced Example

import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-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",
});

// Plugin order matters!
router.usePlugin(loggerPluginFactory());
router.usePlugin(persistentParamsPluginFactory(["lang", "theme"]));
router.usePlugin(
  browserPluginFactory({
    base: "/app",
    forceDeactivate: false,
  }),
);

await router.start();

// URL will be: /app/users?page=1&lang=en&theme=dark
await router.navigate("users", { page: "1", lang: "en", theme: "dark" });

// Replace history without navigation
router.replaceHistoryState("users", { page: "2", lang: "en", theme: "dark" });

See Also


11. Migration from router5

Comparison of router5's browser plugin → Real-Router's browser-plugin + hash-plugin.

Version Comparison

router5 Real-Router
Factory signature browserPluginFactory(opts?, browser?) browserPluginFactory(opts?, browser?)
Options type BrowserPluginOptions (flat interface) BrowserPluginOptions (flat, 2 options)
Export export default export { browserPluginFactory }
Hash routing useHash: true option Separate @real-router/hash-plugin

1. Breaking Changes

Severity Levels:

  • CRITICAL — Plugin will definitely break
  • HIGH — Plugin will likely break
  • MEDIUM — Plugin may break in some cases
  • LOW — Backward compatible, but needs attention

Changes Table

Severity What Changed Was Now Impact
CRITICAL Export method export default browserPluginFactory export { browserPluginFactory } Import will break
CRITICAL Hash mode removed useHash: true option Separate @real-router/hash-plugin Hash routing users must switch packages
HIGH Invalid options Warning + silent fallback Throws Error Unknown/invalid options now throw instead of being ignored
MEDIUM base type string | null string base: null will cause type error
LOW matchUrl return State | null State | undefined === null checks won't work

Removed Options

Option Migration
useHash Switch to @real-router/hash-plugin: import { hashPluginFactory } from "@real-router/hash-plugin"
hashPrefix Moved to @real-router/hash-plugin: hashPluginFactory({ hashPrefix: "!" })
preserveHash Removed — hash fragment preservation is now always-on (when navigating to the same path)

Examples

// ❌ Code that will break (import)
import browserPluginFactory from "@real-router/browser-plugin";

// ✅ Code after migration
import { browserPluginFactory } from "@real-router/browser-plugin";
// ❌ Hash mode — removed from browser-plugin
browserPluginFactory({ useHash: true, hashPrefix: "!" });

// ✅ Switch to hash-plugin
import { hashPluginFactory } from "@real-router/hash-plugin";
router.usePlugin(hashPluginFactory({ hashPrefix: "!" }));
// ❌ Code that will break (base: null)
browserPluginFactory({ base: null });

// ✅ Code after migration
browserPluginFactory({ base: "" });
// or simply omit the option (default is "")
// ❌ preserveHash option — removed
browserPluginFactory({ preserveHash: true });

// ✅ No action needed — hash preservation is always-on
browserPluginFactory();
// ❌ Code that may not work
const state = router.matchUrl(url);
if (state === null) {
  /* handle */
}

// ✅ Code after migration
const state = router.matchUrl(url);
if (!state) {
  /* handle */
}

2. Implementation Changes

Type Changes

  • BrowserPluginOptions: Simplified to flat interface { forceDeactivate?: boolean; base?: string }
  • Browser.getState(): Returns HistoryState | undefined instead of HistoryState
  • HistoryState: Changed from interface extends State to type = State & Record<string, unknown>

Lifecycle Hook Changes

Hook Change
onStart Popstate subscription via shared browser-env helpers
onStop Now sets removePopStateListener = undefined after call
onTransitionSuccess Hash preservation always-on; checks fromState.path === toState.path
onPopState Completely rewritten: added race condition protection, deferred events, error recovery
teardown Removes added methods from router via extendRouter unsubscribe

New Features

  • Race condition protection: Popstate events are deferred during active transitions
  • Deferred events: Only last popstate event is processed (intermediate ones skipped)
  • Error recovery: On critical errors browser state is synchronized with router
  • Options validation: Runtime type validation — throws Error on type mismatch
  • Base path normalization: Auto-adding / at start and removing / at end
  • URL protocol validation: Blocking javascript:, data:, vbscript: and other unsafe protocols
  • SSR fallback browser: Improved SSR support with fallback implementation and warnings

Fixed Bugs

  • Browser history desync: Fixed desynchronization on rapid back/forward clicking
  • URL parsing: Replaced regex parsing with URL API for correct handling of IPv6, Unicode and edge cases
  • Memory leaks: Correct listener removal on repeated start/stop
  • Concurrent transitions: Protection from simultaneous transitions

3. Dependency Changes

Dependency Was Now
@real-router/core peer dependency workspace:^
browser-env absent workspace:^ (new, private)
type-guards absent workspace:^ (new, private)

4. Migration Guide

Checklist

  • Update import: import { browserPluginFactory } from "@real-router/browser-plugin"
  • If using useHash: true: switch to @real-router/hash-plugin
  • Remove preserveHash option (behavior is now always-on)
  • Remove hashPrefix option (moved to hash-plugin)
  • Replace base: null with base: ""
  • Replace === null checks with !state for matchUrl
  • Check SSR code for new warnings

Step-by-Step Migration

  1. Update import

    // Was
    import browserPluginFactory from "@real-router/browser-plugin";
    
    // Now
    import { browserPluginFactory } from "@real-router/browser-plugin";
    
  2. Migrate hash routing to hash-plugin

    // Was
    import browserPluginFactory from "@real-router/browser-plugin";
    router.usePlugin(browserPluginFactory({ useHash: true, hashPrefix: "!" }));
    
    // Now
    import { hashPluginFactory } from "@real-router/hash-plugin";
    router.usePlugin(hashPluginFactory({ hashPrefix: "!" }));
    
  3. Simplify options

    // Was
    browserPluginFactory({
      base: null,
      preserveHash: true,
    });
    
    // Now
    browserPluginFactory({
      base: "",
      // preserveHash removed — always-on
    });
    
  4. Update matchUrl checks

    // Was
    const state = router.matchUrl(url);
    if (state === null) { ... }
    
    // Now
    const state = router.matchUrl(url);
    if (!state) { ... }
    
  5. Check SSR code

    • New fallback browser outputs warnings to console
    • This is expected behavior, but may require log suppression

5. Summary

Category Status
Breaking Changes CRITICAL (import, hash removal)
Removed options useHash, hashPrefix, preserveHash
New options None
Hook changes Significant
Security URL protocol validation
SSR Improved fallback
Type safety Flat interface (2 options)

Maximum severity: CRITICAL — Import change and hash mode removal