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-pluginwhich 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-plugininstead.
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 forforceDeactivate).
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
popstateevent - Removes existing listener if plugin is restarted
onTransitionSuccess
- Determines:
pushState(new entry) orreplaceState(replacement) replaceStateis used when:- First navigation (
fromStateis absent) andreplaceis not explicitlyfalse replace: trueoptionreload: trueoption with equal states
- First navigation (
- Preserves hash fragment when navigating to the same path
- Note: passing
replace: falseon the first navigation forcespushState— this is an explicit user override
teardown
- Removes event listeners
- Removes start interceptor
- Removes router extensions (
buildUrl,matchUrl,replaceHistoryState) viaextendRouterunsubscribe - 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/removeEventListenerforpopstate
Console
- Warnings: URL parsing errors
- Errors: Critical errors in
popstatehandler
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:
- Other plugins process
onTransitionSuccess - Browser plugin updates URL with changes from other plugins
8. Behavior
Main Scenarios
- URL Building:
buildUrlcorrectly addsbasepath - URL Matching:
matchUrlparses URL via URL API, supports IPv6, Unicode - Browser History:
pushStatefor new transitions,replaceStatefor 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— callsnavigateToNotFound(browser.getLocation())to preserve the URL. WhenallowNotFound: false— emits$$errorwithROUTE_NOT_FOUND(observable via theonTransitionErrorplugin hook) and rolls the URL back to the last-known router state. Router state is unchanged.defaultRouteis 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-plugininstead. - 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
- @real-router/hash-plugin — Hash-based routing (no server config needed)
- Plugin Architecture — How plugins work in Real Router
- Guards — Navigation guards and
canDeactivate
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 | undefinedinstead ofHistoryState - HistoryState: Changed from
interface extends Statetotype = 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
Erroron 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
preserveHashoption (behavior is now always-on) - Remove
hashPrefixoption (moved to hash-plugin) - Replace
base: nullwithbase: "" - Replace
=== nullchecks with!stateformatchUrl - Check SSR code for new warnings
Step-by-Step Migration
-
Update import
// Was import browserPluginFactory from "@real-router/browser-plugin"; // Now import { browserPluginFactory } from "@real-router/browser-plugin"; -
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: "!" })); -
Simplify options
// Was browserPluginFactory({ base: null, preserveHash: true, }); // Now browserPluginFactory({ base: "", // preserveHash removed — always-on }); -
Update matchUrl checks
// Was const state = router.matchUrl(url); if (state === null) { ... } // Now const state = router.matchUrl(url); if (!state) { ... } -
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