@real-router/sources 0.8.0 → 0.8.2

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.
@@ -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 router. The router
24
- * subscription stays active while any consumer subscribes; when the router
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
- * Edge cases: `Symbol`/`BigInt` in params bypass `canonicalJson` and produce
28
- * an unstable cache key these will simply miss the cache and create a new
29
- * source on each call. Practical params are primitives, so this is not a
30
- * concern in real usage.
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
- key = `${routeName}|${canonicalJson(params)}|${String(strict)}|${String(ignoreQueryParams)}|${hashKey}`;
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
- const source = buildActiveRouteSource(
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
- return readContextHash(router) === hash;
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
- const source = new BaseSource(initialValue);
169
+ let routerUnsubscribe: (() => void) | undefined;
169
170
 
170
- // Eager connection: subscribe to router immediately. This source is only
171
- // ever reached through the cached public `createActiveRouteSource`, whose
172
- // returned wrapper has a no-op destroy. The source lives with the router;
173
- // the router.subscribe handle is released on router GC.
174
- router.subscribe((next) => {
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 computeSnapshot = (): DismissableErrorSnapshot => {
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>(computeSnapshot(), {
58
- onFirstSubscribe: () => {
59
- unsubFromError = errorSource.subscribe(() => {
60
- source.updateSnapshot(computeSnapshot());
61
- });
62
- },
63
- onLastUnsubscribe: () => {
64
- disconnect();
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
- dismissedVersion = errorSource.getSnapshot().version;
72
- source.updateSnapshot(computeSnapshot());
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
- }
@@ -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
- unsubs.forEach((unsub) => {
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 (source.getSnapshot().error !== null) {
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
- if (!Object.is(source.getSnapshot(), newSnapshot)) {
110
- source.updateSnapshot(newSnapshot);
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
- const IDLE_SNAPSHOT: RouterTransitionSnapshot = {
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
- unsubs.forEach((unsub) => {
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
- const prev = source.getSnapshot();
45
- const newToRoute = stabilizeState(prev.toRoute, toState);
46
- const newFromRoute = stabilizeState(prev.fromRoute, fromState ?? null);
47
-
48
- if (
49
- !prev.isTransitioning ||
50
- newToRoute !== prev.toRoute ||
51
- newFromRoute !== prev.fromRoute
52
- ) {
53
- source.updateSnapshot({
54
- isTransitioning: true,
55
- isLeaveApproved: false,
56
- toRoute: newToRoute,
57
- fromRoute: newFromRoute,
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 prev = source.getSnapshot();
66
-
67
- source.updateSnapshot({
68
- isTransitioning: true,
69
- isLeaveApproved: true,
70
- toRoute: stabilizeState(prev.toRoute, toState),
71
- fromRoute: stabilizeState(prev.fromRoute, fromState ?? null),
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
+ }
@@ -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) AND `state.context.url.hash`
7
- * (URL fragment, #532). When both match, returns `prev` (preserving
8
- * reference) so frameworks can skip re-renders. When hash flips on a
9
- * same-path navigation (tab-style UI), returns `next` so consumers
10
- * subscribing through `useRoute()` see the new state.
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` (reference
13
- * data: from, segments, reload), and `state.context.navigation` /
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 what is rendered. `state.context.url.hash` is the only context
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 readContextHash(state: State | null | undefined): string | undefined {
48
- const ctx = state?.context as { url?: { hash?: string } } | undefined;
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 ctx?.url?.hash;
74
+ return transition?.reload === true;
51
75
  }