@real-router/sources 0.8.4 → 0.8.6

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,108 +0,0 @@
1
- import { BaseSource } from "./BaseSource";
2
- import { getErrorSource } from "./createErrorSource";
3
- import { noopDestroy } from "./internal/noopDestroy.js";
4
-
5
- import type { DismissableErrorSnapshot, RouterSource } from "./types.js";
6
- import type { Router } from "@real-router/core";
7
-
8
- const dismissableCache = new WeakMap<
9
- Router,
10
- RouterSource<DismissableErrorSnapshot>
11
- >();
12
-
13
- /**
14
- * Returns a per-router cached source that wraps `getErrorSource(router)` with
15
- * an integrated "dismissed" version counter, exposing a single reactive
16
- * snapshot `{ error, toRoute, fromRoute, version, resetError }`.
17
- *
18
- * Each `RouterErrorBoundary` in a framework adapter subscribes to this one
19
- * source instead of re-implementing the `dismissedVersion` state pattern
20
- * locally. The 6-copy duplicate across adapters collapses to this helper.
21
- *
22
- * **Semantics:**
23
- * - `error` is non-null only when `underlying.version > dismissedVersion`.
24
- * - `resetError()` sets `dismissedVersion = current underlying version`,
25
- * immediately clearing `error` to `null` and notifying all listeners.
26
- * - A subsequent `TRANSITION_ERROR` advances `version` beyond `dismissedVersion`,
27
- * so `error` becomes non-null again — no additional plumbing needed.
28
- *
29
- * **Cached:** one instance per router. `destroy()` on the returned source is
30
- * a no-op. Shared across all `RouterErrorBoundary` consumers.
31
- */
32
- export function createDismissableError(
33
- router: Router,
34
- ): RouterSource<DismissableErrorSnapshot> {
35
- const cached = dismissableCache.get(router);
36
-
37
- if (cached) {
38
- return cached;
39
- }
40
-
41
- const errorSource = getErrorSource(router);
42
-
43
- let dismissedVersion = -1;
44
- // Hoisted up here so the `onFirstSubscribe` closure below can read/write
45
- // it before `disconnect()`'s declaration. JS hoisting makes the original
46
- // post-declaration order legal, but reading top-to-bottom is clearer.
47
- let unsubFromError: (() => void) | null = null;
48
-
49
- const buildDismissableSnapshot = (): DismissableErrorSnapshot => {
50
- const snap = errorSource.getSnapshot();
51
- const isDismissed = snap.version <= dismissedVersion;
52
-
53
- return {
54
- error: isDismissed ? null : snap.error,
55
- toRoute: isDismissed ? null : snap.toRoute,
56
- fromRoute: isDismissed ? null : snap.fromRoute,
57
- version: snap.version,
58
- resetError,
59
- };
60
- };
61
-
62
- const source = new BaseSource<DismissableErrorSnapshot>(
63
- buildDismissableSnapshot(),
64
- {
65
- onFirstSubscribe: () => {
66
- unsubFromError = errorSource.subscribe(() => {
67
- source.updateSnapshot(buildDismissableSnapshot());
68
- });
69
- },
70
- onLastUnsubscribe: () => {
71
- disconnect();
72
- },
73
- },
74
- );
75
-
76
- function resetError(): void {
77
- const currentVersion = errorSource.getSnapshot().version;
78
-
79
- // No-op guard: if we already dismissed at this version (or are even ahead
80
- // of the live error stream), there's nothing to clear. Skipping prevents
81
- // a redundant snapshot allocation + listener notification under tight
82
- // resetError(); resetError() patterns — common when a RouterErrorBoundary
83
- // user clicks "dismiss" while another dismiss is already in flight.
84
- if (currentVersion <= dismissedVersion) {
85
- return;
86
- }
87
-
88
- dismissedVersion = currentVersion;
89
- source.updateSnapshot(buildDismissableSnapshot());
90
- }
91
-
92
- function disconnect(): void {
93
- const unsub = unsubFromError;
94
-
95
- unsubFromError = null;
96
- unsub?.();
97
- }
98
-
99
- const wrapper: RouterSource<DismissableErrorSnapshot> = {
100
- subscribe: source.subscribe,
101
- getSnapshot: source.getSnapshot,
102
- destroy: noopDestroy,
103
- };
104
-
105
- dismissableCache.set(router, wrapper);
106
-
107
- return wrapper;
108
- }
@@ -1,111 +0,0 @@
1
- import { events } from "@real-router/core";
2
- import { getPluginApi } from "@real-router/core/api";
3
-
4
- import { BaseSource } from "./BaseSource";
5
- import { noopDestroy } from "./internal/noopDestroy.js";
6
-
7
- import type { RouterErrorSnapshot, RouterSource } from "./types.js";
8
- import type { Router, State, RouterError } from "@real-router/core";
9
-
10
- const INITIAL_SNAPSHOT: RouterErrorSnapshot = {
11
- error: null,
12
- toRoute: null,
13
- fromRoute: null,
14
- version: 0,
15
- };
16
-
17
- const errorSourceCache = new WeakMap<
18
- Router,
19
- RouterSource<RouterErrorSnapshot>
20
- >();
21
-
22
- export function createErrorSource(
23
- router: Router,
24
- ): RouterSource<RouterErrorSnapshot> {
25
- let errorVersion = 0;
26
- let hasError = false;
27
-
28
- const source = new BaseSource(INITIAL_SNAPSHOT, {
29
- onDestroy: () => {
30
- for (const unsub of unsubs) {
31
- unsub();
32
- }
33
- },
34
- });
35
-
36
- const api = getPluginApi(router);
37
-
38
- // Eager connection: subscribe to router events immediately
39
- const unsubs = [
40
- api.addEventListener(
41
- events.TRANSITION_ERROR,
42
- (
43
- toState: State | undefined,
44
- fromState: State | undefined,
45
- err: RouterError,
46
- ) => {
47
- errorVersion++;
48
- hasError = true;
49
- source.updateSnapshot({
50
- error: err,
51
- toRoute: toState ?? null,
52
- /* v8 ignore next -- @preserve: fromState undefined only during start() error; unreachable via navigate() */
53
- fromRoute: fromState ?? null,
54
- version: errorVersion,
55
- });
56
- },
57
- ),
58
- api.addEventListener(events.TRANSITION_SUCCESS, () => {
59
- // Skip if no error — avoids unnecessary re-renders.
60
- // BaseSource.updateSnapshot() always notifies listeners (new object = new ref),
61
- // and useSyncExternalStore compares via Object.is().
62
- if (hasError) {
63
- hasError = false;
64
- source.updateSnapshot({
65
- error: null,
66
- toRoute: null,
67
- fromRoute: null,
68
- version: errorVersion,
69
- });
70
- }
71
- }),
72
- ];
73
-
74
- return source;
75
- }
76
-
77
- /**
78
- * Returns a per-router cached error source shared across all consumers.
79
- *
80
- * Safe to call destroy() — the cached source ignores external destroy() calls
81
- * and lives until the router itself is garbage-collected (the WeakMap entry
82
- * releases automatically).
83
- *
84
- * Use this in framework adapters (React/Preact/Solid/Vue/Svelte/Angular) to
85
- * share a single ErrorSource instance across all mount/unmount cycles.
86
- *
87
- * For isolated/advanced use (ad-hoc, short-lived, per-owner teardown), call
88
- * `createErrorSource(router)` directly — it returns a fresh instance with a
89
- * working `destroy()`.
90
- */
91
- export function getErrorSource(
92
- router: Router,
93
- ): RouterSource<RouterErrorSnapshot> {
94
- let cached = errorSourceCache.get(router);
95
-
96
- if (!cached) {
97
- const source = createErrorSource(router);
98
-
99
- // Wrap with no-op destroy. The underlying source is shared across all
100
- // consumers; letting any one consumer call destroy() would tear it down
101
- // for the rest. The source lives as long as the router (WeakMap key).
102
- cached = {
103
- subscribe: source.subscribe,
104
- getSnapshot: source.getSnapshot,
105
- destroy: noopDestroy,
106
- };
107
- errorSourceCache.set(router, cached);
108
- }
109
-
110
- return cached;
111
- }
@@ -1,130 +0,0 @@
1
- import { BaseSource } from "./BaseSource";
2
- import { computeSnapshot } from "./computeSnapshot.js";
3
- import { noopDestroy } from "./internal/noopDestroy.js";
4
-
5
- import type { RouteNodeSnapshot, RouterSource } from "./types.js";
6
- import type { Router } from "@real-router/core";
7
-
8
- const nodeSourceCache = new WeakMap<
9
- Router,
10
- Map<string, RouterSource<RouteNodeSnapshot>>
11
- >();
12
-
13
- /**
14
- * Creates a source scoped to a specific route node.
15
- *
16
- * **Per-router + per-nodeName cache:** repeated calls with the same
17
- * `(router, nodeName)` return the same shared instance. `N` consumers
18
- * calling `createRouteNodeSource(r, "users")` produce one router subscription
19
- * shared across all of them.
20
- *
21
- * Uses a lazy-connection pattern: the router subscription is created when the
22
- * first listener subscribes and removed when the last listener unsubscribes.
23
- * This is compatible with React's useSyncExternalStore and Strict Mode.
24
- *
25
- * `destroy()` on the returned source is a no-op — the shared instance lives
26
- * as long as the router itself (the WeakMap entry releases automatically on
27
- * router GC). Callers that need an isolated instance with working teardown
28
- * can use `buildRouteNodeSource` internally (not exported).
29
- */
30
- export function createRouteNodeSource(
31
- router: Router,
32
- nodeName: string,
33
- ): RouterSource<RouteNodeSnapshot> {
34
- let perRouter = nodeSourceCache.get(router);
35
-
36
- if (!perRouter) {
37
- perRouter = new Map();
38
- nodeSourceCache.set(router, perRouter);
39
- }
40
-
41
- let cached = perRouter.get(nodeName);
42
-
43
- if (!cached) {
44
- const source = buildRouteNodeSource(router, nodeName);
45
-
46
- // Wrap with no-op destroy. The shared source lives with the router.
47
- cached = {
48
- subscribe: source.subscribe,
49
- getSnapshot: source.getSnapshot,
50
- destroy: noopDestroy,
51
- };
52
- perRouter.set(nodeName, cached);
53
- }
54
-
55
- return cached;
56
- }
57
-
58
- function buildRouteNodeSource(
59
- router: Router,
60
- nodeName: string,
61
- ): RouterSource<RouteNodeSnapshot> {
62
- let routerUnsubscribe: (() => void) | null = null;
63
-
64
- // Built once per cached source instance; safe — createRouteNodeSource is
65
- // itself per-(router, nodeName) cached, so shouldUpdate is called once.
66
- const shouldUpdate = router.shouldUpdateNode(nodeName);
67
-
68
- const initialSnapshot: RouteNodeSnapshot = {
69
- route: undefined,
70
- previousRoute: undefined,
71
- };
72
-
73
- const disconnect = (): void => {
74
- const unsub = routerUnsubscribe;
75
-
76
- routerUnsubscribe = null;
77
- unsub?.();
78
- };
79
-
80
- const source = new BaseSource<RouteNodeSnapshot>(
81
- computeSnapshot(initialSnapshot, router, nodeName),
82
- {
83
- onFirstSubscribe: () => {
84
- // Reconcile snapshot with current router state before connecting.
85
- // Covers reconnection after Activity hide/show cycles where the
86
- // source was disconnected and missed navigation events.
87
- const reconciled = computeSnapshot(
88
- source.getSnapshot(),
89
- router,
90
- nodeName,
91
- );
92
-
93
- if (!Object.is(reconciled, source.getSnapshot())) {
94
- source.updateSnapshot(reconciled);
95
- }
96
-
97
- // Connect to router on first subscription
98
- routerUnsubscribe = router.subscribe((next) => {
99
- if (!shouldUpdate(next.route, next.previousRoute)) {
100
- return;
101
- }
102
-
103
- const newSnapshot = computeSnapshot(
104
- source.getSnapshot(),
105
- router,
106
- nodeName,
107
- next,
108
- );
109
-
110
- // computeSnapshot returns the SAME currentSnapshot reference when
111
- // both route and previousRoute stabilize to prev — guard against
112
- // emitting redundant updates to listeners (matters for signal-
113
- // based adapters that re-run effects on every set).
114
- /* v8 ignore next 3 -- @preserve: structurally unreachable after #605
115
- — reload navs always return fresh refs via stabilizeState, and
116
- within-node non-reload navs short-circuit at shouldUpdate. Guard
117
- kept for defensive correctness against future stabilizer changes. */
118
- if (Object.is(source.getSnapshot(), newSnapshot)) {
119
- return;
120
- }
121
-
122
- source.updateSnapshot(newSnapshot);
123
- });
124
- },
125
- onLastUnsubscribe: disconnect,
126
- },
127
- );
128
-
129
- return source;
130
- }
@@ -1,56 +0,0 @@
1
- import { BaseSource } from "./BaseSource";
2
- import { stabilizeState } from "./stabilizeState.js";
3
-
4
- import type { RouteSnapshot, RouterSource } from "./types.js";
5
- import type { Router } from "@real-router/core";
6
-
7
- /**
8
- * Creates a source for the full route state.
9
- *
10
- * Uses a lazy-connection pattern: the router subscription is created when the
11
- * first listener subscribes and removed when the last listener unsubscribes.
12
- * This is compatible with React's useSyncExternalStore and Strict Mode.
13
- */
14
- export function createRouteSource(router: Router): RouterSource<RouteSnapshot> {
15
- let routerUnsubscribe: (() => void) | null = null;
16
-
17
- const disconnect = (): void => {
18
- const unsub = routerUnsubscribe;
19
-
20
- routerUnsubscribe = null;
21
- unsub?.();
22
- };
23
-
24
- const source = new BaseSource<RouteSnapshot>(
25
- {
26
- route: router.getState(),
27
- previousRoute: undefined,
28
- },
29
- {
30
- onFirstSubscribe: () => {
31
- routerUnsubscribe = router.subscribe((next) => {
32
- const prev = source.getSnapshot();
33
- const newRoute = stabilizeState(prev.route, next.route);
34
- const newPreviousRoute = stabilizeState(
35
- prev.previousRoute,
36
- next.previousRoute,
37
- );
38
-
39
- if (
40
- newRoute !== prev.route ||
41
- newPreviousRoute !== prev.previousRoute
42
- ) {
43
- source.updateSnapshot({
44
- route: newRoute,
45
- previousRoute: newPreviousRoute,
46
- });
47
- }
48
- });
49
- },
50
- onLastUnsubscribe: disconnect,
51
- onDestroy: disconnect,
52
- },
53
- );
54
-
55
- return source;
56
- }
@@ -1,192 +0,0 @@
1
- import { events } from "@real-router/core";
2
- import { getPluginApi } from "@real-router/core/api";
3
-
4
- import { BaseSource } from "./BaseSource";
5
- import { noopDestroy } from "./internal/noopDestroy.js";
6
- import { stabilizeState } from "./stabilizeState.js";
7
-
8
- import type { RouterTransitionSnapshot, RouterSource } from "./types.js";
9
- import type { Router, State } from "@real-router/core";
10
-
11
- // Frozen so accidental consumer mutation (`source.getSnapshot().toRoute = X`)
12
- // throws in strict mode. The singleton ref is shared across every IDLE state
13
- // for the lifetime of the process — mutating it would corrupt the contract
14
- // "all IDLE snapshots are the same object reference" relied on by every
15
- // adapter's useSyncExternalStore equivalent.
16
- const IDLE_SNAPSHOT: RouterTransitionSnapshot = Object.freeze({
17
- isTransitioning: false,
18
- isLeaveApproved: false,
19
- toRoute: null,
20
- fromRoute: null,
21
- });
22
-
23
- const transitionSourceCache = new WeakMap<
24
- Router,
25
- RouterSource<RouterTransitionSnapshot>
26
- >();
27
-
28
- /**
29
- * @internal test-only export — returns the next snapshot for a TRANSITION_START
30
- * payload, or `null` when the same-paths dedup guard should suppress the
31
- * update. Exported so the (structurally-unreachable after #605) guard can be
32
- * exercised by unit tests without resorting to private-API hacks.
33
- */
34
- export function nextTransitionStartSnapshot(
35
- prev: RouterTransitionSnapshot,
36
- toState: State,
37
- fromState: State | undefined,
38
- ): RouterTransitionSnapshot | null {
39
- const newToRoute = stabilizeState(prev.toRoute, toState);
40
- const newFromRoute = stabilizeState(prev.fromRoute, fromState ?? null);
41
-
42
- if (
43
- prev.isTransitioning &&
44
- newToRoute === prev.toRoute &&
45
- newFromRoute === prev.fromRoute
46
- ) {
47
- return null;
48
- }
49
-
50
- return {
51
- isTransitioning: true,
52
- isLeaveApproved: false,
53
- toRoute: newToRoute,
54
- fromRoute: newFromRoute,
55
- };
56
- }
57
-
58
- /**
59
- * @internal test-only export — analogous to {@link nextTransitionStartSnapshot}
60
- * for the LEAVE_APPROVE payload. The guard is structurally unreachable in
61
- * practice (router emits LEAVE_APPROVE exactly once per pipeline) but stays
62
- * for plugin-driven re-entrant flows.
63
- */
64
- export function nextLeaveApproveSnapshot(
65
- prev: RouterTransitionSnapshot,
66
- toState: State,
67
- fromState: State | undefined,
68
- ): RouterTransitionSnapshot | null {
69
- const newToRoute = stabilizeState(prev.toRoute, toState);
70
- const newFromRoute = stabilizeState(prev.fromRoute, fromState ?? null);
71
-
72
- if (
73
- prev.isLeaveApproved &&
74
- newToRoute === prev.toRoute &&
75
- newFromRoute === prev.fromRoute
76
- ) {
77
- return null;
78
- }
79
-
80
- return {
81
- isTransitioning: true,
82
- isLeaveApproved: true,
83
- toRoute: newToRoute,
84
- fromRoute: newFromRoute,
85
- };
86
- }
87
-
88
- export function createTransitionSource(
89
- router: Router,
90
- ): RouterSource<RouterTransitionSnapshot> {
91
- const source = new BaseSource(IDLE_SNAPSHOT, {
92
- onDestroy: () => {
93
- for (const unsub of unsubs) {
94
- unsub();
95
- }
96
- },
97
- });
98
-
99
- const api = getPluginApi(router);
100
-
101
- const resetToIdle = (): void => {
102
- source.updateSnapshot(IDLE_SNAPSHOT);
103
- };
104
-
105
- // Eager connection: subscribe to router events immediately
106
- const unsubs = [
107
- api.addEventListener(
108
- events.TRANSITION_START,
109
- (toState: State, fromState?: State) => {
110
- // The same-paths dedup branch inside nextTransitionStartSnapshot is
111
- // structurally unreachable after #605 (every router-emitted
112
- // TRANSITION_START carries a fresh State per navigate()), but the
113
- // helper is kept testable for future stabilizer changes — see the
114
- // direct unit test in createTransitionSource.test.ts.
115
- const next = nextTransitionStartSnapshot(
116
- source.getSnapshot(),
117
- toState,
118
- fromState,
119
- );
120
-
121
- /* v8 ignore next 3 -- @preserve: dedup-skip branch unreachable through
122
- normal router flow; covered directly via nextTransitionStartSnapshot
123
- unit test. */
124
- if (next === null) {
125
- return;
126
- }
127
-
128
- source.updateSnapshot(next);
129
- },
130
- ),
131
- api.addEventListener(
132
- events.TRANSITION_LEAVE_APPROVE,
133
- (toState: State, fromState?: State) => {
134
- const next = nextLeaveApproveSnapshot(
135
- source.getSnapshot(),
136
- toState,
137
- fromState,
138
- );
139
-
140
- /* v8 ignore next 3 -- @preserve: dedup-skip branch unreachable through
141
- normal router flow (LEAVE_APPROVE fires once per pipeline); covered
142
- directly via nextLeaveApproveSnapshot unit test. */
143
- if (next === null) {
144
- return;
145
- }
146
-
147
- source.updateSnapshot(next);
148
- },
149
- ),
150
- api.addEventListener(events.TRANSITION_SUCCESS, resetToIdle),
151
- api.addEventListener(events.TRANSITION_ERROR, resetToIdle),
152
- api.addEventListener(events.TRANSITION_CANCEL, resetToIdle),
153
- ];
154
-
155
- return source;
156
- }
157
-
158
- /**
159
- * Returns a per-router cached transition source shared across all consumers.
160
- *
161
- * Safe to call destroy() — the cached source ignores external destroy() calls
162
- * and lives until the router itself is garbage-collected (the WeakMap entry
163
- * releases automatically).
164
- *
165
- * Use this in framework adapters (React/Preact/Solid/Vue/Svelte/Angular) to
166
- * share a single TransitionSource instance across all mount/unmount cycles.
167
- *
168
- * For isolated/advanced use (ad-hoc, short-lived, per-owner teardown), call
169
- * `createTransitionSource(router)` directly — it returns a fresh instance with
170
- * a working `destroy()`.
171
- */
172
- export function getTransitionSource(
173
- router: Router,
174
- ): RouterSource<RouterTransitionSnapshot> {
175
- let cached = transitionSourceCache.get(router);
176
-
177
- if (!cached) {
178
- const source = createTransitionSource(router);
179
-
180
- // Wrap with no-op destroy. The underlying source is shared across all
181
- // consumers; letting any one consumer call destroy() would tear it down
182
- // for the rest. The source lives as long as the router (WeakMap key).
183
- cached = {
184
- subscribe: source.subscribe,
185
- getSnapshot: source.getSnapshot,
186
- destroy: noopDestroy,
187
- };
188
- transitionSourceCache.set(router, cached);
189
- }
190
-
191
- return cached;
192
- }
package/src/index.ts DELETED
@@ -1,35 +0,0 @@
1
- export type {
2
- RouterSource,
3
- RouteSnapshot,
4
- RouteNodeSnapshot,
5
- ActiveRouteSourceOptions,
6
- RouterTransitionSnapshot,
7
- RouterErrorSnapshot,
8
- DismissableErrorSnapshot,
9
- } from "./types.js";
10
-
11
- export { createRouteSource } from "./createRouteSource";
12
-
13
- export { createRouteNodeSource } from "./createRouteNodeSource";
14
-
15
- export { createActiveRouteSource } from "./createActiveRouteSource";
16
-
17
- export {
18
- createTransitionSource,
19
- getTransitionSource,
20
- } from "./createTransitionSource";
21
-
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";
@@ -1,19 +0,0 @@
1
- /**
2
- * @internal
3
- *
4
- * Shared no-op `destroy()` for cached `RouterSource` wrappers.
5
- *
6
- * Cached factories (`getTransitionSource`, `getErrorSource`, `createDismissableError`,
7
- * `createActiveNameSelector`, cache hit in `createRouteNodeSource`,
8
- * `createActiveRouteSource`) return a wrapper whose `destroy()` is a no-op —
9
- * the underlying source is shared across all consumers and lives as long as
10
- * the router (WeakMap entry releases on router GC).
11
- *
12
- * One module-level function shared by every cached factory keeps the wrapper
13
- * shape (`{ subscribe, getSnapshot, destroy: noopDestroy }`) byte-stable and
14
- * eliminates the previous six copies. (Bundlers inline a stand-alone arrow
15
- * just as readily, so the cost is purely on the maintenance side.)
16
- */
17
- export function noopDestroy(): void {
18
- // Shared cached source — external destroy() is a no-op.
19
- }
@@ -1,32 +0,0 @@
1
- import type { State } from "@real-router/core";
2
-
3
- /**
4
- * @internal
5
- *
6
- * Reads the URL fragment published by `browser-plugin` / `navigation-plugin`
7
- * on a router state. The plugins claim the `"url"` namespace via
8
- * `state.context.url` and the `hash` field carries the **decoded** fragment.
9
- *
10
- * Returns:
11
- * - `undefined` — no plugin claimed the `"url"` namespace (hash-plugin runtime,
12
- * memory-plugin, SSR before hydration) OR the state itself is nullish;
13
- * - `""` — the URL namespace exists but the fragment is empty
14
- * (browser-plugin on a hash-less URL).
15
- *
16
- * Callers that need the "no namespace at all" branch (e.g. `stabilizeState`
17
- * comparing cross-plugin transitions) read the raw `undefined`. Callers that
18
- * collapse "no namespace" to "no hash" (e.g. `createActiveRouteSource`'s
19
- * hash-equality check) coalesce with `?? ""` themselves.
20
- *
21
- * Centralising the context cast removes the previous duplicate definitions in
22
- * `stabilizeState.ts` and `createActiveRouteSource.ts` that drifted in
23
- * signature (state vs router) and default-value (undefined vs "") — both
24
- * variants are reconstructible from this single helper at the callsite.
25
- */
26
- export function readContextHash(
27
- state: State | null | undefined,
28
- ): string | undefined {
29
- const ctx = state?.context as { url?: { hash?: string } } | undefined;
30
-
31
- return ctx?.url?.hash;
32
- }