@real-router/sources
Framework-agnostic subscription layer for Real-Router. Reactive primitives compatible with
useSyncExternalStoreand vanilla JS.
Used internally by @real-router/react. Use this package directly when building integrations for other frameworks or vanilla JS applications.
Installation
npm install @real-router/sourcesPeer dependency: @real-router/core
Quick Start
import { createRouter } from "@real-router/core";
import { createRouteSource } from "@real-router/sources";
const router = createRouter([
{ name: "home", path: "/" },
{ name: "users", path: "/users/:id" },
]);
await router.start("/");
const source = createRouteSource(router);
const unsubscribe = source.subscribe(() => {
console.log("Route:", source.getSnapshot().route?.name);
});Source Factories
| Factory | Returns | Cache |
|---|---|---|
createRouteSource(router) |
RouterSource<{ route, previousRoute }> |
not cached |
createRouteNodeSource(router, node) |
RouterSource<{ route, previousRoute }> |
per-router + per-nodeName |
createActiveRouteSource(router, name, params?, opts?) |
RouterSource<boolean> |
per-router + canonical-args |
createTransitionSource(router) |
RouterSource<{ isTransitioning, isLeaveApproved, toRoute, fromRoute }> |
not cached (advanced) |
getTransitionSource(router) |
same as above | per-router — recommended for integrations |
createErrorSource(router) |
RouterSource<{ error, toRoute, fromRoute, version }> |
not cached (advanced) |
getErrorSource(router) |
same as above | per-router — recommended for integrations |
createDismissableError(router) |
RouterSource<{ error, toRoute, fromRoute, version, resetError }> |
per-router — dismissal-aware error source for RouterErrorBoundary-style UIs |
createActiveNameSelector(router) |
ActiveNameSelector (selector API — subscribe(name, listener) / isActive(name) / destroy; not a RouterSource<T> — no getSnapshot()) |
per-router — O(1) active-name checker for Link fast-path |
Plus utilities: DEFAULT_ACTIVE_OPTIONS, normalizeActiveOptions(opts?), canonicalJson(value), and the ActiveNameSelector type.
All factories return a RouterSource<T> except createActiveNameSelector, which returns an ActiveNameSelector (see Source Factories table above):
interface RouterSource<T> {
subscribe(listener: () => void): () => void; // useSyncExternalStore-compatible
getSnapshot(): T; // current value, synchronous
destroy(): void; // no-op for cached wrappers; real teardown for create*
}
// createActiveNameSelector is the exception — its `subscribe` accepts a route-name
// argument and the active-state check lives on `isActive(name)` (no `getSnapshot()`).
interface ActiveNameSelector {
subscribe(routeName: string, listener: () => void): () => void;
isActive(routeName: string): boolean;
destroy(): void;
}Cached vs non-cached factories
Cached factories (createRouteNodeSource, createActiveRouteSource, getTransitionSource, getErrorSource) share a single source across all consumers of the same router. Multiple subscribe/unsubscribe pairs on the same instance share one router subscription. destroy() on the returned wrapper is a no-op — the underlying source lives as long as the router (the WeakMap entry releases on router GC).
Non-cached factories (createRouteSource, createTransitionSource, createErrorSource) return a fresh instance every call with real teardown on destroy() — use when you need an isolated source.
All route-state sources (createRouteSource, createRouteNodeSource, createActiveRouteSource) deduplicate via stabilizeState: same-path non-reload transitions preserve the snapshot reference and skip listener notifications. Reload navigations bypass dedup. See INVARIANTS.md #6.
Lazy vs Eager Subscription
createRouteSource,createRouteNodeSource,createActiveRouteSource— lazy: subscribe to the router on first listener, unsubscribe when all removedcreateTransitionSource/getTransitionSource— eager: subscribes immediately (needs to trackTRANSITION_START)createErrorSource/getErrorSource— eager: subscribes immediately (needs to trackTRANSITION_ERROR)
createActiveRouteSource Options
const source = createActiveRouteSource(router, "users", undefined, {
strict: false, // default: false — match descendants too
ignoreQueryParams: true, // default: true
hash: undefined, // default: undefined — ignore URL fragment.
// string → match iff state.context.url.hash equals it (#532).
});| Option | Type | Default | Effect |
|---|---|---|---|
strict |
boolean |
false |
When false, parent route is active when the current route is a descendant; when true, only an exact name match is active. |
ignoreQueryParams |
boolean |
true |
Whether to drop query-string params before comparing. |
hash |
string |
undefined |
When set, source is active iff route matches and state.context.url.hash equals this value. Requires a URL-publishing plugin (browser/navigation); under hash-plugin or memory-plugin (no context.url namespace), a non-undefined hash is always false. |
Params are hashed with canonicalJson(), so {a: 1, b: 2} and {b: 2, a: 1} hit the same cache entry. BigInt/circular refs fall back to a fresh non-cached source with a working destroy() — call it to release the router subscription.
Usage Examples
With React (useSyncExternalStore)
import { useSyncExternalStore } from "react";
import { createRouteSource } from "@real-router/sources";
const source = createRouteSource(router);
function CurrentRoute() {
const { route } = useSyncExternalStore(source.subscribe, source.getSnapshot);
return <p>Current route: {route?.name}</p>;
}With Vanilla JS
import { createRouteNodeSource } from "@real-router/sources";
// Only fires when navigating within the "users" subtree
const source = createRouteNodeSource(router, "users");
const unsubscribe = source.subscribe(() => {
const { route } = source.getSnapshot();
console.log("Users section:", route?.name);
});
unsubscribe(); // automatically unsubscribes from routerTransition Tracking
import { getTransitionSource } from "@real-router/sources";
// getTransitionSource — per-router cached. Safe to call destroy() multiple
// times; shared across all consumers in the same process.
const source = getTransitionSource(router);
source.subscribe(() => {
const { isTransitioning, isLeaveApproved, toRoute, fromRoute } =
source.getSnapshot();
if (isTransitioning) {
showSpinner();
} else {
hideSpinner();
}
});Error Tracking
import { getErrorSource } from "@real-router/sources";
const source = getErrorSource(router);
source.subscribe(() => {
const { error, toRoute } = source.getSnapshot();
if (error) {
console.error(`Navigation to ${toRoute?.name} failed: ${error.code}`);
}
});Documentation
- API reference and usage: Wiki — sources-package
- Adapter integration guide (Link fast-path migration recipe, snapshot bridging patterns): Wiki — sources-adapter-guide
Related Packages
| Package | Description |
|---|---|
| @real-router/core | Core router (required dependency) |
| @real-router/react | React integration (uses sources internally) |
| @real-router/rx | Observable API (state$, events$) |
Contributing
See contributing guidelines for development setup and PR process.