@real-router/sources 0.8.1 → 0.8.3
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/README.md +38 -18
- package/dist/cjs/index.d.ts +32 -12
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.mts +32 -12
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/BaseSource.ts +15 -3
- package/src/canonicalJson.ts +87 -13
- package/src/createActiveNameSelector.ts +24 -12
- package/src/createActiveRouteSource.ts +44 -40
- package/src/createDismissableError.ts +31 -18
- package/src/createErrorSource.ts +7 -7
- package/src/createRouteNodeSource.ts +13 -6
- package/src/createTransitionSource.ts +102 -31
- package/src/internal/noopDestroy.ts +19 -0
- package/src/internal/readContextHash.ts +32 -0
- package/src/stabilizeState.ts +37 -13
|
@@ -2,6 +2,8 @@ import { areRoutesRelated } from "@real-router/route-utils";
|
|
|
2
2
|
|
|
3
3
|
import { BaseSource } from "./BaseSource";
|
|
4
4
|
import { canonicalJson } from "./canonicalJson.js";
|
|
5
|
+
import { noopDestroy } from "./internal/noopDestroy.js";
|
|
6
|
+
import { readContextHash } from "./internal/readContextHash.js";
|
|
5
7
|
import { normalizeActiveOptions } from "./normalizeActiveOptions.js";
|
|
6
8
|
|
|
7
9
|
import type { ActiveRouteSourceOptions, RouterSource } from "./types.js";
|
|
@@ -20,14 +22,12 @@ const activeSourceCache = new WeakMap<
|
|
|
20
22
|
* (`{ a:1, b:2 }` and `{ b:2, a:1 }` hit the same cache entry via
|
|
21
23
|
* `canonicalJson`).
|
|
22
24
|
*
|
|
23
|
-
* `destroy()` is a no-op — shared sources live with the
|
|
24
|
-
*
|
|
25
|
-
* is garbage-collected, the WeakMap entry releases automatically.
|
|
25
|
+
* For cached entries `destroy()` is a no-op — shared sources live with the
|
|
26
|
+
* router and release automatically on router GC (WeakMap entry).
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* concern in real usage.
|
|
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
31
|
*/
|
|
32
32
|
export function createActiveRouteSource(
|
|
33
33
|
router: Router,
|
|
@@ -49,13 +49,30 @@ export function createActiveRouteSource(
|
|
|
49
49
|
// if unusual, fragment).
|
|
50
50
|
const hashKey = hash === undefined ? "" : `#${hash}`;
|
|
51
51
|
|
|
52
|
-
|
|
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}`;
|
|
53
67
|
} catch {
|
|
54
68
|
key = undefined;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
if (key === undefined) {
|
|
58
|
-
|
|
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(
|
|
59
76
|
router,
|
|
60
77
|
routeName,
|
|
61
78
|
params,
|
|
@@ -63,12 +80,6 @@ export function createActiveRouteSource(
|
|
|
63
80
|
ignoreQueryParams,
|
|
64
81
|
hash,
|
|
65
82
|
);
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
subscribe: source.subscribe,
|
|
69
|
-
getSnapshot: source.getSnapshot,
|
|
70
|
-
destroy: noopDestroy,
|
|
71
|
-
};
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
let perRouter = activeSourceCache.get(router);
|
|
@@ -101,20 +112,6 @@ export function createActiveRouteSource(
|
|
|
101
112
|
return cached;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
/**
|
|
105
|
-
* Reads the URL fragment published by browser/navigation plugins on the given
|
|
106
|
-
* router state. Returns `""` when no plugin claims the `"url"` namespace
|
|
107
|
-
* (hash-plugin runtime, memory-plugin, SSR) — `undefined` is reserved for
|
|
108
|
-
* "no published fragment yet" and not visible at the source layer.
|
|
109
|
-
*/
|
|
110
|
-
function readContextHash(router: Router): string {
|
|
111
|
-
const ctx = router.getState()?.context as
|
|
112
|
-
| { url?: { hash?: string } }
|
|
113
|
-
| undefined;
|
|
114
|
-
|
|
115
|
-
return ctx?.url?.hash ?? "";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
115
|
/**
|
|
119
116
|
* Combines route-name match with optional hash match (#532).
|
|
120
117
|
*
|
|
@@ -145,7 +142,11 @@ function computeActive(
|
|
|
145
142
|
return true;
|
|
146
143
|
}
|
|
147
144
|
|
|
148
|
-
|
|
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;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
function buildActiveRouteSource(
|
|
@@ -165,13 +166,20 @@ function buildActiveRouteSource(
|
|
|
165
166
|
hash,
|
|
166
167
|
);
|
|
167
168
|
|
|
168
|
-
|
|
169
|
+
let routerUnsubscribe: (() => void) | undefined;
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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) => {
|
|
175
183
|
const isNewRelated = areRoutesRelated(routeName, next.route.name);
|
|
176
184
|
const isPrevRelated =
|
|
177
185
|
next.previousRoute &&
|
|
@@ -213,7 +221,3 @@ function buildActiveRouteSource(
|
|
|
213
221
|
|
|
214
222
|
return source;
|
|
215
223
|
}
|
|
216
|
-
|
|
217
|
-
function noopDestroy(): void {
|
|
218
|
-
// Shared cached source — external destroy() is a no-op.
|
|
219
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseSource } from "./BaseSource";
|
|
2
2
|
import { getErrorSource } from "./createErrorSource";
|
|
3
|
+
import { noopDestroy } from "./internal/noopDestroy.js";
|
|
3
4
|
|
|
4
5
|
import type { DismissableErrorSnapshot, RouterSource } from "./types.js";
|
|
5
6
|
import type { Router } from "@real-router/core";
|
|
@@ -40,8 +41,12 @@ export function createDismissableError(
|
|
|
40
41
|
const errorSource = getErrorSource(router);
|
|
41
42
|
|
|
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;
|
|
43
48
|
|
|
44
|
-
const
|
|
49
|
+
const buildDismissableSnapshot = (): DismissableErrorSnapshot => {
|
|
45
50
|
const snap = errorSource.getSnapshot();
|
|
46
51
|
const isDismissed = snap.version <= dismissedVersion;
|
|
47
52
|
|
|
@@ -54,22 +59,34 @@ export function createDismissableError(
|
|
|
54
59
|
};
|
|
55
60
|
};
|
|
56
61
|
|
|
57
|
-
const source = new BaseSource<DismissableErrorSnapshot>(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
},
|
|
65
73
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
let unsubFromError: (() => void) | null = null;
|
|
74
|
+
);
|
|
69
75
|
|
|
70
76
|
function resetError(): void {
|
|
71
|
-
|
|
72
|
-
|
|
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());
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
function disconnect(): void {
|
|
@@ -89,7 +106,3 @@ export function createDismissableError(
|
|
|
89
106
|
|
|
90
107
|
return wrapper;
|
|
91
108
|
}
|
|
92
|
-
|
|
93
|
-
function noopDestroy(): void {
|
|
94
|
-
// Shared cached source — external destroy() is a no-op.
|
|
95
|
-
}
|
package/src/createErrorSource.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { events } from "@real-router/core";
|
|
|
2
2
|
import { getPluginApi } from "@real-router/core/api";
|
|
3
3
|
|
|
4
4
|
import { BaseSource } from "./BaseSource";
|
|
5
|
+
import { noopDestroy } from "./internal/noopDestroy.js";
|
|
5
6
|
|
|
6
7
|
import type { RouterErrorSnapshot, RouterSource } from "./types.js";
|
|
7
8
|
import type { Router, State, RouterError } from "@real-router/core";
|
|
@@ -22,12 +23,13 @@ export function createErrorSource(
|
|
|
22
23
|
router: Router,
|
|
23
24
|
): RouterSource<RouterErrorSnapshot> {
|
|
24
25
|
let errorVersion = 0;
|
|
26
|
+
let hasError = false;
|
|
25
27
|
|
|
26
28
|
const source = new BaseSource(INITIAL_SNAPSHOT, {
|
|
27
29
|
onDestroy: () => {
|
|
28
|
-
|
|
30
|
+
for (const unsub of unsubs) {
|
|
29
31
|
unsub();
|
|
30
|
-
}
|
|
32
|
+
}
|
|
31
33
|
},
|
|
32
34
|
});
|
|
33
35
|
|
|
@@ -43,6 +45,7 @@ export function createErrorSource(
|
|
|
43
45
|
err: RouterError,
|
|
44
46
|
) => {
|
|
45
47
|
errorVersion++;
|
|
48
|
+
hasError = true;
|
|
46
49
|
source.updateSnapshot({
|
|
47
50
|
error: err,
|
|
48
51
|
toRoute: toState ?? null,
|
|
@@ -56,7 +59,8 @@ export function createErrorSource(
|
|
|
56
59
|
// Skip if no error — avoids unnecessary re-renders.
|
|
57
60
|
// BaseSource.updateSnapshot() always notifies listeners (new object = new ref),
|
|
58
61
|
// and useSyncExternalStore compares via Object.is().
|
|
59
|
-
if (
|
|
62
|
+
if (hasError) {
|
|
63
|
+
hasError = false;
|
|
60
64
|
source.updateSnapshot({
|
|
61
65
|
error: null,
|
|
62
66
|
toRoute: null,
|
|
@@ -105,7 +109,3 @@ export function getErrorSource(
|
|
|
105
109
|
|
|
106
110
|
return cached;
|
|
107
111
|
}
|
|
108
|
-
|
|
109
|
-
function noopDestroy(): void {
|
|
110
|
-
// Shared cached source — external destroy() is a no-op.
|
|
111
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseSource } from "./BaseSource";
|
|
2
2
|
import { computeSnapshot } from "./computeSnapshot.js";
|
|
3
|
+
import { noopDestroy } from "./internal/noopDestroy.js";
|
|
3
4
|
|
|
4
5
|
import type { RouteNodeSnapshot, RouterSource } from "./types.js";
|
|
5
6
|
import type { Router } from "@real-router/core";
|
|
@@ -106,9 +107,19 @@ function buildRouteNodeSource(
|
|
|
106
107
|
next,
|
|
107
108
|
);
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
|
111
120
|
}
|
|
121
|
+
|
|
122
|
+
source.updateSnapshot(newSnapshot);
|
|
112
123
|
});
|
|
113
124
|
},
|
|
114
125
|
onLastUnsubscribe: disconnect,
|
|
@@ -117,7 +128,3 @@ function buildRouteNodeSource(
|
|
|
117
128
|
|
|
118
129
|
return source;
|
|
119
130
|
}
|
|
120
|
-
|
|
121
|
-
function noopDestroy(): void {
|
|
122
|
-
// Shared cached source — external destroy() is a no-op.
|
|
123
|
-
}
|
|
@@ -2,31 +2,97 @@ import { events } from "@real-router/core";
|
|
|
2
2
|
import { getPluginApi } from "@real-router/core/api";
|
|
3
3
|
|
|
4
4
|
import { BaseSource } from "./BaseSource";
|
|
5
|
+
import { noopDestroy } from "./internal/noopDestroy.js";
|
|
5
6
|
import { stabilizeState } from "./stabilizeState.js";
|
|
6
7
|
|
|
7
8
|
import type { RouterTransitionSnapshot, RouterSource } from "./types.js";
|
|
8
9
|
import type { Router, State } from "@real-router/core";
|
|
9
10
|
|
|
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({
|
|
11
17
|
isTransitioning: false,
|
|
12
18
|
isLeaveApproved: false,
|
|
13
19
|
toRoute: null,
|
|
14
20
|
fromRoute: null,
|
|
15
|
-
};
|
|
21
|
+
});
|
|
16
22
|
|
|
17
23
|
const transitionSourceCache = new WeakMap<
|
|
18
24
|
Router,
|
|
19
25
|
RouterSource<RouterTransitionSnapshot>
|
|
20
26
|
>();
|
|
21
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
|
+
|
|
22
88
|
export function createTransitionSource(
|
|
23
89
|
router: Router,
|
|
24
90
|
): RouterSource<RouterTransitionSnapshot> {
|
|
25
91
|
const source = new BaseSource(IDLE_SNAPSHOT, {
|
|
26
92
|
onDestroy: () => {
|
|
27
|
-
|
|
93
|
+
for (const unsub of unsubs) {
|
|
28
94
|
unsub();
|
|
29
|
-
}
|
|
95
|
+
}
|
|
30
96
|
},
|
|
31
97
|
});
|
|
32
98
|
|
|
@@ -41,35 +107,44 @@ export function createTransitionSource(
|
|
|
41
107
|
api.addEventListener(
|
|
42
108
|
events.TRANSITION_START,
|
|
43
109
|
(toState: State, fromState?: State) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
59
126
|
}
|
|
127
|
+
|
|
128
|
+
source.updateSnapshot(next);
|
|
60
129
|
},
|
|
61
130
|
),
|
|
62
131
|
api.addEventListener(
|
|
63
132
|
events.TRANSITION_LEAVE_APPROVE,
|
|
64
133
|
(toState: State, fromState?: State) => {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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);
|
|
73
148
|
},
|
|
74
149
|
),
|
|
75
150
|
api.addEventListener(events.TRANSITION_SUCCESS, resetToIdle),
|
|
@@ -115,7 +190,3 @@ export function getTransitionSource(
|
|
|
115
190
|
|
|
116
191
|
return cached;
|
|
117
192
|
}
|
|
118
|
-
|
|
119
|
-
function noopDestroy(): void {
|
|
120
|
-
// Shared cached source — external destroy() is a no-op.
|
|
121
|
-
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
package/src/stabilizeState.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import { readContextHash } from "./internal/readContextHash.js";
|
|
2
|
+
|
|
1
3
|
import type { State } from "@real-router/core";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* State-aware stabilization for route snapshots.
|
|
5
7
|
*
|
|
6
|
-
* Compares `path` (canonical name+params)
|
|
7
|
-
* (URL fragment, #532)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* subscribing through `useRoute()` see the
|
|
8
|
+
* Compares `path` (canonical name+params), `state.context.url.hash`
|
|
9
|
+
* (URL fragment, #532), and `state.transition.reload` (#605). When all
|
|
10
|
+
* three match (idempotent navigation), returns `prev` (preserving
|
|
11
|
+
* reference) so frameworks can skip re-renders. When any of them flips,
|
|
12
|
+
* returns `next` so consumers subscribing through `useRoute()` see the
|
|
13
|
+
* new state.
|
|
14
|
+
*
|
|
15
|
+
* `transition.reload === true` is the user's explicit signal for a
|
|
16
|
+
* non-idempotent navigation — `router.navigate(name, params, { reload:
|
|
17
|
+
* true })` is the canonical pairing for `invalidate(router, namespace)`
|
|
18
|
+
* and any cache-bust pattern. Bypassing stabilization for reloads makes
|
|
19
|
+
* `useRoute()` consumers see fresh `state.context.<namespace>` values
|
|
20
|
+
* written by the SSR loader plugin's `subscribeLeave` handler.
|
|
11
21
|
*
|
|
12
|
-
* Ignores `meta` (internal: auto-increment id), `transition`
|
|
13
|
-
*
|
|
22
|
+
* Ignores `meta` (internal: auto-increment id), other `transition` fields
|
|
23
|
+
* (`from`, `segments`, `redirected`), and `state.context.navigation` /
|
|
14
24
|
* `state.context.browser` (transient transition metadata) — they don't
|
|
15
|
-
* affect
|
|
16
|
-
* field that participates in render identity, because tab-style UIs
|
|
17
|
-
* subscribe to it directly.
|
|
25
|
+
* affect render identity for idempotent navigations.
|
|
18
26
|
*
|
|
19
27
|
* Accepts `null` for compatibility with `RouterTransitionSnapshot`
|
|
20
28
|
* (toRoute/fromRoute are `State | null`).
|
|
@@ -41,11 +49,27 @@ export function stabilizeState<T extends State | null | undefined>(
|
|
|
41
49
|
return next;
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
// Explicit reload navigation (#605) — caller asked to bypass dedupe so
|
|
53
|
+
// observers see fresh `state.context` written by `invalidate()`-driven
|
|
54
|
+
// loader re-runs. The path equality above guarantees both prev and next
|
|
55
|
+
// are either non-null with matching paths or both nullish; only the
|
|
56
|
+
// non-null branch can carry a meaningful `transition.reload`.
|
|
57
|
+
if (readReloadFlag(next)) {
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
return prev;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
|
-
function
|
|
48
|
-
|
|
64
|
+
function readReloadFlag(state: State | null | undefined): boolean {
|
|
65
|
+
// Defensive read: `transition` is mandatory in the public State type, but a
|
|
66
|
+
// plugin returning a malformed state (or a future fork) shouldn't crash the
|
|
67
|
+
// stabilizer with a TypeError. We cast to a structurally-loose shape so the
|
|
68
|
+
// optional chain is permitted; the runtime guard preserves dedup (false =
|
|
69
|
+
// not-a-reload) for malformed inputs.
|
|
70
|
+
const transition = (
|
|
71
|
+
state as { transition?: { reload?: boolean } } | null | undefined
|
|
72
|
+
)?.transition;
|
|
49
73
|
|
|
50
|
-
return
|
|
74
|
+
return transition?.reload === true;
|
|
51
75
|
}
|