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 forforceDeactivate).
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#//pathURLs and silently failedmatchPath. - 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
popstateevent - Removes existing listener if plugin is restarted
onTransitionSuccess
- Determines:
pushState(new entry) orreplaceState(replacement) replaceStateis used when:- First navigation (
fromStateis absent) replace: trueoptionreload: trueoption with equal states
- First navigation (
- URL is built as
base + "#" + hashPrefix + path
teardown
- Removes event listeners
- Removes start interceptor
- Removes router extensions (
buildUrl,matchUrl,replaceHistoryState) viaextendRouterunsubscribe
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/removeEventListenerforpopstate
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:
- Other plugins process
onTransitionSuccess - Hash plugin updates URL with changes from other plugins
8. Behavior
Main Scenarios
- URL Building:
buildUrlbuildsbase + "#" + hashPrefix + path - URL Matching:
matchUrlparses URL hash, strips prefix, matches against route tree - 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 viaonTransitionError) 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
- Empty hash: Treated as
"/"(root path) - Query collision (outer
?+ inner hash?): The inner hash query is the source of truth. Forhttps://example.com/?outer=1#/users?page=2,matchUrlreturns a state withparams.page === 2and ignoresouter. 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
buildUrloutput always starts with/. Enforced bysafeHashPrefixRulerejecting 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.
replaceHistoryStateexplicitly drops any pre-existing hash when rewriting the URL. - Restricted
hashPrefixcharacter set:/,#,?, and control characters are rejected at factory time to prevent malformed URLs and silentmatchPathfailures. - Outer URL query is secondary: When a URL contains both an outer query (
?a=1before#) and a hash-internal query (?b=2inside the hash path), only the inner query is parsed intostate.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
- @real-router/browser-plugin — History API routing (clean URLs, requires server config)
- Plugin Architecture — How plugins work in Real Router
- Guards — Navigation guards and
canDeactivate