@real-router/sources 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,105 @@
1
1
  import { areRoutesRelated } from "@real-router/route-utils";
2
2
 
3
3
  import { BaseSource } from "./BaseSource";
4
+ import { canonicalJson } from "./canonicalJson.js";
5
+ import { normalizeActiveOptions } from "./normalizeActiveOptions.js";
4
6
 
5
7
  import type { ActiveRouteSourceOptions, RouterSource } from "./types.js";
6
8
  import type { Params, Router } from "@real-router/core";
7
9
 
10
+ const activeSourceCache = new WeakMap<
11
+ Router,
12
+ Map<string, RouterSource<boolean>>
13
+ >();
14
+
15
+ /**
16
+ * Creates a source tracking whether a route (with given params/options) is active.
17
+ *
18
+ * **Per-router + canonical-args cache:** repeated calls with equivalent
19
+ * arguments return the same shared instance. Param key order doesn't matter
20
+ * (`{ a:1, b:2 }` and `{ b:2, a:1 }` hit the same cache entry via
21
+ * `canonicalJson`).
22
+ *
23
+ * `destroy()` is a no-op — shared sources live with the router. The router
24
+ * subscription stays active while any consumer subscribes; when the router
25
+ * is garbage-collected, the WeakMap entry releases automatically.
26
+ *
27
+ * Edge cases: `Symbol`/`BigInt` in params bypass `canonicalJson` and produce
28
+ * an unstable cache key — these will simply miss the cache and create a new
29
+ * source on each call. Practical params are primitives, so this is not a
30
+ * concern in real usage.
31
+ */
8
32
  export function createActiveRouteSource(
9
33
  router: Router,
10
34
  routeName: string,
11
35
  params?: Params,
12
36
  options?: ActiveRouteSourceOptions,
13
37
  ): RouterSource<boolean> {
14
- const strict = options?.strict ?? false;
15
- const ignoreQueryParams = options?.ignoreQueryParams ?? true;
38
+ const { strict, ignoreQueryParams } = normalizeActiveOptions(options);
39
+
40
+ // BigInt/Symbol/circular refs cannot be serialized — fall back to creating
41
+ // a fresh (non-cached) source. Callers pass these edge-case params rarely;
42
+ // the extra allocation is acceptable.
43
+ let key: string | undefined;
44
+
45
+ try {
46
+ key = `${routeName}|${canonicalJson(params)}|${String(strict)}|${String(ignoreQueryParams)}`;
47
+ } catch {
48
+ key = undefined;
49
+ }
50
+
51
+ if (key === undefined) {
52
+ const source = buildActiveRouteSource(
53
+ router,
54
+ routeName,
55
+ params,
56
+ strict,
57
+ ignoreQueryParams,
58
+ );
59
+
60
+ return {
61
+ subscribe: source.subscribe,
62
+ getSnapshot: source.getSnapshot,
63
+ destroy: noopDestroy,
64
+ };
65
+ }
66
+
67
+ let perRouter = activeSourceCache.get(router);
68
+
69
+ if (!perRouter) {
70
+ perRouter = new Map();
71
+ activeSourceCache.set(router, perRouter);
72
+ }
73
+
74
+ let cached = perRouter.get(key);
75
+
76
+ if (!cached) {
77
+ const source = buildActiveRouteSource(
78
+ router,
79
+ routeName,
80
+ params,
81
+ strict,
82
+ ignoreQueryParams,
83
+ );
16
84
 
85
+ cached = {
86
+ subscribe: source.subscribe,
87
+ getSnapshot: source.getSnapshot,
88
+ destroy: noopDestroy,
89
+ };
90
+ perRouter.set(key, cached);
91
+ }
92
+
93
+ return cached;
94
+ }
95
+
96
+ function buildActiveRouteSource(
97
+ router: Router,
98
+ routeName: string,
99
+ params: Params | undefined,
100
+ strict: boolean,
101
+ ignoreQueryParams: boolean,
102
+ ): RouterSource<boolean> {
17
103
  const initialValue = router.isActiveRoute(
18
104
  routeName,
19
105
  params,
@@ -21,14 +107,13 @@ export function createActiveRouteSource(
21
107
  ignoreQueryParams,
22
108
  );
23
109
 
24
- const source = new BaseSource(initialValue, {
25
- onDestroy: () => {
26
- unsubscribe();
27
- },
28
- });
110
+ const source = new BaseSource(initialValue);
29
111
 
30
- // Eager connection: subscribe to router immediately
31
- const unsubscribe = router.subscribe((next) => {
112
+ // Eager connection: subscribe to router immediately. This source is only
113
+ // ever reached through the cached public `createActiveRouteSource`, whose
114
+ // returned wrapper has a no-op destroy. The source lives with the router;
115
+ // the router.subscribe handle is released on router GC.
116
+ router.subscribe((next) => {
32
117
  const isNewRelated = areRoutesRelated(routeName, next.route.name);
33
118
  const isPrevRelated =
34
119
  next.previousRoute &&
@@ -51,3 +136,7 @@ export function createActiveRouteSource(
51
136
 
52
137
  return source;
53
138
  }
139
+
140
+ function noopDestroy(): void {
141
+ // Shared cached source — external destroy() is a no-op.
142
+ }
@@ -0,0 +1,95 @@
1
+ import { BaseSource } from "./BaseSource";
2
+ import { getErrorSource } from "./createErrorSource";
3
+
4
+ import type { DismissableErrorSnapshot, RouterSource } from "./types.js";
5
+ import type { Router } from "@real-router/core";
6
+
7
+ const dismissableCache = new WeakMap<
8
+ Router,
9
+ RouterSource<DismissableErrorSnapshot>
10
+ >();
11
+
12
+ /**
13
+ * Returns a per-router cached source that wraps `getErrorSource(router)` with
14
+ * an integrated "dismissed" version counter, exposing a single reactive
15
+ * snapshot `{ error, toRoute, fromRoute, version, resetError }`.
16
+ *
17
+ * Each `RouterErrorBoundary` in a framework adapter subscribes to this one
18
+ * source instead of re-implementing the `dismissedVersion` state pattern
19
+ * locally. The 6-copy duplicate across adapters collapses to this helper.
20
+ *
21
+ * **Semantics:**
22
+ * - `error` is non-null only when `underlying.version > dismissedVersion`.
23
+ * - `resetError()` sets `dismissedVersion = current underlying version`,
24
+ * immediately clearing `error` to `null` and notifying all listeners.
25
+ * - A subsequent `TRANSITION_ERROR` advances `version` beyond `dismissedVersion`,
26
+ * so `error` becomes non-null again — no additional plumbing needed.
27
+ *
28
+ * **Cached:** one instance per router. `destroy()` on the returned source is
29
+ * a no-op. Shared across all `RouterErrorBoundary` consumers.
30
+ */
31
+ export function createDismissableError(
32
+ router: Router,
33
+ ): RouterSource<DismissableErrorSnapshot> {
34
+ const cached = dismissableCache.get(router);
35
+
36
+ if (cached) {
37
+ return cached;
38
+ }
39
+
40
+ const errorSource = getErrorSource(router);
41
+
42
+ let dismissedVersion = -1;
43
+
44
+ const computeSnapshot = (): DismissableErrorSnapshot => {
45
+ const snap = errorSource.getSnapshot();
46
+ const isDismissed = snap.version <= dismissedVersion;
47
+
48
+ return {
49
+ error: isDismissed ? null : snap.error,
50
+ toRoute: isDismissed ? null : snap.toRoute,
51
+ fromRoute: isDismissed ? null : snap.fromRoute,
52
+ version: snap.version,
53
+ resetError,
54
+ };
55
+ };
56
+
57
+ const source = new BaseSource<DismissableErrorSnapshot>(computeSnapshot(), {
58
+ onFirstSubscribe: () => {
59
+ unsubFromError = errorSource.subscribe(() => {
60
+ source.updateSnapshot(computeSnapshot());
61
+ });
62
+ },
63
+ onLastUnsubscribe: () => {
64
+ disconnect();
65
+ },
66
+ });
67
+
68
+ let unsubFromError: (() => void) | null = null;
69
+
70
+ function resetError(): void {
71
+ dismissedVersion = errorSource.getSnapshot().version;
72
+ source.updateSnapshot(computeSnapshot());
73
+ }
74
+
75
+ function disconnect(): void {
76
+ const unsub = unsubFromError;
77
+
78
+ unsubFromError = null;
79
+ unsub?.();
80
+ }
81
+
82
+ const wrapper: RouterSource<DismissableErrorSnapshot> = {
83
+ subscribe: source.subscribe,
84
+ getSnapshot: source.getSnapshot,
85
+ destroy: noopDestroy,
86
+ };
87
+
88
+ dismissableCache.set(router, wrapper);
89
+
90
+ return wrapper;
91
+ }
92
+
93
+ function noopDestroy(): void {
94
+ // Shared cached source — external destroy() is a no-op.
95
+ }
@@ -13,6 +13,11 @@ const INITIAL_SNAPSHOT: RouterErrorSnapshot = {
13
13
  version: 0,
14
14
  };
15
15
 
16
+ const errorSourceCache = new WeakMap<
17
+ Router,
18
+ RouterSource<RouterErrorSnapshot>
19
+ >();
20
+
16
21
  export function createErrorSource(
17
22
  router: Router,
18
23
  ): RouterSource<RouterErrorSnapshot> {
@@ -64,3 +69,43 @@ export function createErrorSource(
64
69
 
65
70
  return source;
66
71
  }
72
+
73
+ /**
74
+ * Returns a per-router cached error source shared across all consumers.
75
+ *
76
+ * Safe to call destroy() — the cached source ignores external destroy() calls
77
+ * and lives until the router itself is garbage-collected (the WeakMap entry
78
+ * releases automatically).
79
+ *
80
+ * Use this in framework adapters (React/Preact/Solid/Vue/Svelte/Angular) to
81
+ * share a single ErrorSource instance across all mount/unmount cycles.
82
+ *
83
+ * For isolated/advanced use (ad-hoc, short-lived, per-owner teardown), call
84
+ * `createErrorSource(router)` directly — it returns a fresh instance with a
85
+ * working `destroy()`.
86
+ */
87
+ export function getErrorSource(
88
+ router: Router,
89
+ ): RouterSource<RouterErrorSnapshot> {
90
+ let cached = errorSourceCache.get(router);
91
+
92
+ if (!cached) {
93
+ const source = createErrorSource(router);
94
+
95
+ // Wrap with no-op destroy. The underlying source is shared across all
96
+ // consumers; letting any one consumer call destroy() would tear it down
97
+ // for the rest. The source lives as long as the router (WeakMap key).
98
+ cached = {
99
+ subscribe: source.subscribe,
100
+ getSnapshot: source.getSnapshot,
101
+ destroy: noopDestroy,
102
+ };
103
+ errorSourceCache.set(router, cached);
104
+ }
105
+
106
+ return cached;
107
+ }
108
+
109
+ function noopDestroy(): void {
110
+ // Shared cached source — external destroy() is a no-op.
111
+ }
@@ -1,24 +1,68 @@
1
1
  import { BaseSource } from "./BaseSource";
2
2
  import { computeSnapshot } from "./computeSnapshot.js";
3
- import { getCachedShouldUpdate } from "./shouldUpdateCache.js";
4
3
 
5
4
  import type { RouteNodeSnapshot, RouterSource } from "./types.js";
6
5
  import type { Router } from "@real-router/core";
7
6
 
7
+ const nodeSourceCache = new WeakMap<
8
+ Router,
9
+ Map<string, RouterSource<RouteNodeSnapshot>>
10
+ >();
11
+
8
12
  /**
9
13
  * Creates a source scoped to a specific route node.
10
14
  *
15
+ * **Per-router + per-nodeName cache:** repeated calls with the same
16
+ * `(router, nodeName)` return the same shared instance. `N` consumers
17
+ * calling `createRouteNodeSource(r, "users")` produce one router subscription
18
+ * shared across all of them.
19
+ *
11
20
  * Uses a lazy-connection pattern: the router subscription is created when the
12
21
  * first listener subscribes and removed when the last listener unsubscribes.
13
22
  * This is compatible with React's useSyncExternalStore and Strict Mode.
23
+ *
24
+ * `destroy()` on the returned source is a no-op — the shared instance lives
25
+ * as long as the router itself (the WeakMap entry releases automatically on
26
+ * router GC). Callers that need an isolated instance with working teardown
27
+ * can use `buildRouteNodeSource` internally (not exported).
14
28
  */
15
29
  export function createRouteNodeSource(
16
30
  router: Router,
17
31
  nodeName: string,
32
+ ): RouterSource<RouteNodeSnapshot> {
33
+ let perRouter = nodeSourceCache.get(router);
34
+
35
+ if (!perRouter) {
36
+ perRouter = new Map();
37
+ nodeSourceCache.set(router, perRouter);
38
+ }
39
+
40
+ let cached = perRouter.get(nodeName);
41
+
42
+ if (!cached) {
43
+ const source = buildRouteNodeSource(router, nodeName);
44
+
45
+ // Wrap with no-op destroy. The shared source lives with the router.
46
+ cached = {
47
+ subscribe: source.subscribe,
48
+ getSnapshot: source.getSnapshot,
49
+ destroy: noopDestroy,
50
+ };
51
+ perRouter.set(nodeName, cached);
52
+ }
53
+
54
+ return cached;
55
+ }
56
+
57
+ function buildRouteNodeSource(
58
+ router: Router,
59
+ nodeName: string,
18
60
  ): RouterSource<RouteNodeSnapshot> {
19
61
  let routerUnsubscribe: (() => void) | null = null;
20
62
 
21
- const shouldUpdate = getCachedShouldUpdate(router, nodeName);
63
+ // Built once per cached source instance; safe — createRouteNodeSource is
64
+ // itself per-(router, nodeName) cached, so shouldUpdate is called once.
65
+ const shouldUpdate = router.shouldUpdateNode(nodeName);
22
66
 
23
67
  const initialSnapshot: RouteNodeSnapshot = {
24
68
  route: undefined,
@@ -68,9 +112,12 @@ export function createRouteNodeSource(
68
112
  });
69
113
  },
70
114
  onLastUnsubscribe: disconnect,
71
- onDestroy: disconnect,
72
115
  },
73
116
  );
74
117
 
75
118
  return source;
76
119
  }
120
+
121
+ function noopDestroy(): void {
122
+ // Shared cached source — external destroy() is a no-op.
123
+ }
@@ -14,6 +14,11 @@ const IDLE_SNAPSHOT: RouterTransitionSnapshot = {
14
14
  fromRoute: null,
15
15
  };
16
16
 
17
+ const transitionSourceCache = new WeakMap<
18
+ Router,
19
+ RouterSource<RouterTransitionSnapshot>
20
+ >();
21
+
17
22
  export function createTransitionSource(
18
23
  router: Router,
19
24
  ): RouterSource<RouterTransitionSnapshot> {
@@ -74,3 +79,43 @@ export function createTransitionSource(
74
79
 
75
80
  return source;
76
81
  }
82
+
83
+ /**
84
+ * Returns a per-router cached transition source shared across all consumers.
85
+ *
86
+ * Safe to call destroy() — the cached source ignores external destroy() calls
87
+ * and lives until the router itself is garbage-collected (the WeakMap entry
88
+ * releases automatically).
89
+ *
90
+ * Use this in framework adapters (React/Preact/Solid/Vue/Svelte/Angular) to
91
+ * share a single TransitionSource instance across all mount/unmount cycles.
92
+ *
93
+ * For isolated/advanced use (ad-hoc, short-lived, per-owner teardown), call
94
+ * `createTransitionSource(router)` directly — it returns a fresh instance with
95
+ * a working `destroy()`.
96
+ */
97
+ export function getTransitionSource(
98
+ router: Router,
99
+ ): RouterSource<RouterTransitionSnapshot> {
100
+ let cached = transitionSourceCache.get(router);
101
+
102
+ if (!cached) {
103
+ const source = createTransitionSource(router);
104
+
105
+ // Wrap with no-op destroy. The underlying source is shared across all
106
+ // consumers; letting any one consumer call destroy() would tear it down
107
+ // for the rest. The source lives as long as the router (WeakMap key).
108
+ cached = {
109
+ subscribe: source.subscribe,
110
+ getSnapshot: source.getSnapshot,
111
+ destroy: noopDestroy,
112
+ };
113
+ transitionSourceCache.set(router, cached);
114
+ }
115
+
116
+ return cached;
117
+ }
118
+
119
+ function noopDestroy(): void {
120
+ // Shared cached source — external destroy() is a no-op.
121
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  ActiveRouteSourceOptions,
6
6
  RouterTransitionSnapshot,
7
7
  RouterErrorSnapshot,
8
+ DismissableErrorSnapshot,
8
9
  } from "./types.js";
9
10
 
10
11
  export { createRouteSource } from "./createRouteSource";
@@ -13,6 +14,22 @@ export { createRouteNodeSource } from "./createRouteNodeSource";
13
14
 
14
15
  export { createActiveRouteSource } from "./createActiveRouteSource";
15
16
 
16
- export { createTransitionSource } from "./createTransitionSource";
17
+ export {
18
+ createTransitionSource,
19
+ getTransitionSource,
20
+ } from "./createTransitionSource";
17
21
 
18
- export { createErrorSource } from "./createErrorSource";
22
+ export { createErrorSource, getErrorSource } from "./createErrorSource";
23
+
24
+ export { createDismissableError } from "./createDismissableError";
25
+
26
+ export { createActiveNameSelector } from "./createActiveNameSelector";
27
+
28
+ export type { ActiveNameSelector } from "./createActiveNameSelector";
29
+
30
+ export {
31
+ DEFAULT_ACTIVE_OPTIONS,
32
+ normalizeActiveOptions,
33
+ } from "./normalizeActiveOptions";
34
+
35
+ export { canonicalJson } from "./canonicalJson";
@@ -0,0 +1,29 @@
1
+ import type { ActiveRouteSourceOptions } from "./types.js";
2
+
3
+ /**
4
+ * Default options for `createActiveRouteSource` and adapter-level helpers.
5
+ *
6
+ * Frozen to prevent accidental mutation by consumers.
7
+ */
8
+ export const DEFAULT_ACTIVE_OPTIONS: Readonly<
9
+ Required<ActiveRouteSourceOptions>
10
+ > = Object.freeze({
11
+ strict: false,
12
+ ignoreQueryParams: true,
13
+ });
14
+
15
+ /**
16
+ * Normalizes partial `ActiveRouteSourceOptions` into a fully-defaulted object.
17
+ *
18
+ * Use this to produce a stable options record for comparison, caching, or
19
+ * downstream consumers that require all fields present.
20
+ */
21
+ export function normalizeActiveOptions(
22
+ options?: ActiveRouteSourceOptions,
23
+ ): Required<ActiveRouteSourceOptions> {
24
+ return {
25
+ strict: options?.strict ?? DEFAULT_ACTIVE_OPTIONS.strict,
26
+ ignoreQueryParams:
27
+ options?.ignoreQueryParams ?? DEFAULT_ACTIVE_OPTIONS.ignoreQueryParams,
28
+ };
29
+ }
package/src/types.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { RouterError, State } from "@real-router/core";
1
+ import type { Params, RouterError, State } from "@real-router/core";
2
2
 
3
- export interface RouteSnapshot {
4
- route: State | undefined;
3
+ export interface RouteSnapshot<P extends Params = Params> {
4
+ route: State<P> | undefined;
5
5
  previousRoute: State | undefined;
6
6
  }
7
7
 
8
- export interface RouteNodeSnapshot {
9
- route: State | undefined;
8
+ export interface RouteNodeSnapshot<P extends Params = Params> {
9
+ route: State<P> | undefined;
10
10
  previousRoute: State | undefined;
11
11
  }
12
12
 
@@ -34,3 +34,16 @@ export interface RouterErrorSnapshot {
34
34
  fromRoute: State | null;
35
35
  version: number;
36
36
  }
37
+
38
+ export interface DismissableErrorSnapshot {
39
+ /** Currently visible error, or `null` if none (never seen or dismissed). */
40
+ error: RouterError | null;
41
+ /** Target route of the failed navigation. */
42
+ toRoute: State | null;
43
+ /** Source route at the time of failure. */
44
+ fromRoute: State | null;
45
+ /** Monotonic version counter from the underlying error source. */
46
+ version: number;
47
+ /** Dismisses the current error. Next error (new version) becomes visible again. */
48
+ resetError: () => void;
49
+ }
@@ -1,27 +0,0 @@
1
- import type { Router, State } from "@real-router/core";
2
-
3
- const shouldUpdateCache = new WeakMap<
4
- Router,
5
- Map<string, (toState: State, fromState?: State) => boolean>
6
- >();
7
-
8
- export function getCachedShouldUpdate(
9
- router: Router,
10
- nodeName: string,
11
- ): (toState: State, fromState?: State) => boolean {
12
- let routerCache = shouldUpdateCache.get(router);
13
-
14
- if (!routerCache) {
15
- routerCache = new Map();
16
- shouldUpdateCache.set(router, routerCache);
17
- }
18
-
19
- let fn = routerCache.get(nodeName);
20
-
21
- if (!fn) {
22
- fn = router.shouldUpdateNode(nodeName);
23
- routerCache.set(nodeName, fn);
24
- }
25
-
26
- return fn;
27
- }