@real-router/sources 0.8.5 → 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.
- package/package.json +3 -4
- package/src/BaseSource.ts +0 -95
- package/src/canonicalJson.ts +0 -110
- package/src/computeSnapshot.ts +0 -44
- package/src/createActiveNameSelector.ts +0 -182
- package/src/createActiveRouteSource.ts +0 -223
- package/src/createDismissableError.ts +0 -108
- package/src/createErrorSource.ts +0 -111
- package/src/createRouteNodeSource.ts +0 -130
- package/src/createRouteSource.ts +0 -56
- package/src/createTransitionSource.ts +0 -192
- package/src/index.ts +0 -35
- package/src/internal/noopDestroy.ts +0 -19
- package/src/internal/readContextHash.ts +0 -32
- package/src/normalizeActiveOptions.ts +0 -41
- package/src/stabilizeState.ts +0 -75
- package/src/types.ts +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/sources",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
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.
|
|
45
|
+
"@real-router/core": "^0.57.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
|
-
}
|
package/src/canonicalJson.ts
DELETED
|
@@ -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
|
-
}
|
package/src/computeSnapshot.ts
DELETED
|
@@ -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
|
-
}
|