@real-router/angular 0.11.0 → 0.11.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.
- package/package.json +6 -7
- package/src/components/NavigationAnnouncer.ts +0 -18
- package/src/components/RouteView.ts +0 -141
- package/src/components/RouterErrorBoundary.ts +0 -72
- package/src/directives/RealLink.ts +0 -144
- package/src/directives/RealLinkActive.ts +0 -77
- package/src/directives/RouteMatch.ts +0 -7
- package/src/directives/RouteNotFound.ts +0 -6
- package/src/directives/RouteSelf.ts +0 -6
- package/src/dom-utils/direction-tracker.ts +0 -70
- package/src/dom-utils/index.ts +0 -31
- package/src/dom-utils/link-utils.ts +0 -339
- package/src/dom-utils/route-announcer.ts +0 -215
- package/src/dom-utils/scroll-restore.ts +0 -511
- package/src/dom-utils/scroll-spy.ts +0 -688
- package/src/dom-utils/view-transitions.ts +0 -142
- package/src/functions/index.ts +0 -29
- package/src/functions/injectIsActiveRoute.ts +0 -31
- package/src/functions/injectNavigator.ts +0 -12
- package/src/functions/injectOrThrow.ts +0 -19
- package/src/functions/injectRoute.ts +0 -39
- package/src/functions/injectRouteEnter.ts +0 -117
- package/src/functions/injectRouteExit.ts +0 -118
- package/src/functions/injectRouteNode.ts +0 -19
- package/src/functions/injectRouteUtils.ts +0 -15
- package/src/functions/injectRouter.ts +0 -12
- package/src/functions/injectRouterTransition.ts +0 -17
- package/src/index.ts +0 -63
- package/src/internal/buildActiveRouteOptions.ts +0 -20
- package/src/internal/install.ts +0 -90
- package/src/internal/subscribeSourceToSignal.ts +0 -48
- package/src/providers.ts +0 -80
- package/src/providersFactory.ts +0 -316
- package/src/sourceToSignal.ts +0 -28
- package/src/types.ts +0 -13
|
@@ -1,511 +0,0 @@
|
|
|
1
|
-
import type { Router, State } from "@real-router/core";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_STORAGE_KEY = "real-router:scroll";
|
|
4
|
-
|
|
5
|
-
// Bounded retry budget for resolving a late-mounting scroll container on the
|
|
6
|
-
// restore path. A per-route container (e.g. an `overflow:auto` div rendered
|
|
7
|
-
// only on one route) can be committed to the DOM a few frames after the
|
|
8
|
-
// navigation settles — heavier routes paint later than the subscribe's rAF.
|
|
9
|
-
// ~10 frames (≈160ms at 60fps) comfortably covers a React commit of a large
|
|
10
|
-
// route without being perceptible. See the doc-block on `restorePos`.
|
|
11
|
-
const RESTORE_RETRY_FRAMES = 10;
|
|
12
|
-
|
|
13
|
-
const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
|
|
14
|
-
destroy: () => {
|
|
15
|
-
/* no-op */
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export type ScrollRestorationMode = "restore" | "top" | "native";
|
|
20
|
-
|
|
21
|
-
export interface ScrollRestorationOptions {
|
|
22
|
-
mode?: ScrollRestorationMode | undefined;
|
|
23
|
-
anchorScrolling?: boolean | undefined;
|
|
24
|
-
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
25
|
-
/**
|
|
26
|
-
* Scroll behavior passed to `scrollTo({ behavior })` and
|
|
27
|
-
* `scrollIntoView({ behavior })`.
|
|
28
|
-
*
|
|
29
|
-
* - `"auto"` (default) — browser-defined, usually instant.
|
|
30
|
-
* - `"instant"` — explicit instant jump (no animation).
|
|
31
|
-
* - `"smooth"` — animated transition. Note: smooth restore on back/traverse
|
|
32
|
-
* can feel disorienting if the user expects to land at the saved position
|
|
33
|
-
* immediately. Recommended for `mode: "top"` or anchor scroll only.
|
|
34
|
-
*
|
|
35
|
-
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
|
|
36
|
-
*/
|
|
37
|
-
behavior?: ScrollBehavior | undefined;
|
|
38
|
-
/**
|
|
39
|
-
* sessionStorage key used to persist saved scroll positions. Default:
|
|
40
|
-
* `"real-router:scroll"`. Override only when multiple independent
|
|
41
|
-
* `RouterProvider` instances share the same document and you need to
|
|
42
|
-
* isolate their scroll stores (e.g. micro-frontends, embedded widgets,
|
|
43
|
-
* or testing). For a single app with one provider the default is fine.
|
|
44
|
-
*/
|
|
45
|
-
storageKey?: string | undefined;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface NavigationContext {
|
|
49
|
-
direction?: "forward" | "back" | "unknown";
|
|
50
|
-
navigationType?: "push" | "replace" | "traverse" | "reload";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function createScrollRestoration(
|
|
54
|
-
router: Router,
|
|
55
|
-
options?: ScrollRestorationOptions,
|
|
56
|
-
): { destroy: () => void } {
|
|
57
|
-
if (typeof globalThis.window === "undefined") {
|
|
58
|
-
return NOOP_INSTANCE;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const mode = options?.mode ?? "restore";
|
|
62
|
-
|
|
63
|
-
// mode "native" = utility does nothing. Don't flip history.scrollRestoration,
|
|
64
|
-
// don't subscribe, don't register pagehide — `history.scrollRestoration`
|
|
65
|
-
// stays at the browser default ("auto") so the browser handles scroll
|
|
66
|
-
// restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
|
|
67
|
-
// === "manual"` — utility's "native" leaves the DOM property at "auto" so
|
|
68
|
-
// the browser is in charge.)
|
|
69
|
-
if (mode === "native") {
|
|
70
|
-
return NOOP_INSTANCE;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const anchorEnabled = options?.anchorScrolling ?? true;
|
|
74
|
-
const getContainer = options?.scrollContainer;
|
|
75
|
-
const behavior: ScrollBehavior = options?.behavior ?? "auto";
|
|
76
|
-
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
77
|
-
|
|
78
|
-
// Write-through in-memory cache: parse sessionStorage once per provider
|
|
79
|
-
// mount, then mutate in-memory. Avoids a JSON.parse + JSON.stringify pair
|
|
80
|
-
// on every subscribeLeave / pagehide event.
|
|
81
|
-
let store: Record<string, number> | undefined;
|
|
82
|
-
|
|
83
|
-
const loadStore = (): Record<string, number> => {
|
|
84
|
-
if (store !== undefined) {
|
|
85
|
-
return store;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const raw = sessionStorage.getItem(storageKey);
|
|
90
|
-
|
|
91
|
-
store = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
92
|
-
} catch {
|
|
93
|
-
store = {};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return store;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const putPos = (key: string, pos: number): void => {
|
|
100
|
-
try {
|
|
101
|
-
const cached = loadStore();
|
|
102
|
-
|
|
103
|
-
// Skip-same-value: when a route is left at the same scroll position it
|
|
104
|
-
// already holds in the cache (e.g. tab-switching without scrolling),
|
|
105
|
-
// both the in-memory write and the JSON.stringify + setItem pair are
|
|
106
|
-
// no-ops. Eliminates redundant serialization on the navigation hot
|
|
107
|
-
// path for the common "click tabs without scrolling" case.
|
|
108
|
-
if (cached[key] === pos) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
cached[key] = pos;
|
|
113
|
-
sessionStorage.setItem(storageKey, JSON.stringify(cached));
|
|
114
|
-
} catch {
|
|
115
|
-
// Ignore quota / security errors.
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const prevScrollRestoration = history.scrollRestoration;
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
history.scrollRestoration = "manual";
|
|
123
|
-
} catch {
|
|
124
|
-
// Ignore — some embedded contexts may reject the assignment.
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Resolve the container lazily on every event so containers mounted AFTER
|
|
128
|
-
// the provider still get correct scroll handling. Falls back to window when
|
|
129
|
-
// the getter is absent or returns null (pre-mount).
|
|
130
|
-
const readPos = (): number => {
|
|
131
|
-
const element = getContainer?.();
|
|
132
|
-
|
|
133
|
-
return element ? element.scrollTop : globalThis.scrollY;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const writePos = (top: number): void => {
|
|
137
|
-
const element = getContainer?.();
|
|
138
|
-
|
|
139
|
-
if (element) {
|
|
140
|
-
element.scrollTo({ top, left: 0, behavior });
|
|
141
|
-
} else {
|
|
142
|
-
globalThis.scrollTo({ top, left: 0, behavior });
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
// Restore path (back / traverse / reload). Unlike `writePos`, this tolerates a
|
|
147
|
-
// scroll container that both MOUNTS and LAYS OUT a few frames AFTER the
|
|
148
|
-
// navigation settles.
|
|
149
|
-
//
|
|
150
|
-
// The capture-side `readPos` always runs against an already-mounted DOM (the
|
|
151
|
-
// route being left). On restore the target route — and its container — is
|
|
152
|
-
// still being committed by the view layer. The subscribe callback schedules a
|
|
153
|
-
// single rAF; for a heavy route (e.g. a long virtual list) the framework's
|
|
154
|
-
// commit can land AFTER that frame. Two distinct failures follow, each losing
|
|
155
|
-
// the saved position (Scenario 6 e2e, reproduced under CI's slower runner):
|
|
156
|
-
//
|
|
157
|
-
// 1. Container not mounted yet → `getContainer()` is `null`, the scroll
|
|
158
|
-
// silently falls back to `window`, which on a container-only route has
|
|
159
|
-
// nothing to scroll.
|
|
160
|
-
// 2. Container mounted but its content not laid out yet → `scrollHeight`
|
|
161
|
-
// is still small, so a single `scrollTo({ top })` clamps short of the
|
|
162
|
-
// saved position and never re-applies once layout grows.
|
|
163
|
-
//
|
|
164
|
-
// With no `scrollContainer` getter the target is always `window`, present
|
|
165
|
-
// from the first frame — restore in a single shot (unchanged behaviour). When
|
|
166
|
-
// a getter is configured we cannot tell "this route legitimately uses window"
|
|
167
|
-
// from "the container is still mounting", so re-apply the scroll on every
|
|
168
|
-
// frame for a bounded budget: window as a fallback while the container is
|
|
169
|
-
// absent (harmless clamp on container routes), the container itself once it
|
|
170
|
-
// appears. For instant restores we stop early the moment the position sticks;
|
|
171
|
-
// smooth restores animate asynchronously, so they run the full budget. The
|
|
172
|
-
// frame budget is the hard backstop against an unreachable target (saved
|
|
173
|
-
// position taller than the restored content).
|
|
174
|
-
const restorePos = (top: number): void => {
|
|
175
|
-
if (!getContainer) {
|
|
176
|
-
globalThis.scrollTo({ top, left: 0, behavior });
|
|
177
|
-
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
let frames = 0;
|
|
182
|
-
|
|
183
|
-
const attempt = (): void => {
|
|
184
|
-
if (destroyed) {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const element = getContainer();
|
|
189
|
-
|
|
190
|
-
if (element) {
|
|
191
|
-
element.scrollTo({ top, left: 0, behavior });
|
|
192
|
-
|
|
193
|
-
// Instant restore landed within rounding tolerance → done; no point
|
|
194
|
-
// re-applying. Smooth restore never matches synchronously, so let it
|
|
195
|
-
// ride the budget.
|
|
196
|
-
if (behavior !== "smooth" && Math.abs(element.scrollTop - top) <= 1) {
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
} else {
|
|
200
|
-
globalThis.scrollTo({ top, left: 0, behavior });
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (frames >= RESTORE_RETRY_FRAMES) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
frames += 1;
|
|
208
|
-
requestAnimationFrame(attempt);
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
attempt();
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const scrollToHashOrTop = (route: State): void => {
|
|
215
|
-
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
216
|
-
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
217
|
-
// installed. The value is already DECODED — feeding it through
|
|
218
|
-
// `decodeURIComponent` again would throw on a bare `%`.
|
|
219
|
-
const ctxHash = (route.context as { url?: { hash?: string } } | undefined)
|
|
220
|
-
?.url?.hash;
|
|
221
|
-
|
|
222
|
-
if (ctxHash !== undefined) {
|
|
223
|
-
if (anchorEnabled && ctxHash.length > 0) {
|
|
224
|
-
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
225
|
-
const element = document.getElementById(ctxHash);
|
|
226
|
-
|
|
227
|
-
if (element) {
|
|
228
|
-
element.scrollIntoView({ behavior });
|
|
229
|
-
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
writePos(0);
|
|
235
|
-
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
240
|
-
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
241
|
-
// match. Fall back to the raw slice if the hash contains a malformed
|
|
242
|
-
// escape sequence (decodeURIComponent throws on those).
|
|
243
|
-
const hash = globalThis.location.hash;
|
|
244
|
-
|
|
245
|
-
if (anchorEnabled && hash.length > 1) {
|
|
246
|
-
let id: string;
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
id = decodeURIComponent(hash.slice(1));
|
|
250
|
-
} catch {
|
|
251
|
-
id = hash.slice(1);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
255
|
-
const element = document.getElementById(id);
|
|
256
|
-
|
|
257
|
-
if (element) {
|
|
258
|
-
element.scrollIntoView({ behavior });
|
|
259
|
-
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
writePos(0);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
let destroyed = false;
|
|
268
|
-
let unserializableWarned = false;
|
|
269
|
-
|
|
270
|
-
// `keyOf` defers to `canonicalJson` which calls `JSON.stringify`. Two
|
|
271
|
-
// realistic inputs blow up the serializer and would otherwise crash the
|
|
272
|
-
// subscribe callback (taking scroll-restore offline for the whole session):
|
|
273
|
-
// - `BigInt` params → `TypeError: Do not know how to serialize a BigInt`
|
|
274
|
-
// - cyclic params (reactive proxies, DOM-ref back-pointers) → stack
|
|
275
|
-
// overflow.
|
|
276
|
-
// The defensive wrapper drops capture/restore for that specific navigation
|
|
277
|
-
// and warns once per provider — the rest of the cache stays usable.
|
|
278
|
-
const safeKeyOf = (state: State): string | null => {
|
|
279
|
-
try {
|
|
280
|
-
return keyOf(state);
|
|
281
|
-
} catch {
|
|
282
|
-
if (!unserializableWarned) {
|
|
283
|
-
unserializableWarned = true;
|
|
284
|
-
console.error(
|
|
285
|
-
`[real-router] scroll-restore: route "${state.name}" has params that cannot be canonicalized (e.g. BigInt or cyclic structure). Scroll position will not be captured or restored for this route.`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const unsubscribe = router.subscribe(({ route, previousRoute }) => {
|
|
294
|
-
const nav = (route.context as { navigation?: NavigationContext })
|
|
295
|
-
.navigation;
|
|
296
|
-
|
|
297
|
-
// Browsers dispatch reload as the initial navigation after refresh, so
|
|
298
|
-
// previousRoute is undefined and capture is naturally skipped. The
|
|
299
|
-
// pre-refresh position was already persisted via pagehide.
|
|
300
|
-
if (previousRoute) {
|
|
301
|
-
const prevKey = safeKeyOf(previousRoute);
|
|
302
|
-
|
|
303
|
-
if (prevKey !== null) {
|
|
304
|
-
putPos(prevKey, readPos());
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
requestAnimationFrame(() => {
|
|
309
|
-
if (destroyed) {
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (mode === "top") {
|
|
314
|
-
scrollToHashOrTop(route);
|
|
315
|
-
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Restore branches (reload, back/traverse) MUST be evaluated before the
|
|
320
|
-
// replace-skip below. Since #657 lifted `replace` into TransitionMeta, a
|
|
321
|
-
// history TRAVERSAL (back/forward) under navigation-plugin carries
|
|
322
|
-
// `transition.replace === true` — a traversal reuses an existing history
|
|
323
|
-
// entry, which is replace-shaped at the history level. If the replace-skip
|
|
324
|
-
// ran first it would swallow every back/forward navigation and restore
|
|
325
|
-
// would never fire (the Scenario 6 e2e regression). Genuine in-place
|
|
326
|
-
// replaces (`router.navigate({ replace: true })`, navigateToNotFound) are
|
|
327
|
-
// not traversals and fall through to the skip below.
|
|
328
|
-
//
|
|
329
|
-
// Both arms of each check are required: `transition.reload` only fires for
|
|
330
|
-
// programmatic `router.navigate({reload:true})`. F5 under navigation-plugin
|
|
331
|
-
// primes `nav.navigationType === "reload"` via #531 getActivationType but
|
|
332
|
-
// leaves opts.reload undefined, so dropping the plugin arm would regress F5
|
|
333
|
-
// scroll-restore. Browser-plugin's F5 is not covered (no priming, out of
|
|
334
|
-
// scope).
|
|
335
|
-
if (route.transition.reload || nav?.navigationType === "reload") {
|
|
336
|
-
const key = safeKeyOf(route);
|
|
337
|
-
|
|
338
|
-
restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
|
|
339
|
-
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (nav?.direction === "back" || nav?.navigationType === "traverse") {
|
|
344
|
-
const key = safeKeyOf(route);
|
|
345
|
-
|
|
346
|
-
restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
|
|
347
|
-
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Genuine in-place replace (not a traversal) — leave scroll untouched.
|
|
352
|
-
if (route.transition.replace || nav?.navigationType === "replace") {
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
scrollToHashOrTop(route);
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
const onPageHide = (): void => {
|
|
361
|
-
const current = router.getState();
|
|
362
|
-
|
|
363
|
-
if (current) {
|
|
364
|
-
const key = safeKeyOf(current);
|
|
365
|
-
|
|
366
|
-
if (key !== null) {
|
|
367
|
-
putPos(key, readPos());
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
globalThis.addEventListener("pagehide", onPageHide);
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
destroy: () => {
|
|
376
|
-
if (destroyed) {
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
destroyed = true;
|
|
381
|
-
unsubscribe();
|
|
382
|
-
globalThis.removeEventListener("pagehide", onPageHide);
|
|
383
|
-
|
|
384
|
-
try {
|
|
385
|
-
history.scrollRestoration = prevScrollRestoration;
|
|
386
|
-
} catch {
|
|
387
|
-
// Ignore.
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Internal cache-key builder for scroll-position storage.
|
|
395
|
-
*
|
|
396
|
-
* **Exported for testing only — not part of the public API** (intentionally
|
|
397
|
-
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
398
|
-
* the direct path to lock the `(name, canonicalJson(params))` key shape
|
|
399
|
-
* as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
|
|
400
|
-
* key format would silently lose scroll positions across an upgrade —
|
|
401
|
-
* the test set is the contract.
|
|
402
|
-
*
|
|
403
|
-
* ## Identity-based memoization (audit-2026-05-17 §8b #2)
|
|
404
|
-
*
|
|
405
|
-
* `State` objects emitted by core are frozen per-navigation: their
|
|
406
|
-
* `name` / `params` are immutable for the lifetime of the snapshot, and
|
|
407
|
-
* any change produces a new `State` reference. A `WeakMap<State, string>`
|
|
408
|
-
* therefore safely caches the canonicalised key by identity — repeat
|
|
409
|
-
* `keyOf(state)` calls on the same snapshot (typical on
|
|
410
|
-
* back/forward/traverse where the same prior `State` is re-emitted)
|
|
411
|
-
* skip the recursive `canonicalJson` pass entirely.
|
|
412
|
-
*
|
|
413
|
-
* The cache key is the `State` reference, so entries auto-release when
|
|
414
|
-
* the snapshot is GC'd — no eviction needed.
|
|
415
|
-
*/
|
|
416
|
-
const KEY_CACHE = new WeakMap<State, string>();
|
|
417
|
-
|
|
418
|
-
export function keyOf(state: State): string {
|
|
419
|
-
const cached = KEY_CACHE.get(state);
|
|
420
|
-
|
|
421
|
-
if (cached !== undefined) {
|
|
422
|
-
return cached;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const key = `${state.name}:${canonicalJson(state.params)}`;
|
|
426
|
-
|
|
427
|
-
KEY_CACHE.set(state, key);
|
|
428
|
-
|
|
429
|
-
return key;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Stable JSON serializer with sorted object keys.
|
|
434
|
-
*
|
|
435
|
-
* **Exported for testing only — not part of the public API** (intentionally
|
|
436
|
-
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
437
|
-
* the direct path to lock the key-order-insensitive property
|
|
438
|
-
* (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
|
|
439
|
-
*
|
|
440
|
-
* ## Divergence from `@real-router/sources/canonicalJson` — by design
|
|
441
|
-
*
|
|
442
|
-
* Two independent implementations live in the monorepo:
|
|
443
|
-
*
|
|
444
|
-
* - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
|
|
445
|
-
* cache key builder. Uses `localeCompare` and a plain-object accumulator;
|
|
446
|
-
* tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
|
|
447
|
-
* replacer happens to sort them; relies on `JSON.stringify`'s native cycle
|
|
448
|
-
* detector. Designed to be cheap on the navigation hot path. The
|
|
449
|
-
* surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
|
|
450
|
-
* cyclic) and skips the offending capture/restore.
|
|
451
|
-
*
|
|
452
|
-
* - **`@real-router/sources/canonicalJson`** — sources cache key builder.
|
|
453
|
-
* Uses byte-order compare (`< / >`) for locale-independence, a
|
|
454
|
-
* `Object.create(null)` accumulator to prevent prototype pollution, and a
|
|
455
|
-
* bespoke path-based cycle detector (the native one cannot see the cloned
|
|
456
|
-
* graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
|
|
457
|
-
* back to a non-cached source.
|
|
458
|
-
*
|
|
459
|
-
* **They are intentionally NOT interchangeable.** Aligning them would either
|
|
460
|
-
* regress scroll-restore performance (byte-order + recursive clone is heavier
|
|
461
|
-
* per call) or weaken the sources cache (locale dependence breaks
|
|
462
|
-
* deterministic cache keys across machines). No cross-package equivalence
|
|
463
|
-
* test exists or should be added; the relationship is "different invariants,
|
|
464
|
-
* different costs, different consumers." Audit-2 / audit-2026-05-17 §2
|
|
465
|
-
* documents the choice.
|
|
466
|
-
*/
|
|
467
|
-
export function canonicalJson(value: unknown): string {
|
|
468
|
-
return JSON.stringify(value, canonicalReplacer);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function canonicalReplacer(_key: string, val: unknown): unknown {
|
|
472
|
-
// audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
|
|
473
|
-
// `JSON.stringify` silently drops function and symbol values from
|
|
474
|
-
// object output. Two routes that differ ONLY in a function/Symbol
|
|
475
|
-
// value would canonicalize to the same string → silent scroll-cache
|
|
476
|
-
// key collision (positions clobber each other). Replacing the value
|
|
477
|
-
// with a sentinel string breaks the collision while keeping the
|
|
478
|
-
// canonical form deterministic. The sentinels are intentionally
|
|
479
|
-
// ASCII-only and lexically distinct from valid JSON-stringified
|
|
480
|
-
// values; consumers will see `"<fn>"` / `"<sym>"` if they ever
|
|
481
|
-
// round-trip the cache key, signalling the substitution clearly.
|
|
482
|
-
if (typeof val === "function") {
|
|
483
|
-
return "<fn>";
|
|
484
|
-
}
|
|
485
|
-
if (typeof val === "symbol") {
|
|
486
|
-
return "<sym>";
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
490
|
-
// Null-prototype accumulator: a plain `{}` would interpret
|
|
491
|
-
// `sorted["__proto__"] = x` as a prototype assignment (silently dropped
|
|
492
|
-
// from JSON.stringify output AND a prototype-pollution vector). Mirrors
|
|
493
|
-
// the same guard in `@real-router/sources/canonicalJson`. The two
|
|
494
|
-
// implementations are still intentionally divergent (see the doc-block
|
|
495
|
-
// on [[canonicalJson]] above), but prototype-safety is non-negotiable
|
|
496
|
-
// on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
|
|
497
|
-
const sorted = Object.create(null) as Record<string, unknown>;
|
|
498
|
-
// eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
|
|
499
|
-
const keys = Object.keys(val).sort((left: string, right: string) =>
|
|
500
|
-
left.localeCompare(right),
|
|
501
|
-
);
|
|
502
|
-
|
|
503
|
-
for (const key of keys) {
|
|
504
|
-
sorted[key] = (val as Record<string, unknown>)[key];
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return sorted;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return val;
|
|
511
|
-
}
|