sources adapter guide - greydragon888/real-router GitHub Wiki
This guide walks through building a UI framework integration for Real-Router using @real-router/sources. It covers the full adapter architecture โ every source type, the framework bridge pattern, the Link component, the RouterErrorBoundary pattern, and testing strategy โ with real examples from all six official adapters (@real-router/react, preact, solid, vue, svelte, angular).
Your adapter package should depend on:
{
"dependencies": {
"@real-router/core": "workspace:^",
"@real-router/sources": "workspace:^"
}
}@real-router/sources provides seven source factories (five create + two shared get*). Each returns a RouterSource<T> โ a minimal interface with subscribe, getSnapshot, and destroy:
interface RouterSource<T> {
subscribe(listener: () => void): () => void;
getSnapshot(): T;
destroy(): void;
}This interface is intentionally simple. Every major UI framework has a mechanism for subscribing to external stores โ RouterSource<T> maps directly onto those mechanisms.
Cached by default: createRouteNodeSource, createActiveRouteSource, getTransitionSource, getErrorSource, createDismissableError, and createActiveNameSelector cache instances per router (and per-args where applicable). Multiple consumers produce one router subscription, not N. destroy() on a cached wrapper is a no-op โ the shared source lives with the router. Framework adapters should use these by default.
Non-cached create* variants (createRouteSource, createTransitionSource, createErrorSource) remain available for advanced cases requiring isolated instances with working teardown.
| Source | Snapshot Type | Cached | Use Case |
|---|---|---|---|
createRouteSource |
RouteSnapshot |
no | Root provider, global route display |
createRouteNodeSource |
RouteNodeSnapshot |
per-(router, node) | Nested layouts, scoped components |
createActiveRouteSource |
boolean |
per-(router, args) | Navigation links with custom params / strict / ignoreQueryParams flags |
createActiveNameSelector |
selector API (not a RouterSource<T>) |
per-router | Link fast-path โ default-options Link components; O(1) shared subscribe |
getTransitionSource |
RouterTransitionSnapshot |
per-router | Loading indicators, progress bars |
getErrorSource |
RouterErrorSnapshot |
per-router | Raw error feed (rare โ prefer createDismissableError for UI) |
createDismissableError |
DismissableErrorSnapshot |
per-router |
RouterErrorBoundary โ error feed with integrated dismissal + resetError |
-
"Show the current route" โ
createRouteSource -
"Update this section only when its routes change" โ
createRouteNodeSource -
"Is this link active?" (custom params / strict mode) โ
createActiveRouteSource -
"Is this link active?" (default options, Link-heavy UI) โ
createActiveNameSelector -
"Show a loading indicator during navigations" โ
getTransitionSource -
"Show navigation errors to the user" โ
createDismissableError(integrated dismissal) -
"Stream raw navigation errors for logging" โ
getErrorSource
Every adapter needs a way to provide the router instance to the component tree. The provider should create a RouteSource and share the reactive state.
// RouterProvider.tsx
import { createRouteSource } from "@real-router/sources";
import { getNavigator } from "@real-router/core";
import { useMemo, useSyncExternalStore } from "react";
export const RouterProvider: FC<{ router: Router; children: ReactNode }> = ({
router,
children,
}) => {
const navigator = useMemo(() => getNavigator(router), [router]);
// Create the source โ one per router instance
const store = useMemo(() => createRouteSource(router), [router]);
// Subscribe to route changes via useSyncExternalStore
const { route, previousRoute } = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot, // SSR: same snapshot for server rendering
);
const routeContextValue = useMemo(
() => ({ navigator, route, previousRoute }),
[navigator, route, previousRoute],
);
return (
<RouterContext value={router}>
<NavigatorContext value={navigator}>
<RouteContext value={routeContextValue}>{children}</RouteContext>
</NavigatorContext>
</RouterContext>
);
};Key points:
-
createRouteSourceuses a lazy-connection pattern โ it subscribes to the router only when the first listener attaches. In React,useSyncExternalStoremanages this automatically. - The source is created via
useMemowith[router]as dependency. If the router instance changes, a new source is created. -
getSnapshotis passed as both the client and server snapshot โ the router returns the same state on both sides.
For any framework, the provider pattern is:
1. Accept a router instance
2. Create a source: createRouteSource(router)
3. Subscribe using your framework's reactivity primitive
4. Expose { route, previousRoute } to child components
Concrete implementations for Vue/Svelte/Solid/Angular live in the next step (Bridge utility) โ once you have a bridge, the provider collapses to 3 lines.
Every non-React framework needs a small helper that converts RouterSource<T> into the framework's native reactive primitive. The bridge is called once per source in each hook/composable โ write it once, reuse across the adapter.
Pattern contract:
bridge(source: RouterSource<T>) โ frameworkReactive<T>
where frameworkReactive is whatever your framework uses for external-store subscription: signal, ref, store, snippet getter.
React / Preact use React's native useSyncExternalStore โ no custom bridge needed:
// React / Preact
import { useSyncExternalStore } from "react"; // or preact-compat polyfill
const snapshot = useSyncExternalStore(
source.subscribe,
source.getSnapshot,
source.getSnapshot,
);Solid โ createSignalFromSource (signal + onCleanup):
// @real-router/solid โ createSignalFromSource.ts
import { createSignal, onCleanup, type Accessor } from "solid-js";
import type { RouterSource } from "@real-router/sources";
export function createSignalFromSource<T>(source: RouterSource<T>): Accessor<T> {
const [value, setValue] = createSignal(source.getSnapshot());
const unsubscribe = source.subscribe(() => {
setValue(() => source.getSnapshot());
});
onCleanup(unsubscribe); // No source.destroy() โ cached sources are no-op anyway.
return value;
}Vue โ useRefFromSource (shallowRef + onScopeDispose):
// @real-router/vue โ useRefFromSource.ts
import { shallowRef, onScopeDispose, type ShallowRef } from "vue";
import type { RouterSource } from "@real-router/sources";
export function useRefFromSource<T>(source: RouterSource<T>): ShallowRef<T> {
const ref = shallowRef(source.getSnapshot());
const unsubscribe = source.subscribe(() => {
ref.value = source.getSnapshot();
});
onScopeDispose(unsubscribe);
return ref;
}Svelte 5 โ createReactiveSource (runes createSubscriber โ lazy):
// @real-router/svelte โ createReactiveSource.svelte.ts
import { createSubscriber } from "svelte/reactivity";
import type { RouterSource } from "@real-router/sources";
export function createReactiveSource<T>(
source: RouterSource<T>,
): { readonly current: T } {
const subscribe = createSubscriber((update) => source.subscribe(update));
return {
get current() {
subscribe(); // registers dependency in the reactive context
return source.getSnapshot();
},
};
}Angular โ sourceToSignal (signal + DestroyRef):
// @real-router/angular โ sourceToSignal.ts
import { signal, inject, DestroyRef, type Signal } from "@angular/core";
import type { RouterSource } from "@real-router/sources";
export function sourceToSignal<T>(source: RouterSource<T>): Signal<T> {
const sig = signal<T>(source.getSnapshot());
const destroyRef = inject(DestroyRef);
const unsubscribe = source.subscribe(() => {
sig.set(source.getSnapshot());
});
destroyRef.onDestroy(() => {
unsubscribe();
source.destroy(); // Safe โ cached sources treat destroy() as no-op.
});
return sig.asReadonly();
}-
Always call
source.subscribe()first, THEN readsource.getSnapshot()inside the listener โ mirrors theuseSyncExternalStorecontract. If your framework reads the initial value eagerly, usesource.getSnapshot()directly as the initial state and reconcile on the first callback. -
Call
unsubscribe()in your framework's cleanup hook (onCleanup,onScopeDispose,DestroyRef.onDestroy). This is non-negotiable โ cached sources survive, but withoutunsubscribe()the listener itself leaks in the source's#listenersSet. -
source.destroy()in cleanup is optional but safe. Cached sources (the default) have a no-opdestroy(). Non-cached sources (createRouteSource,createTransitionSource,createErrorSource) have real teardown. Callingdestroy()unconditionally (as Angular does) is the simplest rule that works in both cases. -
Don't cache the bridge output at the adapter level. The source itself is cached by
@real-router/sources; the bridge just maps one source โ one framework reactive. Every hook call should produce a fresh framework-reactive object tied to the current component's lifetime.
With the bridge in place, RouterProvider becomes trivial:
// Vue
import { createRouteSource } from "@real-router/sources";
import { useRefFromSource } from "./useRefFromSource";
export function setupProvider(router: Router) {
const source = createRouteSource(router);
const snapshot = useRefFromSource(source);
// provide() snapshot under RouteKey...
}// Svelte 5
import { createRouteSource } from "@real-router/sources";
import { createReactiveSource } from "./createReactiveSource.svelte";
export function setupProvider(router: Router) {
const source = createRouteSource(router);
const { current } = createReactiveSource(source);
// setContext() the reactive object...
}Node-scoped subscriptions are the most important optimization primitive for apps with deeply nested route trees. createRouteNodeSource skips updates that don't affect the observed node โ critical for performance in apps with many nested route sections.
// useRouteNode.tsx
import { createRouteNodeSource } from "@real-router/sources";
import { getNavigator } from "@real-router/core";
import { useMemo, useSyncExternalStore } from "react";
export function useRouteNode(nodeName: string): RouteContext {
const router = useRouter();
// Recreate source when router or nodeName changes
const store = useMemo(
() => createRouteNodeSource(router, nodeName),
[router, nodeName],
);
const { route, previousRoute } = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
const navigator = useMemo(() => getNavigator(router), [router]);
return useMemo(
(): RouteContext => ({ navigator, route, previousRoute }),
[navigator, route, previousRoute],
);
}Key points:
-
createRouteNodeSourcecaches per(router, nodeName)inside@real-router/sources. Multiple components observing the same node share one router subscription. - The cached source uses a lazy-connection pattern internally โ it connects to the router on the first listener and disconnects when the last listener unsubscribes.
useSyncExternalStorehandles the lifecycle. - On reconnection (e.g., after Activity hide/show), the snapshot is reconciled with the current router state.
-
destroy()on the returned source is a no-op โ the shared instance lives with the router. No manual teardown needed.
When the current route is outside the observed node's subtree, RouteNodeSnapshot.route is undefined. For example, if you observe "users" and the current route is "settings":
const source = createRouteNodeSource(router, "users");
source.getSnapshot();
// โ { route: undefined, previousRoute: ... }This allows UI components to conditionally render based on whether their section is active:
function UsersSection() {
const { route } = useRouteNode("users");
if (!route) return null; // Not in users section
return <UsersList />;
}createActiveRouteSource provides an optimized boolean check for "is this route active?". It's used primarily for navigation links that need active styling.
// useIsActiveRoute.tsx (internal โ not exported publicly)
import { createActiveRouteSource } from "@real-router/sources";
import { useSyncExternalStore } from "react";
export function useIsActiveRoute(
routeName: string,
params?: Params,
strict = false,
ignoreQueryParams = true,
): boolean {
const router = useRouter();
// createActiveRouteSource caches per (router, name, canonicalJson(params), options).
// Inline object literals like `{ id: "123" }` are safe: canonical hashing means
// equivalent shapes hit the same cache entry regardless of key order.
const store = createActiveRouteSource(router, routeName, params, {
strict,
ignoreQueryParams,
});
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
}Key points:
-
No external params stabilization needed.
createActiveRouteSourcehashes params viacanonicalJsoninternally, so inline objects like{ id: "123" }resolve to the same cached source as long as their shape matches.{ a: 1, b: 2 }and{ b: 2, a: 1 }also hit the same entry. - The source checks route relationships via
areRoutesRelated()before callingrouter.isActiveRoute(). If neither the current nor previous route is related to the tracked route, the update is skipped entirely. -
strict: false(default) means"users"is considered active when the current route is"users.profile"(descendant).strict: truerequires an exact match. -
destroy()on the returned source is a no-op โ shared instance per canonical args.
Transition and error state are eager โ their sources must be subscribed to the router from the moment the router starts, not lazily on first listener. @real-router/sources provides per-router cached wrappers (getTransitionSource, getErrorSource, createDismissableError) that framework adapters should always use โ one shared source per router, safe destroy() (no-op), no memory leaks across mount/unmount cycles.
// @real-router/react โ useRouterTransition.tsx
import { getTransitionSource } from "@real-router/sources";
import { useSyncExternalStore } from "react";
import { useRouter } from "./useRouter";
export function useRouterTransition() {
const router = useRouter();
const store = getTransitionSource(router);
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
}For non-React adapters, plug in the bridge from Step 2:
// Vue
export function useRouterTransition() {
return useRefFromSource(getTransitionSource(useRouter()));
}
// Solid
export function useRouterTransition() {
return createSignalFromSource(getTransitionSource(useRouter()));
}
// Svelte 5
export function useRouterTransition() {
return createReactiveSource(getTransitionSource(useRouter()));
}
// Angular
export function injectRouterTransition() {
return sourceToSignal(getTransitionSource(injectRouter()));
}Snapshot shape (RouterTransitionSnapshot):
{
isTransitioning: boolean; // true during TRANSITION_START or LEAVE_APPROVED
isLeaveApproved: boolean; // true after deactivation guards pass, before activation
toRoute: State | null;
fromRoute: State | null;
}Use cases: loading spinners, progress bars, blocking UI during async guards.
@real-router/sources ships a derived source that bundles the dismissal pattern: createDismissableError(router). It wraps getErrorSource with integrated dismissedVersion state and exposes a resetError callback inside the snapshot:
{
error: RouterError | null; // null while version โค dismissedVersion
toRoute: State | null;
fromRoute: State | null;
version: number; // monotonic โ bumps on every TRANSITION_ERROR
resetError: () => void; // captures current version into dismissedVersion
}Per-router cached, destroy() is a no-op. Use this everywhere instead of handwriting dismissedVersion state in each adapter.
Adapter bridge (same as any other source):
// @real-router/react โ RouterErrorBoundary.tsx (simplified)
import { createDismissableError } from "@real-router/sources";
import { useSyncExternalStore, useEffect } from "react";
import { useRouter } from "../hooks/useRouter";
export function RouterErrorBoundary({ fallback, onError, children }) {
const router = useRouter();
const source = createDismissableError(router);
const snapshot = useSyncExternalStore(
source.subscribe,
source.getSnapshot,
source.getSnapshot,
);
useEffect(() => {
if (snapshot.error) {
onError?.(snapshot.error, snapshot.toRoute, snapshot.fromRoute);
}
}, [snapshot.version]);
return (
<>
{children}
{snapshot.error && fallback(snapshot.error, snapshot.resetError)}
</>
);
}No local useState(dismissedVersion). No local resetError closure. The shared source handles both and N boundaries on the same router share one subscription.
Per-framework bridges (replace useSyncExternalStore with whatever your framework uses):
| Framework | Bridge primitive |
|---|---|
| React / Preact | useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) |
| Solid |
createSignalFromSource(source) โ Accessor |
| Vue |
useRefFromSource(source) โ ShallowRef |
| Svelte |
createReactiveSource(source) โ { current } getter |
| Angular |
sourceToSignal(source) โ Signal |
Why version and not error identity: if the user clicks the same blocked link twice, the router emits the same RouterError instance both times. Without version, the second click wouldn't re-trigger the boundary. With version, every TRANSITION_ERROR advances the counter, so the boundary re-renders even for repeated errors.
Reset semantics: snapshot.resetError() captures the current underlying version into dismissedVersion and emits an update. Subsequent errors bump version beyond the captured value, making snapshot.error truthy again โ no additional plumbing needed.
Falling back to raw getErrorSource: use the raw source if you need an error snapshot without dismissal semantics (e.g. pure analytics / logging sink).
The Link component is the centerpiece of every adapter. It combines three concerns:
-
URL derivation โ
buildUrl(with@real-router/browser-plugin) orbuildPathfallback. -
Active-route tracking โ
createActiveRouteSourcevia the bridge from Step 2. -
Click handling โ
router.navigate()with modifier-key filtering (shouldNavigatefromshared/dom-utils).
// @real-router/react โ Link.tsx (abbreviated)
import { memo } from "react";
import { useRouter } from "../hooks/useRouter";
import { useIsActiveRoute } from "../hooks/useIsActiveRoute";
import {
shouldNavigate,
buildHref,
buildActiveClassName,
} from "../dom-utils";
export const Link = memo<LinkProps>(function Link(props) {
const router = useRouter();
const isActive = useIsActiveRoute(
props.routeName,
props.routeParams,
props.activeStrict,
props.ignoreQueryParams,
);
const href = buildHref(router, props.routeName, props.routeParams ?? {});
const className = buildActiveClassName(
isActive,
props.activeClassName,
props.className,
);
const onClick = (evt: MouseEvent) => {
props.onClick?.(evt);
if (!shouldNavigate(evt)) return;
if (props.target === "_blank") return;
evt.preventDefault();
void router.navigate(props.routeName, props.routeParams, props.routeOptions);
};
return (
<a {...anchorProps} href={href} className={className} onClick={onClick}>
{props.children}
</a>
);
}, areLinkPropsEqual);The monorepo ships three helpers in shared/dom-utils/ (symlinked into every adapter as src/dom-utils/):
| Helper | Purpose |
|---|---|
shouldNavigate(evt) |
Returns false for middle-click, Ctrl/Cmd/Shift/Alt + click, non-left buttons. Prevents hijacking browser-native "open in new tab" behavior. |
buildHref(router, routeName, params) |
Tries router.buildUrl (if the browser-plugin added it) first, falls back to router.buildPath. Returns undefined for invalid route names, logs a console error. |
buildActiveClassName(isActive, activeClassName, baseClassName) |
Concatenates class strings with proper whitespace handling. No double spaces. |
Third-party adapters can depend on these via the shared/dom-utils/ symlink (same as the official adapters) or copy the small helpers inline.
Solid pioneered a createSelector-based fast path for links with simple args (no params, activeStrict: false, ignoreQueryParams: true). All other configurations fall through to createActiveRouteSource.
The pattern is now framework-agnostic via createActiveNameSelector(router) in @real-router/sources โ one shared router.subscribe handle services any number of distinct routeName listeners, notifying only names whose active status actually flipped.
interface ActiveNameSelector {
subscribe(routeName: string, listener: () => void): () => void;
isActive(routeName: string): boolean;
destroy(): void;
}Per-router cached. destroy() is a no-op.
When to opt into the fast path: Link components with default options โ activeStrict: false, ignoreQueryParams: true, no custom routeParams. These are the common navigation-link case.
When NOT to opt in: custom routeParams, strict matching, or ignoreQueryParams: false. Use createActiveRouteSource for those โ its cache handles the full argument surface.
// @real-router/solid โ Link.tsx (simplified, uses inline equivalent in RouterProvider)
const useFastPath =
!props.activeStrict &&
props.ignoreQueryParams &&
props.routeParams === EMPTY_PARAMS;
const isActive = useFastPath
? () => ctx.routeSelector(props.routeName) // O(1), shared selector
: createSignalFromSource(createActiveRouteSource( // full source, cached
router,
props.routeName,
props.routeParams,
{ strict: props.activeStrict, ignoreQueryParams: props.ignoreQueryParams },
));React/Preact/Vue/Svelte/Angular currently use only the slow path โ every Link creates a cached createActiveRouteSource. This is fine because the source itself is cached per-(router, canonical args): N Links to "users.profile" share ONE subscription, not N. Opting into createActiveNameSelector for the common case is an available optimization that replaces N subscriptions with a single shared one โ worth considering for Link-heavy UIs.
- Let user's
onClickhandler run first โ they maypreventDefault()to cancel. - Return early if
shouldNavigate(evt)returns false โ respect modifier keys. - Return early if
target="_blank"โ let the browser open a new tab. -
evt.preventDefault()only when you're actually navigating. - Use fire-and-forget navigation (
void router.navigate(...)) โ don't block on the promise inside an event handler.
Framework comparator optimizations (React memo + areLinkPropsEqual, Vue computed, etc.) are adapter-level concerns. The underlying cache in @real-router/sources is already key-order-insensitive via canonicalJson(params) โ inline <Link routeParams={{ id: "1" }} /> does not create duplicate subscriptions across re-renders.
| Source |
destroy() behavior |
Cleanup pattern |
|---|---|---|
createRouteSource |
real teardown | Call destroy() when owner is done |
createRouteNodeSource (cached) |
no-op | Just unsubscribe โ shared instance lives with the router |
createActiveRouteSource (cached) |
no-op | Just unsubscribe โ shared instance lives with the router |
getTransitionSource (cached) |
no-op | Just unsubscribe โ shared instance lives with the router |
getErrorSource (cached) |
no-op | Just unsubscribe โ shared instance lives with the router |
createDismissableError (cached) |
no-op | Just unsubscribe โ shared instance lives with the router |
createActiveNameSelector (cached) |
no-op | Just unsubscribe via per-name subscribe โ shared selector |
createTransitionSource (non-cached) |
real teardown | Call destroy() when owner is done |
createErrorSource (non-cached) |
real teardown | Call destroy() when owner is done |
Cached sources share a single instance per router (and per-args where applicable) stored in WeakMap<Router, ...>. When the router is garbage-collected, the entry releases automatically โ no manual cleanup needed in the adapter.
This design makes adapter bridges that unconditionally call destroy() (e.g., Angular's sourceToSignal in DestroyRef.onDestroy) safe to use with cached sources: the shared instance is unaffected.
For frameworks without automatic cleanup (vanilla JS, jQuery, etc.), use the non-cached create* variants if you need explicit teardown:
const source = createTransitionSource(router);
const unsubscribe = source.subscribe(listener);
// When tearing down:
unsubscribe();
source.destroy();createRouteNodeSource and createActiveRouteSource guarantee referential stability โ getSnapshot() returns the same object reference until the state actually changes (via Object.is() checks before updating the internal snapshot). This is critical for frameworks that use reference equality to detect changes (React, Solid).
createRouteSource does not guarantee referential stability โ it creates a new snapshot object on every router transition. This is acceptable because it updates on every navigation by design, so the snapshot always represents a genuine change.
Every official adapter has three test layers: functional, property, and stress (memory). Mirror this structure in your adapter โ it catches both correctness regressions and silent memory leaks.
Test each public hook and component in isolation. Minimum coverage:
- Hook returns the initial snapshot (before any navigation).
-
Hook updates after
router.navigate(...)โ await navigation, assert new value. -
Hook does NOT update on unrelated navigations โ critical for
useRouteNode(prove the filter works). - Hook cleans up on unmount โ no listeners left in the source, no errors on subsequent navigations.
-
Link renders correct
hrefโ both with and without@real-router/browser-plugin. -
Link active CSS toggles correctly โ for each combination of
strictรignoreQueryParams. -
Link click triggers
router.navigateโ with modifier keys respected.
Use your framework's standard testing harness (React Testing Library, Vue Test Utils, Svelte Testing Library, Solid Testing Library, Angular TestBed).
Run against createActiveRouteSource and createRouteNodeSource with @fast-check/vitest. The invariants live in packages/sources/INVARIANTS.md:
-
Idempotent identity โ
createRouteNodeSource(r, n)returns the same instance for the same args. -
Canonical params equivalence โ
createActiveRouteSource(r, name, {a:1,b:2})andcreateActiveRouteSource(r, name, {b:2,a:1})hit the same cache entry. -
Monotonicity โ
strict: trueactive impliesstrict: falseactive. -
previousRoute global โ across sibling subtrees,
previousRouteis the actual prior route, not the last prior route in the current subtree.
Reuse the helpers from packages/sources/tests/property/helpers.ts if your adapter lives in this monorepo.
The most valuable tests for adapters. Without them, a broken cache (local WeakMap re-introduced, useMemo with unstable deps, etc.) produces a silent memory leak on mount/unmount cycles โ correctness tests pass, memory grows linearly.
The standard pattern (used by all 6 official adapters โ see packages/<adapter>/tests/stress/memory-mount-unmount.stress.*):
// Pseudocode โ adapt to your framework's mount/unmount API
function measureMountUnmountHeap(iterations: number) {
// Warm-up: one mount/unmount to stabilize JIT
const w = mountConsumer(router);
unmount(w);
forceGC(); forceGC();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < iterations; i++) {
const handle = mountConsumer(router);
unmount(handle);
}
forceGC(); forceGC();
const after = process.memoryUsage().heapUsed;
return after - before;
}Three patterns, each catches a different class of regression:
| Pattern | Setup | Catches |
|---|---|---|
| A โ Transition ร1000 | Mount/unmount one component using useRouterTransition ร 1000 |
Leak in getTransitionSource cache or bridge cleanup |
| B โ RouteNode ร1000 + nav | 10 trees ร 100 components using useRouteNode("users") + 50 navigations, then unmount |
Leak in createRouteNodeSource cache; wrong bridge that creates fresh source per mount |
| C โ ErrorBoundary ร500 fresh routers | 500 iterations: create fresh router, mount RouterErrorBoundary, unmount, stop router |
Router / source not released when router is GC'd; WeakMap entry stuck |
Enabling --expose-gc: vitest stress configs must pass --expose-gc in execArgv:
// vitest.config.stress.mts
export default {
test: {
environment: "jsdom",
pool: "forks",
execArgv: ["--expose-gc"],
// ...
},
};Setting baselines: first run the tests with only console.log(formatBytes(delta)) โ no expect() bounds. Record the numbers. Then add expect(delta).toBeLessThan(...) with a small margin over the measured baseline (15-30%). This becomes your regression gate. Example thresholds (B/iter) observed across official adapters, post-refactor:
| Adapter | Pattern A | Pattern B |
|---|---|---|
| React | < 75 000 | < 2 500 |
| Preact | < 4 500 | < 2 100 |
| Solid | < 4 500 | < 500 |
| Vue | < 3 500 | < 9 500 |
| Svelte | < 6 500 | < 500 |
| Angular | < 15 000 | < 12 000 |
(React and Angular absolute bounds are wide because of React runtime / TestBed overhead โ not adapter leaks. See .claude/react-adapter-memory-investigation-2026-04-18.md in the monorepo for the full breakdown.)
For catching GC-ability regressions โ verifies that after unmount(), the component + bridge + source reachable from the WeakMap are actually collected:
const fr = new FinalizationRegistry((label: string) => collected.add(label));
const refs: WeakRef<object>[] = [];
for (let i = 0; i < 100; i++) {
const marker = { i };
fr.register(marker, `iter-${i}`);
refs.push(new WeakRef(marker));
const handle = mountConsumer(router);
unmount(handle);
}
// Give GC a chance
for (let i = 0; i < 5; i++) {
forceGC();
await new Promise((r) => setTimeout(r, 10));
}
const live = refs.filter((r) => r.deref() !== undefined).length;
expect(live).toBeLessThan(5); // 95%+ collectedpackages/<adapter>/tests/
โโโ functional/ # unit tests per hook/component
โโโ integration/ # multi-hook scenarios (RouteView, RouterErrorBoundary)
โโโ property/ # fast-check property tests
โโโ stress/
โ โโโ memory-mount-unmount.stress.* # Patterns A/B/C + FinalizationRegistry
โ โโโ subscription-fanout.stress.* # N consumers, 1 source, M navigations
โ โโโ mount-unmount-lifecycle.stress.* # Rapid churn
โ โโโ ...
โโโ helpers.* # createStressRouter, forceGC, takeHeapSnapshot
โโโ setup.ts
A minimal framework-agnostic adapter exposing the full subscription surface. Each "store" is a thin wrapper over a source โ in a real adapter you'd replace these with framework-specific bridges (Step 2).
import {
createRouteSource,
createRouteNodeSource,
createActiveRouteSource,
createActiveNameSelector,
getTransitionSource,
getErrorSource,
createDismissableError,
} from "@real-router/sources";
import type {
ActiveRouteSourceOptions,
} from "@real-router/sources";
import type { Router, Params } from "@real-router/types";
// 1. Root state โ subscribe to all navigations (not cached โ per-provider).
export function createRouterStore(router: Router) {
const source = createRouteSource(router);
return {
subscribe: source.subscribe,
getRoute: () => source.getSnapshot().route,
getPreviousRoute: () => source.getSnapshot().previousRoute,
destroy: source.destroy, // real teardown โ call when provider unmounts
};
}
// 2. Node-scoped state โ cached per (router, nodeName).
export function createRouteNodeStore(router: Router, nodeName: string) {
const source = createRouteNodeSource(router, nodeName);
// destroy() is a no-op; multiple consumers share this instance.
return {
subscribe: source.subscribe,
getRoute: () => source.getSnapshot().route,
getPreviousRoute: () => source.getSnapshot().previousRoute,
};
}
// 3. Active check โ cached per (router, name, canonical(params), options).
export function createActiveRouteStore(
router: Router,
routeName: string,
params?: Params,
options?: ActiveRouteSourceOptions,
) {
const source = createActiveRouteSource(router, routeName, params, options);
return {
subscribe: source.subscribe,
isActive: source.getSnapshot,
};
}
// 4. Transition tracking โ cached per router (shared eager source).
export function createTransitionStore(router: Router) {
const source = getTransitionSource(router);
return {
subscribe: source.subscribe,
getSnapshot: source.getSnapshot,
// No destroy โ shared instance lives with router.
};
}
// 5. Raw error tracking โ cached per router (shared eager source).
// Use createDismissableError for RouterErrorBoundary; this is the raw feed.
export function createErrorStore(router: Router) {
const source = getErrorSource(router);
return {
subscribe: source.subscribe,
getSnapshot: source.getSnapshot,
};
}
// 6. RouterErrorBoundary feed โ cached per router, integrated dismissal + resetError.
export function createErrorBoundaryStore(router: Router) {
const source = createDismissableError(router);
return {
subscribe: source.subscribe,
getSnapshot: source.getSnapshot, // { error, toRoute, fromRoute, version, resetError }
};
}
// 7. Link fast-path โ O(1) active-name selector shared across all Link components.
// Use only for Links with default options (no custom params, non-strict, ignore query).
export function createLinkActiveSelector(router: Router) {
const selector = createActiveNameSelector(router);
return {
subscribe: selector.subscribe, // (routeName, listener) => unsubscribe
isActive: selector.isActive, // (routeName) => boolean
};
}createRouteNodeSource and createActiveRouteSource implement deduplication โ they only notify listeners when the snapshot actually changes:
-
createRouteNodeSourceโ returns the same snapshot reference if neitherroutenorpreviousRoutechanged (computeSnapshotchecks reference equality, plusObject.is()guard) -
createActiveRouteSourceโ only notifies when the boolean value changes (Object.is()comparison)
createRouteSource does not deduplicate โ it creates a new snapshot object and notifies all listeners on every router transition. This is by design: createRouteSource is the "no filtering" source that forwards all state changes.
createRouteNodeSource caches the filter function per (router, nodeName) pair. Multiple sources for the same node on the same router share a single filter function.
createActiveRouteSource uses areRoutesRelated(routeName, currentRoute) as a fast pre-check before calling router.isActiveRoute(). If neither the new nor the previous route is related (ancestor/descendant), the isActiveRoute call is skipped entirely. This avoids unnecessary computation for navigations in unrelated parts of the route tree.
- @real-router/sources โ Package overview and full API reference
- subscribe โ Core router subscription
- shouldUpdateNode โ Node-scoped update logic
-
useRouteNode โ React hook built on
createRouteNodeSource -
useRouterTransition โ React hook built on
getTransitionSource -
RouterErrorBoundary โ React component built on
createDismissableError -
RouterProvider โ React provider built on
createRouteSource - Preact-Integration ยท Solid-Integration ยท Vue-Integration ยท Svelte-Integration ยท Angular-Integration โ Reference implementations for each adapter