hash plugin - greydragon888/real-router GitHub Wiki

@real-router/hash-plugin

1. Overview

  • Name: Hash Plugin
  • Package: @real-router/hash-plugin
  • Purpose: Integrates Real Router with hash-based URL routing, synchronizing router state with the URL hash fragment and handling back/forward button navigation.
  • Typical scenarios: SPA applications on static hosting (GitHub Pages, S3, Netlify) where server-side configuration is not available; legacy browser support; simple deployments without URL rewriting.

2. Installation and Setup

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

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

3. Configuration Options

Option Type Default Description
hashPrefix string "" Prefix after # (e.g., "!" for #!/path)
base string "" Base path prepended before hash (e.g., "/app"/app#/path)
forceDeactivate boolean true Force deactivation even if canDeactivate returned false

Configuration Examples

// Minimal configuration
hashPluginFactory();
// URL: example.com/#/users

// Hashbang routing
hashPluginFactory({ hashPrefix: "!" });
// URL: example.com/#!/users

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

// Hashbang with base path
hashPluginFactory({ hashPrefix: "!", base: "/app" });
// URL: example.com/app#!/users

// Disable force deactivation (respect canDeactivate guards on back/forward)
hashPluginFactory({ 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 (e.g., string for hashPrefix, boolean for forceDeactivate).

base rules (via safeBaseRule)

  • Must not contain control characters (\u0000-\u001F, \u007F).
  • Must not contain .. path segments (e.g., /app/../evil).
  • Normalised to canonical form: leading /, no trailing /, no // runs. "app""/app", "/app/""/app", "//app//""/app", "/""".

hashPrefix rules (via safeHashPrefixRule)

  • Must not contain / — the slash is added automatically before the path. hashPrefix: "/" previously produced broken #//path URLs and silently failed matchPath.
  • Must not contain # — it is the hash delimiter.
  • Must not contain ? — it conflicts with the query delimiter.
  • Must not contain control characters.
hashPluginFactory({ hashPrefix: "/" });   // throws: must not contain '/'
hashPluginFactory({ hashPrefix: "#" });   // throws: must not contain '#'
hashPluginFactory({ hashPrefix: "?" });   // throws: must not contain '?'
hashPluginFactory({ hashPrefix: "!\n" }); // throws: must not contain control characters
hashPluginFactory({ base: "../evil" });   // throws: must not contain '..' segments

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)
    • replace: true option
    • reload: true option with equal states
  • URL is built as base + "#" + hashPrefix + path

teardown

  • Removes event listeners
  • Removes start interceptor
  • Removes router extensions (buildUrl, matchUrl, replaceHistoryState) via extendRouter unsubscribe

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 and hash prefix
matchUrl (url: string) => State | undefined Matches URL to router state
replaceHistoryState (name: string, params?: Params) => void Replaces history.state without navigation. Does not preserve the hash — the hash IS the route

router.start Override

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

  • If path is not specified, injects the current hash-extracted path 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
buildUrl Building full hash URL (plugin's own method)
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. 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/hash-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.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 hash plugin is registered last:

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

8. Behavior

Main Scenarios

  • URL Building: buildUrl builds base + "#" + hashPrefix + path
  • URL Matching: matchUrl parses URL hash, strips prefix, matches against route tree
  • 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 onTransitionError) 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
  • Empty hash: Treated as "/" (root path)
  • Query collision (outer ? + inner hash ?): The inner hash query is the source of truth. For https://example.com/?outer=1#/users?page=2, matchUrl returns a state with params.page === 2 and ignores outer. The outer ?... is only used as a fallback when the hash has no query of its own.

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
  • Leading-slash invariant: Any path extracted from a buildUrl output always starts with /. Enforced by safeHashPrefixRule rejecting prefixes that contain /.
  • Query source of truth: The hash-internal query always wins over the outer URL search.

9. Limitations and Known Issues

  • No hash fragment preservation: The hash IS the route — there is no separate hash fragment concept. replaceHistoryState explicitly drops any pre-existing hash when rewriting the URL.
  • Restricted hashPrefix character set: /, #, ?, and control characters are rejected at factory time to prevent malformed URLs and silent matchPath failures.
  • Outer URL query is secondary: When a URL contains both an outer query (?a=1 before #) and a hash-internal query (?b=2 inside the hash path), only the inner query is parsed into state.params. The outer query is used exclusively as a fallback when the hash has no query of its own.
  • 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 { hashPluginFactory } from "@real-router/hash-plugin";

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

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

router.usePlugin(hashPluginFactory());
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 { hashPluginFactory } from "@real-router/hash-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(
  hashPluginFactory({
    hashPrefix: "!",
    forceDeactivate: false,
  }),
);

await router.start();

// URL will be: #!/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" });

Query Source of Truth

When a URL embeds both an outer query and a hash-internal query, only the inner one is parsed into state.params.

const router = createRouter(
  [
    { name: "home", path: "/" },
    { name: "users", path: "/users" },
  ],
  { defaultRoute: "home", queryParamsMode: "default" },
);

router.usePlugin(hashPluginFactory());

// Outer ?outer=1 is ignored — the hash owns the route's query.
router.matchUrl("https://example.com/?outer=1#/users?page=2");
// → { name: "users", params: { page: 2 }, path: "/users" }

// If the hash has no query, the outer query is used as a fallback.
router.matchUrl("https://example.com/?page=3#/users");
// → { name: "users", params: { page: 3 }, path: "/users" }

See Also