@real-router/sources 0.8.5 → 0.8.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/sources",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "type": "commonjs",
5
5
  "description": "Framework-agnostic subscription layer for Real-Router state",
6
6
  "main": "./dist/cjs/index.js",
@@ -18,8 +18,7 @@
18
18
  }
19
19
  },
20
20
  "files": [
21
- "dist",
22
- "src"
21
+ "dist"
23
22
  ],
24
23
  "repository": {
25
24
  "type": "git",
@@ -43,7 +42,7 @@
43
42
  "homepage": "https://github.com/greydragon888/real-router",
44
43
  "sideEffects": false,
45
44
  "dependencies": {
46
- "@real-router/core": "^0.56.0",
45
+ "@real-router/core": "^0.58.0",
47
46
  "@real-router/route-utils": "^0.2.3"
48
47
  },
49
48
  "devDependencies": {
package/src/BaseSource.ts DELETED
@@ -1,95 +0,0 @@
1
- export interface BaseSourceOptions {
2
- onFirstSubscribe?: () => void;
3
- onLastUnsubscribe?: () => void;
4
- onDestroy?: () => void;
5
- }
6
-
7
- export class BaseSource<T> {
8
- #currentSnapshot: T;
9
- #destroyed = false;
10
-
11
- readonly #listeners = new Set<() => void>();
12
- readonly #onFirstSubscribe: (() => void) | undefined;
13
- readonly #onLastUnsubscribe: (() => void) | undefined;
14
- readonly #onDestroy: (() => void) | undefined;
15
-
16
- constructor(initialSnapshot: T, options?: BaseSourceOptions) {
17
- this.#currentSnapshot = initialSnapshot;
18
- this.#onFirstSubscribe = options?.onFirstSubscribe;
19
- this.#onLastUnsubscribe = options?.onLastUnsubscribe;
20
- this.#onDestroy = options?.onDestroy;
21
-
22
- this.subscribe = this.subscribe.bind(this);
23
- this.getSnapshot = this.getSnapshot.bind(this);
24
- this.destroy = this.destroy.bind(this);
25
- }
26
-
27
- subscribe(listener: () => void): () => void {
28
- if (this.#destroyed) {
29
- return () => {};
30
- }
31
-
32
- const wasFirst = this.#listeners.size === 0;
33
-
34
- // Add listener BEFORE onFirstSubscribe so that if the reconciliation in
35
- // onFirstSubscribe calls updateSnapshot(), this listener receives the
36
- // notification. Critical for useSyncExternalStore in adapters — without
37
- // this the post-reconnection snapshot is missed and consumers render
38
- // stale data. (See Preact RouteView nested remount test.)
39
- this.#listeners.add(listener);
40
-
41
- if (wasFirst && this.#onFirstSubscribe) {
42
- this.#onFirstSubscribe();
43
- }
44
-
45
- return () => {
46
- this.#listeners.delete(listener);
47
-
48
- if (
49
- !this.#destroyed &&
50
- this.#listeners.size === 0 &&
51
- this.#onLastUnsubscribe
52
- ) {
53
- this.#onLastUnsubscribe();
54
- }
55
- };
56
- }
57
-
58
- getSnapshot(): T {
59
- return this.#currentSnapshot;
60
- }
61
-
62
- updateSnapshot(snapshot: T): void {
63
- /* v8 ignore next 2 -- @preserve: defensive guard unreachable via public API (destroy() removes router subscription first) */
64
- if (this.#destroyed) {
65
- return;
66
- }
67
-
68
- this.#currentSnapshot = snapshot;
69
- // Isolate listener exceptions so a single throwing subscriber (e.g. React
70
- // error-boundary fallback throwing inside `onStoreChange`) does not block
71
- // the remaining subscribers — the invariant "after updateSnapshot all
72
- // listeners see the new snapshot" must hold. Re-throw asynchronously via
73
- // queueMicrotask so global error handlers / test harnesses still surface
74
- // the bug without breaking the synchronous notification fan-out.
75
- for (const listener of this.#listeners) {
76
- try {
77
- listener();
78
- } catch (error) {
79
- queueMicrotask(() => {
80
- throw error;
81
- });
82
- }
83
- }
84
- }
85
-
86
- destroy(): void {
87
- if (this.#destroyed) {
88
- return;
89
- }
90
-
91
- this.#destroyed = true;
92
- this.#onDestroy?.();
93
- this.#listeners.clear();
94
- }
95
- }
@@ -1,110 +0,0 @@
1
- /**
2
- * Serializes a value into a stable JSON string — object keys are sorted at
3
- * every level so that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same
4
- * output.
5
- *
6
- * Used as a cache key for `createActiveRouteSource` so that equivalent params
7
- * objects share the same cached source regardless of key order.
8
- *
9
- * **Divergence from `shared/dom-utils/scroll-restore.canonicalJson` — by
10
- * design.** That sibling implementation is the cheap navigation-hot-path
11
- * variant (uses `localeCompare`, plain-object accumulator, native cycle
12
- * detector) and pairs with `safeKeyOf` for crash-tolerance. This one is the
13
- * strict cache-key variant: byte-order compare, prototype-less accumulator,
14
- * bespoke cycle detection — eagerly throws on inputs that would cause
15
- * collisions or pollution, so callers can fall back to non-cached sources.
16
- * They are NOT interchangeable; cross-package equivalence is explicitly not
17
- * a goal (audit-2 / audit-2026-05-17 §2).
18
- *
19
- * Edge cases:
20
- * - Arrays preserve order (canonical: index-ordered already).
21
- * - `undefined` values are dropped (standard JSON behaviour).
22
- * - `Date` is serialized via its `toJSON` method (ISO string).
23
- * - `Symbol` becomes `undefined` (standard JSON behaviour).
24
- * - `BigInt` throws via `JSON.stringify` defaults.
25
- * - `Map`, `Set`, `RegExp`, `WeakMap`, `WeakSet` would silently collapse to
26
- * `"{}"` (no enumerable own keys), which would cause **different inputs to
27
- * share the same cache key**. We detect these explicitly and throw — callers
28
- * then take the non-cached fallback path (same behaviour as `BigInt`).
29
- * - Circular references throw `TypeError` (parity with native `JSON.stringify`).
30
- * The replacer copies each object level into a fresh prototype-less record,
31
- * so we must run our own cycle detection — the native detector never sees
32
- * the original object graph.
33
- * - `__proto__` keys are preserved as own properties (no prototype pollution
34
- * and no silent collision between `{ __proto__: x, b: 1 }` and `{ b: 1 }`).
35
- *
36
- * In practice, route params carry primitives (`string | number | boolean`);
37
- * the cache-key-collision edge cases above are defensive bugs caught loudly.
38
- */
39
- export function canonicalJson(value: unknown): string {
40
- return JSON.stringify(canonicalize(value, new Set<object>()));
41
- }
42
-
43
- // Key comparison uses byte-order (`<` / `>`) instead of `localeCompare()` so
44
- // the output is independent of the Node.js ICU build / system locale. Same
45
- // canonical form on every machine — required for cache-key stability.
46
- function compareKeys(left: string, right: string): number {
47
- return left < right ? -1 : 1;
48
- }
49
-
50
- /**
51
- * Returns a structural clone with object keys sorted at every level. Path-based
52
- * cycle detection (`path` set) matches the semantics of native `JSON.stringify`
53
- * — a `TypeError` is thrown on a true cycle, but the same object reachable via
54
- * two independent branches (DAG) serialises fine.
55
- *
56
- * `Date` instances pass through unchanged so `JSON.stringify` can invoke their
57
- * `toJSON` hook. Other built-ins (`Map`, `Set`, `WeakMap`, `WeakSet`, `RegExp`)
58
- * throw eagerly to avoid silent cache-key collisions.
59
- */
60
- function canonicalize(value: unknown, path: Set<object>): unknown {
61
- if (value === null || typeof value !== "object") {
62
- return value;
63
- }
64
-
65
- if (value instanceof Date) {
66
- return value;
67
- }
68
-
69
- if (
70
- value instanceof Map ||
71
- value instanceof Set ||
72
- value instanceof WeakMap ||
73
- value instanceof WeakSet ||
74
- value instanceof RegExp
75
- ) {
76
- throw new TypeError(
77
- `canonicalJson: cannot serialize ${(value as { constructor: { name: string } }).constructor.name} — non-enumerable own keys collapse to "{}" and would cause cache-key collisions. Pass primitive params (string | number | boolean) instead.`,
78
- );
79
- }
80
-
81
- if (path.has(value)) {
82
- throw new TypeError(
83
- "canonicalJson: cannot serialize circular structure (cycle detected during traversal).",
84
- );
85
- }
86
-
87
- path.add(value);
88
- try {
89
- if (Array.isArray(value)) {
90
- return value.map((item) => canonicalize(item, path));
91
- }
92
-
93
- // Use a null-prototype record so `__proto__` is treated as a regular
94
- // own property — assigning to a plain `{}` would set the prototype
95
- // chain instead and silently collide with inputs that omit the key.
96
- const sorted: Record<string, unknown> = Object.create(null) as Record<
97
- string,
98
- unknown
99
- >;
100
- const keys = Object.keys(value).toSorted(compareKeys);
101
-
102
- for (const key of keys) {
103
- sorted[key] = canonicalize((value as Record<string, unknown>)[key], path);
104
- }
105
-
106
- return sorted;
107
- } finally {
108
- path.delete(value);
109
- }
110
- }
@@ -1,44 +0,0 @@
1
- import { stabilizeState } from "./stabilizeState.js";
2
-
3
- import type { RouteNodeSnapshot } from "./types.js";
4
- import type { Router, SubscribeState } from "@real-router/core";
5
-
6
- export function computeSnapshot(
7
- currentSnapshot: RouteNodeSnapshot,
8
- router: Router,
9
- nodeName: string,
10
- next?: SubscribeState,
11
- ): RouteNodeSnapshot {
12
- const currentRoute = next?.route ?? router.getState();
13
- const previousRoute = next?.previousRoute;
14
-
15
- const isNodeActive =
16
- nodeName === "" ||
17
- (currentRoute !== undefined &&
18
- (currentRoute.name === nodeName ||
19
- currentRoute.name.startsWith(`${nodeName}.`)));
20
-
21
- const route = isNodeActive ? currentRoute : undefined;
22
-
23
- if (
24
- route === currentSnapshot.route &&
25
- previousRoute === currentSnapshot.previousRoute
26
- ) {
27
- return currentSnapshot;
28
- }
29
-
30
- const newRoute = stabilizeState(currentSnapshot.route, route);
31
- const newPreviousRoute = stabilizeState(
32
- currentSnapshot.previousRoute,
33
- previousRoute,
34
- );
35
-
36
- if (
37
- newRoute === currentSnapshot.route &&
38
- newPreviousRoute === currentSnapshot.previousRoute
39
- ) {
40
- return currentSnapshot;
41
- }
42
-
43
- return { route: newRoute, previousRoute: newPreviousRoute };
44
- }
@@ -1,182 +0,0 @@
1
- import { areRoutesRelated } from "@real-router/route-utils";
2
-
3
- import { noopDestroy } from "./internal/noopDestroy.js";
4
-
5
- import type { Router } from "@real-router/core";
6
-
7
- export interface ActiveNameSelector {
8
- /**
9
- * Subscribes to active-state changes of a specific route name.
10
- * Listener is called only when `isActive(routeName)` for this name transitions.
11
- * Returns an unsubscribe function.
12
- */
13
- subscribe: (routeName: string, listener: () => void) => () => void;
14
- /**
15
- * O(1) active check for the given route name (non-strict by default —
16
- * matches descendants). Uses the shared underlying router subscription.
17
- */
18
- isActive: (routeName: string) => boolean;
19
- /** No-op on the cached wrapper. */
20
- destroy: () => void;
21
- }
22
-
23
- const selectorCache = new WeakMap<Router, ActiveNameSelector>();
24
-
25
- /**
26
- * Per-router cached selector providing O(1) active-route checks with one
27
- * shared router subscription for any number of distinct `routeName`
28
- * consumers.
29
- *
30
- * **When to use:** framework `Link` components that need an active boolean
31
- * without custom `params` / `activeStrict` / `ignoreQueryParams` — e.g. the
32
- * common navigation-link case. Multiple `<Link>` components with different
33
- * `routeName` share ONE `router.subscribe` handle instead of creating one
34
- * per Link (which is what `createActiveRouteSource(router, name)` does — it
35
- * caches per-name, so N names = N subscriptions).
36
- *
37
- * **When NOT to use:** Link needs `activeStrict: true`, custom `routeParams`,
38
- * or `ignoreQueryParams: false`. Fall back to `createActiveRouteSource` —
39
- * its cache handles the full argument surface.
40
- *
41
- * Based on the `routeSelector` pattern pioneered by `@real-router/solid`'s
42
- * `RouterProvider` (`createSelector` + `areRoutesRelated`). This helper
43
- * ports it to framework-agnostic API so Vue / React / Preact / Svelte /
44
- * Angular Link components can adopt the same fast path.
45
- *
46
- * @see Solid reference implementation — `packages/solid/src/RouterProvider.tsx`
47
- */
48
- export function createActiveNameSelector(router: Router): ActiveNameSelector {
49
- const cached = selectorCache.get(router);
50
-
51
- if (cached) {
52
- return cached;
53
- }
54
-
55
- // listeners per-name — re-evaluated on every router transition
56
- const listenersByName = new Map<string, Set<() => void>>();
57
- // cached active state per-name — used to diff before notifying
58
- const activeByName = new Map<string, boolean>();
59
-
60
- let routerUnsubscribe: (() => void) | null = null;
61
-
62
- const isActiveNonStrict = (routeName: string): boolean => {
63
- const current = router.getState();
64
-
65
- if (!current) {
66
- return false;
67
- }
68
-
69
- // Empty string represents the root of the name hierarchy — every named
70
- // route is its descendant. Without this short-circuit, `current.name`
71
- // would have to equal `""` or start with `"."` (both impossible for
72
- // valid route names), breaking symmetry with `createRouteNodeSource("")`
73
- // which is always-active when a route is current.
74
- if (routeName === "") {
75
- return true;
76
- }
77
-
78
- return (
79
- current.name === routeName || current.name.startsWith(`${routeName}.`)
80
- );
81
- };
82
-
83
- const connect = (): void => {
84
- routerUnsubscribe = router.subscribe((next) => {
85
- for (const [routeName, listeners] of listenersByName) {
86
- // Cheap pre-filter: if neither new nor previous route is related
87
- // to this name, its active state cannot have changed. Empty
88
- // routeName is the implicit root — every route is its descendant,
89
- // so the filter would falsely exclude it (`areRoutesRelated`
90
- // doesn't treat `""` specially). Skip the filter for the root.
91
- if (routeName !== "") {
92
- const isNewRelated = areRoutesRelated(routeName, next.route.name);
93
- const isPrevRelated =
94
- next.previousRoute &&
95
- areRoutesRelated(routeName, next.previousRoute.name);
96
-
97
- if (!isNewRelated && !isPrevRelated) {
98
- continue;
99
- }
100
- }
101
-
102
- // activeByName always has an entry for names present in listenersByName —
103
- // subscribe() seeds it, and we only iterate over listenersByName.
104
- const prevActive = activeByName.get(routeName) === true;
105
- const nextActive = isActiveNonStrict(routeName);
106
-
107
- if (prevActive === nextActive) {
108
- continue;
109
- }
110
-
111
- activeByName.set(routeName, nextActive);
112
- for (const listener of listeners) {
113
- listener();
114
- }
115
- }
116
- });
117
- };
118
-
119
- const disconnect = (): void => {
120
- const unsub = routerUnsubscribe;
121
-
122
- routerUnsubscribe = null;
123
- unsub?.();
124
- };
125
-
126
- const subscribe = (routeName: string, listener: () => void): (() => void) => {
127
- let listeners = listenersByName.get(routeName);
128
-
129
- if (!listeners) {
130
- listeners = new Set();
131
- listenersByName.set(routeName, listeners);
132
- activeByName.set(routeName, isActiveNonStrict(routeName));
133
- }
134
-
135
- listeners.add(listener);
136
-
137
- if (!routerUnsubscribe) {
138
- connect();
139
- }
140
-
141
- let unsubscribed = false;
142
-
143
- return () => {
144
- if (unsubscribed) {
145
- return;
146
- }
147
-
148
- unsubscribed = true;
149
- listeners.delete(listener);
150
-
151
- if (listeners.size === 0) {
152
- listenersByName.delete(routeName);
153
- activeByName.delete(routeName);
154
- }
155
-
156
- if (listenersByName.size === 0) {
157
- disconnect();
158
- }
159
- };
160
- };
161
-
162
- const isActive = (routeName: string): boolean => {
163
- const cachedActive = activeByName.get(routeName);
164
-
165
- if (cachedActive !== undefined) {
166
- return cachedActive;
167
- }
168
-
169
- // Not subscribed — compute on demand.
170
- return isActiveNonStrict(routeName);
171
- };
172
-
173
- const selector: ActiveNameSelector = {
174
- subscribe,
175
- isActive,
176
- destroy: noopDestroy,
177
- };
178
-
179
- selectorCache.set(router, selector);
180
-
181
- return selector;
182
- }
@@ -1,223 +0,0 @@
1
- import { areRoutesRelated } from "@real-router/route-utils";
2
-
3
- import { BaseSource } from "./BaseSource";
4
- import { canonicalJson } from "./canonicalJson.js";
5
- import { noopDestroy } from "./internal/noopDestroy.js";
6
- import { readContextHash } from "./internal/readContextHash.js";
7
- import { normalizeActiveOptions } from "./normalizeActiveOptions.js";
8
-
9
- import type { ActiveRouteSourceOptions, RouterSource } from "./types.js";
10
- import type { Params, Router } from "@real-router/core";
11
-
12
- const activeSourceCache = new WeakMap<
13
- Router,
14
- Map<string, RouterSource<boolean>>
15
- >();
16
-
17
- /**
18
- * Creates a source tracking whether a route (with given params/options) is active.
19
- *
20
- * **Per-router + canonical-args cache:** repeated calls with equivalent
21
- * arguments return the same shared instance. Param key order doesn't matter
22
- * (`{ a:1, b:2 }` and `{ b:2, a:1 }` hit the same cache entry via
23
- * `canonicalJson`).
24
- *
25
- * For cached entries `destroy()` is a no-op — shared sources live with the
26
- * router and release automatically on router GC (WeakMap entry).
27
- *
28
- * `BigInt`/circular params can't be serialized → the source bypasses the cache
29
- * and `destroy()` becomes a real teardown that detaches the underlying
30
- * `router.subscribe` handle.
31
- */
32
- export function createActiveRouteSource(
33
- router: Router,
34
- routeName: string,
35
- params?: Params,
36
- options?: ActiveRouteSourceOptions,
37
- ): RouterSource<boolean> {
38
- const { strict, ignoreQueryParams, hash } = 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
- // `hash === undefined` produces "" via String(undefined) → "undefined";
47
- // we encode it as the empty string sentinel to keep the key short and
48
- // distinct from the literal "undefined" hash value (which is a valid,
49
- // if unusual, fragment).
50
- const hashKey = hash === undefined ? "" : `#${hash}`;
51
-
52
- // `params === undefined` is the common Link case (`<Link to="users">`
53
- // with no params). Skip canonicalJson(undefined) — it returns the literal
54
- // string "undefined" and template interpolation would just embed it. An
55
- // explicit empty sentinel avoids the call and shaves the cache-key by 9
56
- // characters per Link.
57
- const paramsKey = params === undefined ? "" : canonicalJson(params);
58
-
59
- // Delimiter `|` is safe because route names use `.` as the segment
60
- // separator (`users.list`, not `users|list`) and canonicalJson-encoded
61
- // params escape `"` (so any literal `|` inside params lives inside a
62
- // quoted JSON string and can't be confused with our delimiter). If route
63
- // names ever grow a `|` character, this composite key would become
64
- // ambiguous — change the separator to a control char or hash-encode each
65
- // field.
66
- key = `${routeName}|${paramsKey}|${String(strict)}|${String(ignoreQueryParams)}|${hashKey}`;
67
- } catch {
68
- key = undefined;
69
- }
70
-
71
- if (key === undefined) {
72
- // Non-cached fallback (canonicalJson threw on BigInt / circular / etc.).
73
- // Return the real source — `destroy()` must unwind the router subscription;
74
- // otherwise the wrapper leaks for the lifetime of the router.
75
- return buildActiveRouteSource(
76
- router,
77
- routeName,
78
- params,
79
- strict,
80
- ignoreQueryParams,
81
- hash,
82
- );
83
- }
84
-
85
- let perRouter = activeSourceCache.get(router);
86
-
87
- if (!perRouter) {
88
- perRouter = new Map();
89
- activeSourceCache.set(router, perRouter);
90
- }
91
-
92
- let cached = perRouter.get(key);
93
-
94
- if (!cached) {
95
- const source = buildActiveRouteSource(
96
- router,
97
- routeName,
98
- params,
99
- strict,
100
- ignoreQueryParams,
101
- hash,
102
- );
103
-
104
- cached = {
105
- subscribe: source.subscribe,
106
- getSnapshot: source.getSnapshot,
107
- destroy: noopDestroy,
108
- };
109
- perRouter.set(key, cached);
110
- }
111
-
112
- return cached;
113
- }
114
-
115
- /**
116
- * Combines route-name match with optional hash match (#532).
117
- *
118
- * - Route-name match: `router.isActiveRoute(name, params, strict, ignoreQueryParams)`.
119
- * - Hash match (only when `hash !== undefined`): `state.context.url.hash` must
120
- * equal the requested fragment exactly. With hash-plugin (no `url`
121
- * namespace), this returns `false` — the documented limitation.
122
- */
123
- function computeActive(
124
- router: Router,
125
- routeName: string,
126
- params: Params | undefined,
127
- strict: boolean,
128
- ignoreQueryParams: boolean,
129
- hash: string | undefined,
130
- ): boolean {
131
- const routeActive = router.isActiveRoute(
132
- routeName,
133
- params,
134
- strict,
135
- ignoreQueryParams,
136
- );
137
-
138
- if (!routeActive) {
139
- return false;
140
- }
141
- if (hash === undefined) {
142
- return true;
143
- }
144
-
145
- // `readContextHash` returns `undefined` when no URL plugin claimed the
146
- // namespace (hash-plugin runtime, memory-plugin, SSR). For hash-equality
147
- // matching we collapse that to `""` — a hash-aware Link with no URL plugin
148
- // can only match when the consumer also asked for `hash: ""`.
149
- return (readContextHash(router.getState()) ?? "") === hash;
150
- }
151
-
152
- function buildActiveRouteSource(
153
- router: Router,
154
- routeName: string,
155
- params: Params | undefined,
156
- strict: boolean,
157
- ignoreQueryParams: boolean,
158
- hash: string | undefined,
159
- ): RouterSource<boolean> {
160
- const initialValue = computeActive(
161
- router,
162
- routeName,
163
- params,
164
- strict,
165
- ignoreQueryParams,
166
- hash,
167
- );
168
-
169
- let routerUnsubscribe: (() => void) | undefined;
170
-
171
- const source = new BaseSource(initialValue, {
172
- onDestroy: () => {
173
- routerUnsubscribe?.();
174
- routerUnsubscribe = undefined;
175
- },
176
- });
177
-
178
- // Eager connection: subscribe to router immediately. For the cached path,
179
- // the returned wrapper has a no-op destroy and the handle lives with the
180
- // router (released on router GC). For the non-cached fallback (BigInt /
181
- // circular params), the handle is unwound through `onDestroy` above.
182
- routerUnsubscribe = router.subscribe((next) => {
183
- const isNewRelated = areRoutesRelated(routeName, next.route.name);
184
- const isPrevRelated =
185
- next.previousRoute &&
186
- areRoutesRelated(routeName, next.previousRoute.name);
187
-
188
- // Hash-aware sources also flip on same-path-different-hash transitions.
189
- // The route comparison alone misses these (route is identical), but the
190
- // hash claim updated, so we must re-evaluate. Detect via the `hashChanged`
191
- // flag published by URL plugins.
192
- const hashFlip =
193
- hash !== undefined &&
194
- ((next.route.context as { url?: { hashChanged?: boolean } } | undefined)
195
- ?.url?.hashChanged ??
196
- false);
197
-
198
- if (!isNewRelated && !isPrevRelated && !hashFlip) {
199
- return;
200
- }
201
-
202
- // If new route is not related, we know the route is inactive —
203
- // avoid calling isActiveRoute for the optimization. (Hash check would
204
- // also fail without route-match, so this short-circuit holds for
205
- // hash-aware sources too.)
206
- const newValue = isNewRelated
207
- ? computeActive(
208
- router,
209
- routeName,
210
- params,
211
- strict,
212
- ignoreQueryParams,
213
- hash,
214
- )
215
- : false;
216
-
217
- if (!Object.is(source.getSnapshot(), newValue)) {
218
- source.updateSnapshot(newValue);
219
- }
220
- });
221
-
222
- return source;
223
- }