@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.
Files changed (35) hide show
  1. package/package.json +6 -7
  2. package/src/components/NavigationAnnouncer.ts +0 -18
  3. package/src/components/RouteView.ts +0 -141
  4. package/src/components/RouterErrorBoundary.ts +0 -72
  5. package/src/directives/RealLink.ts +0 -144
  6. package/src/directives/RealLinkActive.ts +0 -77
  7. package/src/directives/RouteMatch.ts +0 -7
  8. package/src/directives/RouteNotFound.ts +0 -6
  9. package/src/directives/RouteSelf.ts +0 -6
  10. package/src/dom-utils/direction-tracker.ts +0 -70
  11. package/src/dom-utils/index.ts +0 -31
  12. package/src/dom-utils/link-utils.ts +0 -339
  13. package/src/dom-utils/route-announcer.ts +0 -215
  14. package/src/dom-utils/scroll-restore.ts +0 -511
  15. package/src/dom-utils/scroll-spy.ts +0 -688
  16. package/src/dom-utils/view-transitions.ts +0 -142
  17. package/src/functions/index.ts +0 -29
  18. package/src/functions/injectIsActiveRoute.ts +0 -31
  19. package/src/functions/injectNavigator.ts +0 -12
  20. package/src/functions/injectOrThrow.ts +0 -19
  21. package/src/functions/injectRoute.ts +0 -39
  22. package/src/functions/injectRouteEnter.ts +0 -117
  23. package/src/functions/injectRouteExit.ts +0 -118
  24. package/src/functions/injectRouteNode.ts +0 -19
  25. package/src/functions/injectRouteUtils.ts +0 -15
  26. package/src/functions/injectRouter.ts +0 -12
  27. package/src/functions/injectRouterTransition.ts +0 -17
  28. package/src/index.ts +0 -63
  29. package/src/internal/buildActiveRouteOptions.ts +0 -20
  30. package/src/internal/install.ts +0 -90
  31. package/src/internal/subscribeSourceToSignal.ts +0 -48
  32. package/src/providers.ts +0 -80
  33. package/src/providersFactory.ts +0 -316
  34. package/src/sourceToSignal.ts +0 -28
  35. 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
- }