@real-router/angular 0.10.0 → 0.11.1

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.
@@ -1,13 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, DestroyRef, ApplicationRef, signal, InjectionToken, provideEnvironmentInitializer, makeEnvironmentProviders, makeStateKey, REQUEST, provideAppInitializer, TransferState, assertInInjectionContext, effect, input, TemplateRef, Directive, contentChildren, computed, Component, output, ElementRef } from '@angular/core';
3
3
  import { getNavigator, UNKNOWN_ROUTE } from '@real-router/core';
4
- import { createRouteSource, createRouteNodeSource, getTransitionSource, createActiveRouteSource, createDismissableError } from '@real-router/sources';
4
+ import { getTransitionSource, createRouteSource, createRouteNodeSource, createActiveRouteSource, createDismissableError } from '@real-router/sources';
5
5
  import { cloneRouter, getPluginApi } from '@real-router/core/api';
6
6
  import { hydrateRouter, serializeRouterState } from '@real-router/core/utils';
7
7
  import { getRouteUtils, startsWithSegment } from '@real-router/route-utils';
8
8
  import { NgTemplateOutlet } from '@angular/common';
9
9
 
10
- const NOOP_INSTANCE$3 = Object.freeze({
10
+ const NOOP_INSTANCE$4 = Object.freeze({
11
11
  destroy: () => {
12
12
  /* no-op */
13
13
  },
@@ -35,7 +35,7 @@ const NOOP_INSTANCE$3 = Object.freeze({
35
35
  */
36
36
  function createDirectionTracker(router) {
37
37
  if (typeof document === "undefined") {
38
- return NOOP_INSTANCE$3;
38
+ return NOOP_INSTANCE$4;
39
39
  }
40
40
  let popstateFlag = false;
41
41
  document.documentElement.dataset.navDirection = "forward";
@@ -70,7 +70,7 @@ const SAFARI_READY_DELAY = 100;
70
70
  const ANNOUNCER_ATTR = "data-real-router-announcer";
71
71
  const INTERNAL_ROUTE_PREFIX = "@@";
72
72
  const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0";
73
- const NOOP_INSTANCE$2 = Object.freeze({
73
+ const NOOP_INSTANCE$3 = Object.freeze({
74
74
  destroy: () => {
75
75
  /* no-op */
76
76
  },
@@ -86,7 +86,7 @@ function createRouteAnnouncer(router, options) {
86
86
  // bootstrap. Closes review-2026-05-10 §5.10 ⛔ "NavigationAnnouncer
87
87
  // SSR mode" MED.
88
88
  if (typeof document === "undefined") {
89
- return NOOP_INSTANCE$2;
89
+ return NOOP_INSTANCE$3;
90
90
  }
91
91
  const prefix = options?.prefix ?? "Navigated to ";
92
92
  const getCustomText = options?.getAnnouncementText;
@@ -228,14 +228,21 @@ function manageFocus(h1) {
228
228
  }
229
229
 
230
230
  const DEFAULT_STORAGE_KEY = "real-router:scroll";
231
- const NOOP_INSTANCE$1 = Object.freeze({
231
+ // Bounded retry budget for resolving a late-mounting scroll container on the
232
+ // restore path. A per-route container (e.g. an `overflow:auto` div rendered
233
+ // only on one route) can be committed to the DOM a few frames after the
234
+ // navigation settles — heavier routes paint later than the subscribe's rAF.
235
+ // ~10 frames (≈160ms at 60fps) comfortably covers a React commit of a large
236
+ // route without being perceptible. See the doc-block on `restorePos`.
237
+ const RESTORE_RETRY_FRAMES = 10;
238
+ const NOOP_INSTANCE$2 = Object.freeze({
232
239
  destroy: () => {
233
240
  /* no-op */
234
241
  },
235
242
  });
236
243
  function createScrollRestoration(router, options) {
237
244
  if (typeof globalThis.window === "undefined") {
238
- return NOOP_INSTANCE$1;
245
+ return NOOP_INSTANCE$2;
239
246
  }
240
247
  const mode = options?.mode ?? "restore";
241
248
  // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
@@ -245,7 +252,7 @@ function createScrollRestoration(router, options) {
245
252
  // === "manual"` — utility's "native" leaves the DOM property at "auto" so
246
253
  // the browser is in charge.)
247
254
  if (mode === "native") {
248
- return NOOP_INSTANCE$1;
255
+ return NOOP_INSTANCE$2;
249
256
  }
250
257
  const anchorEnabled = options?.anchorScrolling ?? true;
251
258
  const getContainer = options?.scrollContainer;
@@ -309,6 +316,65 @@ function createScrollRestoration(router, options) {
309
316
  globalThis.scrollTo({ top, left: 0, behavior });
310
317
  }
311
318
  };
319
+ // Restore path (back / traverse / reload). Unlike `writePos`, this tolerates a
320
+ // scroll container that both MOUNTS and LAYS OUT a few frames AFTER the
321
+ // navigation settles.
322
+ //
323
+ // The capture-side `readPos` always runs against an already-mounted DOM (the
324
+ // route being left). On restore the target route — and its container — is
325
+ // still being committed by the view layer. The subscribe callback schedules a
326
+ // single rAF; for a heavy route (e.g. a long virtual list) the framework's
327
+ // commit can land AFTER that frame. Two distinct failures follow, each losing
328
+ // the saved position (Scenario 6 e2e, reproduced under CI's slower runner):
329
+ //
330
+ // 1. Container not mounted yet → `getContainer()` is `null`, the scroll
331
+ // silently falls back to `window`, which on a container-only route has
332
+ // nothing to scroll.
333
+ // 2. Container mounted but its content not laid out yet → `scrollHeight`
334
+ // is still small, so a single `scrollTo({ top })` clamps short of the
335
+ // saved position and never re-applies once layout grows.
336
+ //
337
+ // With no `scrollContainer` getter the target is always `window`, present
338
+ // from the first frame — restore in a single shot (unchanged behaviour). When
339
+ // a getter is configured we cannot tell "this route legitimately uses window"
340
+ // from "the container is still mounting", so re-apply the scroll on every
341
+ // frame for a bounded budget: window as a fallback while the container is
342
+ // absent (harmless clamp on container routes), the container itself once it
343
+ // appears. For instant restores we stop early the moment the position sticks;
344
+ // smooth restores animate asynchronously, so they run the full budget. The
345
+ // frame budget is the hard backstop against an unreachable target (saved
346
+ // position taller than the restored content).
347
+ const restorePos = (top) => {
348
+ if (!getContainer) {
349
+ globalThis.scrollTo({ top, left: 0, behavior });
350
+ return;
351
+ }
352
+ let frames = 0;
353
+ const attempt = () => {
354
+ if (destroyed) {
355
+ return;
356
+ }
357
+ const element = getContainer();
358
+ if (element) {
359
+ element.scrollTo({ top, left: 0, behavior });
360
+ // Instant restore landed within rounding tolerance → done; no point
361
+ // re-applying. Smooth restore never matches synchronously, so let it
362
+ // ride the budget.
363
+ if (behavior !== "smooth" && Math.abs(element.scrollTop - top) <= 1) {
364
+ return;
365
+ }
366
+ }
367
+ else {
368
+ globalThis.scrollTo({ top, left: 0, behavior });
369
+ }
370
+ if (frames >= RESTORE_RETRY_FRAMES) {
371
+ return;
372
+ }
373
+ frames += 1;
374
+ requestAnimationFrame(attempt);
375
+ };
376
+ attempt();
377
+ };
312
378
  const scrollToHashOrTop = (route) => {
313
379
  // URL plugin path (#532): `state.context.url.hash` is the source of truth
314
380
  // when one of the URL plugins (browser-plugin / navigation-plugin) is
@@ -392,23 +458,34 @@ function createScrollRestoration(router, options) {
392
458
  scrollToHashOrTop(route);
393
459
  return;
394
460
  }
395
- if (route.transition.replace || nav?.navigationType === "replace") {
396
- return;
397
- }
398
- // Both arms are required: `transition.reload` only fires for programmatic
399
- // `router.navigate({reload:true})`. F5 under navigation-plugin primes
400
- // `nav.navigationType === "reload"` via #531 getActivationType but leaves
401
- // opts.reload undefined, so dropping the plugin arm would regress F5
402
- // scroll-restore. Same belt-and-suspenders pattern is used for replace
403
- // above. Browser-plugin's F5 is not covered (no priming, out of scope).
461
+ // Restore branches (reload, back/traverse) MUST be evaluated before the
462
+ // replace-skip below. Since #657 lifted `replace` into TransitionMeta, a
463
+ // history TRAVERSAL (back/forward) under navigation-plugin carries
464
+ // `transition.replace === true` a traversal reuses an existing history
465
+ // entry, which is replace-shaped at the history level. If the replace-skip
466
+ // ran first it would swallow every back/forward navigation and restore
467
+ // would never fire (the Scenario 6 e2e regression). Genuine in-place
468
+ // replaces (`router.navigate({ replace: true })`, navigateToNotFound) are
469
+ // not traversals and fall through to the skip below.
470
+ //
471
+ // Both arms of each check are required: `transition.reload` only fires for
472
+ // programmatic `router.navigate({reload:true})`. F5 under navigation-plugin
473
+ // primes `nav.navigationType === "reload"` via #531 getActivationType but
474
+ // leaves opts.reload undefined, so dropping the plugin arm would regress F5
475
+ // scroll-restore. Browser-plugin's F5 is not covered (no priming, out of
476
+ // scope).
404
477
  if (route.transition.reload || nav?.navigationType === "reload") {
405
478
  const key = safeKeyOf(route);
406
- writePos(key === null ? 0 : (loadStore()[key] ?? 0));
479
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
407
480
  return;
408
481
  }
409
482
  if (nav?.direction === "back" || nav?.navigationType === "traverse") {
410
483
  const key = safeKeyOf(route);
411
- writePos(key === null ? 0 : (loadStore()[key] ?? 0));
484
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
485
+ return;
486
+ }
487
+ // Genuine in-place replace (not a traversal) — leave scroll untouched.
488
+ if (route.transition.replace || nav?.navigationType === "replace") {
412
489
  return;
413
490
  }
414
491
  scrollToHashOrTop(route);
@@ -548,6 +625,424 @@ function canonicalReplacer(_key, val) {
548
625
  return val;
549
626
  }
550
627
 
628
+ const NOOP_INSTANCE$1 = Object.freeze({
629
+ destroy: () => {
630
+ /* no-op */
631
+ },
632
+ });
633
+ // Hardcoded internals (RFC §5.1 — promote only with evidence).
634
+ const RAF_DEBOUNCE_MS = 150;
635
+ const MUTATION_DEBOUNCE_MS = 250;
636
+ const COOLDOWN_TIMEOUT_MS = 500;
637
+ const DEFAULT_ROOT_MARGIN = "-20% 0px -60% 0px";
638
+ const getUrlContext = (state) => state.context?.url;
639
+ // =============================================================================
640
+ // Picker — pure, no state. RFC §5.2 selection rule.
641
+ // =============================================================================
642
+ // Pick the anchor closest to the active zone top in viewport coordinates.
643
+ // `entry.rootBounds.top` already reflects `rootMargin` (per W3C IO spec
644
+ // §3.3) — for `rootMargin: "-20% 0px -60% 0px"` it returns 20% of root
645
+ // height, for `"-50% 0px -50% 0px"` it returns the center, etc. Distance
646
+ // = boundingClientRect.top − zoneTop in viewport pixels: positive = anchor
647
+ // below zone top (just entered), negative = anchor above zone top (body
648
+ // crossing zone from above). We prefer smallest non-negative; fall back to
649
+ // least-negative when no entry has crossed yet.
650
+ // Falls back to zoneTop = 0 when rootBounds is null (cross-origin roots,
651
+ // unit tests). Single pass — handles `Iterable` so flushes can pass
652
+ // `Map.values()` directly without realising the array.
653
+ const pickTopmost = (entries) => {
654
+ let bestPositive = null;
655
+ let bestPositiveDist = Number.POSITIVE_INFINITY;
656
+ let bestNegative = null;
657
+ let bestNegativeDist = Number.NEGATIVE_INFINITY;
658
+ for (const entry of entries) {
659
+ if (!entry.isIntersecting) {
660
+ continue;
661
+ }
662
+ const zoneTop = entry.rootBounds?.top ?? 0;
663
+ const distance = entry.boundingClientRect.top - zoneTop;
664
+ if (distance >= 0) {
665
+ if (distance < bestPositiveDist) {
666
+ bestPositive = entry;
667
+ bestPositiveDist = distance;
668
+ }
669
+ }
670
+ else if (distance > bestNegativeDist) {
671
+ bestNegative = entry;
672
+ bestNegativeDist = distance;
673
+ }
674
+ }
675
+ return bestPositive ?? bestNegative;
676
+ };
677
+ const createUrlPluginDetector = (router, onMissing) => {
678
+ let detectionUnsub = null;
679
+ const verify = (state) => {
680
+ const context = state.context;
681
+ if (context && context.url === undefined) {
682
+ console.warn("[real-router] scroll-spy: state.context.url is not claimed. " +
683
+ "Spy requires browser-plugin or navigation-plugin. Disabling.");
684
+ onMissing();
685
+ }
686
+ };
687
+ const peekState = router.getState();
688
+ if (peekState) {
689
+ verify(peekState);
690
+ }
691
+ else {
692
+ // Re-entry guard: `router.subscribe` MAY invoke the callback synchronously
693
+ // from inside `.subscribe(...)` before the function returns. In that case
694
+ // `detectionUnsub` is still `null` when the callback fires. Without this
695
+ // boolean, a hypothetical multi-fire would double-warn.
696
+ let detectionConsumed = false;
697
+ detectionUnsub = router.subscribe(({ route }) => {
698
+ if (detectionConsumed) {
699
+ return;
700
+ }
701
+ detectionConsumed = true;
702
+ verify(route);
703
+ detectionUnsub?.();
704
+ detectionUnsub = null;
705
+ });
706
+ }
707
+ return {
708
+ destroy() {
709
+ detectionUnsub?.();
710
+ detectionUnsub = null;
711
+ },
712
+ };
713
+ };
714
+ const createCooldown = (getContainer) => {
715
+ let active = false;
716
+ let timeout = null;
717
+ let listenerContainer = null;
718
+ let listener = null;
719
+ const clear = () => {
720
+ if (timeout !== null) {
721
+ clearTimeout(timeout);
722
+ timeout = null;
723
+ }
724
+ if (listener) {
725
+ const target = listenerContainer ?? globalThis;
726
+ target.removeEventListener("scrollend", listener);
727
+ }
728
+ listener = null;
729
+ listenerContainer = null;
730
+ active = false;
731
+ };
732
+ return {
733
+ get active() {
734
+ return active;
735
+ },
736
+ start() {
737
+ // Reset rather than stack timers if cooldown is already active.
738
+ clear();
739
+ active = true;
740
+ const lift = () => {
741
+ clear();
742
+ };
743
+ listener = lift;
744
+ listenerContainer = getContainer();
745
+ const target = listenerContainer ?? globalThis;
746
+ target.addEventListener("scrollend", lift, { once: true });
747
+ timeout = setTimeout(lift, COOLDOWN_TIMEOUT_MS);
748
+ },
749
+ destroy() {
750
+ clear();
751
+ },
752
+ };
753
+ };
754
+ const createDebouncer = (callback, trailingMs) => {
755
+ let raf = null;
756
+ let timeout = null;
757
+ return {
758
+ schedule() {
759
+ if (raf !== null) {
760
+ return;
761
+ }
762
+ raf = requestAnimationFrame(() => {
763
+ raf = null;
764
+ if (timeout !== null) {
765
+ clearTimeout(timeout);
766
+ }
767
+ timeout = setTimeout(() => {
768
+ timeout = null;
769
+ callback();
770
+ }, trailingMs);
771
+ });
772
+ },
773
+ destroy() {
774
+ if (raf !== null) {
775
+ cancelAnimationFrame(raf);
776
+ raf = null;
777
+ }
778
+ if (timeout !== null) {
779
+ clearTimeout(timeout);
780
+ timeout = null;
781
+ }
782
+ },
783
+ };
784
+ };
785
+ const createObserverPair = (selector, rootMargin, getContainer, onIntersection, onInvalidSelector, isStopped) => {
786
+ const observed = new Set();
787
+ // Latest IO entry per target — accumulated across batches. IO delivers
788
+ // entries only for targets whose intersection state CHANGED (W3C IO
789
+ // §3.2.1), so a fast scroll that lands two callbacks inside the same
790
+ // debounce window must merge by target, not overwrite. Entries are
791
+ // dropped from the map when their target leaves the DOM (see `reconcile`)
792
+ // and on `destroy()`.
793
+ const pending = new Map();
794
+ let duplicateIdWarned = false;
795
+ let mutationTimer = null;
796
+ const handleIntersection = (entries) => {
797
+ // Defensive: IO callback may fire AFTER `destroy()` if a queued event
798
+ // was already scheduled by the browser before `disconnect()`. Cheap
799
+ // belt-and-suspenders.
800
+ if (isStopped()) {
801
+ return;
802
+ }
803
+ for (const entry of entries) {
804
+ pending.set(entry.target, entry);
805
+ }
806
+ onIntersection();
807
+ };
808
+ const io = new IntersectionObserver(handleIntersection, {
809
+ root: getContainer(),
810
+ rootMargin,
811
+ threshold: 0,
812
+ });
813
+ const observeMatches = () => {
814
+ const scope = getContainer() ?? document;
815
+ let candidates;
816
+ try {
817
+ candidates = scope.querySelectorAll(selector);
818
+ }
819
+ catch {
820
+ onInvalidSelector();
821
+ return;
822
+ }
823
+ const seenIds = new Set();
824
+ for (const element of candidates) {
825
+ // Detect duplicate ids once (RFC §7.7). The DOM permits duplicate ids
826
+ // even though it is a markup bug; the spy keeps working but picks the
827
+ // first one deterministically via the topmost-visible rule.
828
+ const id = element.id;
829
+ if (id && !duplicateIdWarned) {
830
+ if (seenIds.has(id)) {
831
+ duplicateIdWarned = true;
832
+ console.warn(`[real-router] scroll-spy: duplicate id "${id}" observed. ` +
833
+ "Selection picks the topmost visible match deterministically.");
834
+ }
835
+ seenIds.add(id);
836
+ }
837
+ if (observed.has(element)) {
838
+ continue;
839
+ }
840
+ io.observe(element);
841
+ observed.add(element);
842
+ }
843
+ };
844
+ const reconcile = () => {
845
+ // Drop observed elements that left the DOM. Avoids observer holding
846
+ // strong refs to detached nodes. Also drop their accumulated entry so
847
+ // stale "was intersecting" state for a removed node cannot be picked
848
+ // by `pickTopmost` after the node is gone.
849
+ for (const element of observed) {
850
+ if (!element.isConnected) {
851
+ io.unobserve(element);
852
+ observed.delete(element);
853
+ pending.delete(element);
854
+ }
855
+ }
856
+ observeMatches();
857
+ };
858
+ observeMatches();
859
+ // MutationObserver targets the scroll container (or document.body for
860
+ // window viewport). `childList: true, subtree: true` catches structural
861
+ // changes; `attributes: true, attributeFilter: ["id"]` catches anchor
862
+ // id renames (typical for client-rendered docs).
863
+ const mutationTarget = getContainer() ?? document.body;
864
+ const mo = new MutationObserver(() => {
865
+ if (mutationTimer !== null) {
866
+ clearTimeout(mutationTimer);
867
+ }
868
+ mutationTimer = setTimeout(() => {
869
+ mutationTimer = null;
870
+ reconcile();
871
+ }, MUTATION_DEBOUNCE_MS);
872
+ });
873
+ mo.observe(mutationTarget, {
874
+ childList: true,
875
+ subtree: true,
876
+ attributes: true,
877
+ attributeFilter: ["id"],
878
+ });
879
+ return {
880
+ pending,
881
+ destroy() {
882
+ io.disconnect();
883
+ mo.disconnect();
884
+ if (mutationTimer !== null) {
885
+ clearTimeout(mutationTimer);
886
+ mutationTimer = null;
887
+ }
888
+ observed.clear();
889
+ pending.clear();
890
+ },
891
+ };
892
+ };
893
+ // =============================================================================
894
+ // Main: compositional wiring
895
+ // =============================================================================
896
+ function createScrollSpy(router, options) {
897
+ // SSR guard (RFC §7.5) — return early without warnings.
898
+ if (typeof document === "undefined") {
899
+ return NOOP_INSTANCE$1;
900
+ }
901
+ // Feature-detect IntersectionObserver — no polyfill ships (RFC §4).
902
+ if (typeof IntersectionObserver === "undefined") {
903
+ return NOOP_INSTANCE$1;
904
+ }
905
+ const { selector } = options;
906
+ // Empty selector → disabled. Documented opt-out for conditional enabling
907
+ // (RFC §5.4 `scrollSpy={{ selector: enable ? "[id]" : "" }}`).
908
+ if (!selector) {
909
+ return NOOP_INSTANCE$1;
910
+ }
911
+ const rootMargin = options.rootMargin ?? DEFAULT_ROOT_MARGIN;
912
+ const getContainer = options.scrollContainer;
913
+ const resolveContainer = () => getContainer?.() ?? null;
914
+ // Shared lifecycle flags (Oracle Q1 — `silenced` has multiple unrelated
915
+ // triggers; Oracle Q3 — `selfEmitting` synchronously bracketed around
916
+ // `router.navigate()` cannot cleanly extract). Kept in main scope.
917
+ let destroyed = false;
918
+ let silenced = false;
919
+ let selfEmitting = false;
920
+ const isStopped = () => silenced || destroyed;
921
+ // Symmetric late-binding (Oracle Q2): declare `flush` as nullable, wire
922
+ // debouncer + observers, then assign the real implementation. Reads as
923
+ // intentional wiring rather than accidental closure capture ordering.
924
+ // The `flush?.()` call below safely no-ops if a callback somehow fires
925
+ // before assignment (impossible in practice — IO/debounce are async).
926
+ let flush = null;
927
+ const transitionSource = getTransitionSource(router);
928
+ const detector = createUrlPluginDetector(router, () => {
929
+ silenced = true;
930
+ });
931
+ const cooldown = createCooldown(resolveContainer);
932
+ const debouncer = createDebouncer(() => {
933
+ flush?.();
934
+ }, RAF_DEBOUNCE_MS);
935
+ const observers = createObserverPair(selector, rootMargin, resolveContainer, () => {
936
+ debouncer.schedule();
937
+ }, () => {
938
+ if (silenced) {
939
+ return;
940
+ }
941
+ silenced = true;
942
+ console.warn(`[real-router] scroll-spy: invalid selector "${selector}". Disabling.`);
943
+ }, isStopped);
944
+ flush = () => {
945
+ if (destroyed || silenced) {
946
+ observers.pending.clear();
947
+ return;
948
+ }
949
+ // Gate-skipped flushes keep `pendingEntries` populated — the merged
950
+ // state is still the best-known snapshot, and the next non-gated flush
951
+ // consumes it. Clearing under a gate would re-introduce the overwrite
952
+ // bug for any anchor whose intersection state did not change during
953
+ // the gate window.
954
+ if (transitionSource.getSnapshot().isTransitioning) {
955
+ return;
956
+ }
957
+ if (cooldown.active) {
958
+ return;
959
+ }
960
+ if (observers.pending.size === 0) {
961
+ return;
962
+ }
963
+ // Successful flush consumes the merged snapshot. We clear so that the
964
+ // next debounce window starts fresh; an anchor that is still
965
+ // intersecting will only stay observable if IO emits another event for
966
+ // it (which it does whenever the anchor's intersection state actually
967
+ // changes). Skipping the clear here would leak state from one user-
968
+ // perceived "scroll stop" into the next.
969
+ const picked = pickTopmost(observers.pending.values());
970
+ observers.pending.clear();
971
+ if (!picked) {
972
+ // No anchor visible / above zone — preserve last hash (RFC §10 #5).
973
+ return;
974
+ }
975
+ const newHash = picked.target.id;
976
+ if (!newHash) {
977
+ return;
978
+ }
979
+ const state = router.getState();
980
+ if (!state) {
981
+ return;
982
+ }
983
+ const currentHash = getUrlContext(state)?.hash ?? "";
984
+ if (newHash === currentHash) {
985
+ return;
986
+ }
987
+ // Emit the same-route same-params hash-only transition. URL plugin
988
+ // writes `state.context.url.hash = newHash` + `hashChanged = true` in
989
+ // its `onTransitionSuccess` claim.
990
+ const opts = {
991
+ hash: newHash,
992
+ replace: true,
993
+ force: true,
994
+ hashChange: true,
995
+ };
996
+ // Self-emit guard (RFC §5.2): set synchronously around our own
997
+ // `router.navigate()` so the `router.subscribe` callback skips the
998
+ // cooldown setup for spy-emitted transitions — otherwise spy would
999
+ // rate-limit itself to ≤ 2 emits/s, contradicting the ≤ 10/s benchmark
1000
+ // target. Test coupling (Q8): preserve exact `.catch(noop).finally(reset)`
1001
+ // chain — migrating to `try/finally` over `await router.navigate(...)`
1002
+ // changes microtask schedule and breaks "spy continues after rejection".
1003
+ selfEmitting = true;
1004
+ router
1005
+ .navigate(state.name, state.params, opts)
1006
+ .catch(() => {
1007
+ // Fire-and-forget — suppress expected rejections (concurrent
1008
+ // navigate, router stopped, etc.) consistent with `<Link>` adapter
1009
+ // patterns.
1010
+ })
1011
+ .finally(() => {
1012
+ selfEmitting = false;
1013
+ });
1014
+ };
1015
+ // Cooldown setup on user-driven hash transitions. Spy's own emits are
1016
+ // distinguished via the synchronous `selfEmitting` flag (see `flush`).
1017
+ const unsubscribeRouter = router.subscribe(({ route }) => {
1018
+ if (selfEmitting) {
1019
+ return;
1020
+ }
1021
+ if (getUrlContext(route)?.hashChanged) {
1022
+ cooldown.start();
1023
+ }
1024
+ });
1025
+ return {
1026
+ destroy() {
1027
+ if (destroyed) {
1028
+ return;
1029
+ }
1030
+ destroyed = true;
1031
+ // Unsubscribe FIRST to prevent late-arriving router transition
1032
+ // callback from calling `cooldown.start()` on a half-destroyed
1033
+ // instance. Without this ordering, a transition with `hashChanged:
1034
+ // true` firing between subsystem teardown and `unsubscribeRouter()`
1035
+ // would re-install a 500ms timer that survives `destroy()`. Verified
1036
+ // via Oracle review (Q5/Q7).
1037
+ unsubscribeRouter();
1038
+ observers.destroy();
1039
+ debouncer.destroy();
1040
+ cooldown.destroy();
1041
+ detector.destroy();
1042
+ },
1043
+ };
1044
+ }
1045
+
551
1046
  const NOOP_INSTANCE = Object.freeze({
552
1047
  destroy: () => {
553
1048
  /* no-op */
@@ -915,6 +1410,13 @@ function installScrollRestoration(options) {
915
1410
  sr.destroy();
916
1411
  });
917
1412
  }
1413
+ function installScrollSpy(options) {
1414
+ const router = inject(ROUTER);
1415
+ const spy = createScrollSpy(router, options);
1416
+ inject(DestroyRef).onDestroy(() => {
1417
+ spy.destroy();
1418
+ });
1419
+ }
918
1420
  function installViewTransitions() {
919
1421
  const router = inject(ROUTER);
920
1422
  // Feature-detect `document.startViewTransition` once at install time. The
@@ -1007,6 +1509,12 @@ function provideRealRouter(router, options) {
1007
1509
  installScrollRestoration(scrollOpts);
1008
1510
  }));
1009
1511
  }
1512
+ if (options?.scrollSpy && options.scrollSpy.selector !== "") {
1513
+ const spyOpts = options.scrollSpy;
1514
+ providers.push(provideEnvironmentInitializer(() => {
1515
+ installScrollSpy(spyOpts);
1516
+ }));
1517
+ }
1010
1518
  if (options?.viewTransitions === true) {
1011
1519
  providers.push(provideEnvironmentInitializer(installViewTransitions));
1012
1520
  }
@@ -1055,7 +1563,7 @@ const ROUTER_STATE_KEY = makeStateKey("@real-router/angular:ssrState");
1055
1563
  * @returns `EnvironmentProviders` to spread into `ApplicationConfig.providers`.
1056
1564
  */
1057
1565
  function provideRealRouterFactory(options) {
1058
- const { baseRouter, plugins, deps, scrollRestoration, viewTransitions } = options;
1566
+ const { baseRouter, plugins, deps, scrollRestoration, scrollSpy, viewTransitions, } = options;
1059
1567
  const providers = [
1060
1568
  {
1061
1569
  provide: ROUTER,
@@ -1152,6 +1660,11 @@ function provideRealRouterFactory(options) {
1152
1660
  installScrollRestoration(scrollRestoration);
1153
1661
  }));
1154
1662
  }
1663
+ if (scrollSpy && scrollSpy.selector !== "") {
1664
+ providers.push(provideEnvironmentInitializer(() => {
1665
+ installScrollSpy(scrollSpy);
1666
+ }));
1667
+ }
1155
1668
  if (viewTransitions === true) {
1156
1669
  providers.push(provideEnvironmentInitializer(installViewTransitions));
1157
1670
  }