sources adapter guide - greydragon888/real-router GitHub Wiki

Building a Framework Adapter with @real-router/sources

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).

Prerequisites

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 Types and When to Use Each

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

Choosing the right source

  • "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

Step 1: Router Provider

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.

How @real-router/react does it

// 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:

  1. createRouteSource uses a lazy-connection pattern โ€” it subscribes to the router only when the first listener attaches. In React, useSyncExternalStore manages this automatically.
  2. The source is created via useMemo with [router] as dependency. If the router instance changes, a new source is created.
  3. getSnapshot is passed as both the client and server snapshot โ€” the router returns the same state on both sides.

Adapter template for other frameworks

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.


Step 2: Framework Bridge Utility

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.

How each official adapter implements the bridge

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();
}

Bridge-authoring rules

  1. Always call source.subscribe() first, THEN read source.getSnapshot() inside the listener โ€” mirrors the useSyncExternalStore contract. If your framework reads the initial value eagerly, use source.getSnapshot() directly as the initial state and reconcile on the first callback.

  2. Call unsubscribe() in your framework's cleanup hook (onCleanup, onScopeDispose, DestroyRef.onDestroy). This is non-negotiable โ€” cached sources survive, but without unsubscribe() the listener itself leaks in the source's #listeners Set.

  3. source.destroy() in cleanup is optional but safe. Cached sources (the default) have a no-op destroy(). Non-cached sources (createRouteSource, createTransitionSource, createErrorSource) have real teardown. Calling destroy() unconditionally (as Angular does) is the simplest rule that works in both cases.

  4. 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.

Provider using the bridge

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...
}

Step 3: Route Node Hook (Scoped Updates)

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.

How @real-router/react does it

// 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:

  1. createRouteNodeSource caches per (router, nodeName) inside @real-router/sources. Multiple components observing the same node share one router subscription.
  2. 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. useSyncExternalStore handles the lifecycle.
  3. On reconnection (e.g., after Activity hide/show), the snapshot is reconciled with the current router state.
  4. destroy() on the returned source is a no-op โ€” the shared instance lives with the router. No manual teardown needed.

Why route can be undefined

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 />;
}

Step 4: Active Route Check

createActiveRouteSource provides an optimized boolean check for "is this route active?". It's used primarily for navigation links that need active styling.

How @real-router/react does it (internal hook used by Link)

// 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:

  1. No external params stabilization needed. createActiveRouteSource hashes params via canonicalJson internally, 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.
  2. The source checks route relationships via areRoutesRelated() before calling router.isActiveRoute(). If neither the current nor previous route is related to the tracked route, the update is skipped entirely.
  3. strict: false (default) means "users" is considered active when the current route is "users.profile" (descendant). strict: true requires an exact match.
  4. destroy() on the returned source is a no-op โ€” shared instance per canonical args.

Step 5: Transition and Error Hooks

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.

Transition hook (useRouterTransition)

// @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.

Error source โ†’ RouterErrorBoundary

@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).


Step 6: Link Component

The Link component is the centerpiece of every adapter. It combines three concerns:

  1. URL derivation โ€” buildUrl (with @real-router/browser-plugin) or buildPath fallback.
  2. Active-route tracking โ€” createActiveRouteSource via the bridge from Step 2.
  3. Click handling โ€” router.navigate() with modifier-key filtering (shouldNavigate from shared/dom-utils).

How @real-router/react does it (abbreviated)

// @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);

Reusing shared/dom-utils

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.

Fast-path vs slow-path active detection

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.

Click handling rules (applies to all frameworks)

  1. Let user's onClick handler run first โ€” they may preventDefault() to cancel.
  2. Return early if shouldNavigate(evt) returns false โ€” respect modifier keys.
  3. Return early if target="_blank" โ€” let the browser open a new tab.
  4. evt.preventDefault() only when you're actually navigating.
  5. Use fire-and-forget navigation (void router.navigate(...)) โ€” don't block on the promise inside an event handler.

Params stabilization

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.


Step 7: Lifecycle Considerations

Source cleanup

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();

Snapshot stability

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.


Testing Strategy

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.

1. Functional tests (per hook / component)

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).

2. Property-based tests

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}) and createActiveRouteSource(r, name, {b:2,a:1}) hit the same cache entry.
  • Monotonicity โ€” strict: true active implies strict: false active.
  • previousRoute global โ€” across sibling subtrees, previousRoute is 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.

3. Memory stress tests (regression gate)

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.)

4. FinalizationRegistry check

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%+ collected

5. Test organization

packages/<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

Complete Adapter Example

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
  };
}

Performance Notes

Snapshot deduplication

createRouteNodeSource and createActiveRouteSource implement deduplication โ€” they only notify listeners when the snapshot actually changes:

  • createRouteNodeSource โ€” returns the same snapshot reference if neither route nor previousRoute changed (computeSnapshot checks reference equality, plus Object.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.

shouldUpdateNode caching

createRouteNodeSource caches the filter function per (router, nodeName) pair. Multiple sources for the same node on the same router share a single filter function.

areRoutesRelated optimization

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.


See Also

โš ๏ธ **GitHub.com Fallback** โš ๏ธ