@real-router/angular 0.8.0 → 0.9.0

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 (43) hide show
  1. package/README.md +183 -4
  2. package/dist/README.md +183 -4
  3. package/dist/fesm2022/real-router-angular-ssr.mjs +323 -0
  4. package/dist/fesm2022/real-router-angular-ssr.mjs.map +1 -0
  5. package/dist/fesm2022/real-router-angular.mjs +759 -173
  6. package/dist/fesm2022/real-router-angular.mjs.map +1 -1
  7. package/dist/types/real-router-angular-ssr.d.ts +227 -0
  8. package/dist/types/real-router-angular-ssr.d.ts.map +1 -0
  9. package/dist/types/real-router-angular.d.ts +119 -20
  10. package/dist/types/real-router-angular.d.ts.map +1 -1
  11. package/package.json +18 -11
  12. package/src/components/RouteView.ts +81 -56
  13. package/src/components/RouterErrorBoundary.ts +7 -5
  14. package/src/directives/RealLink.ts +57 -37
  15. package/src/directives/RealLinkActive.ts +34 -25
  16. package/src/dom-utils/link-utils.ts +119 -7
  17. package/src/dom-utils/route-announcer.ts +58 -2
  18. package/src/dom-utils/scroll-restore.ts +160 -12
  19. package/src/functions/injectIsActiveRoute.ts +9 -8
  20. package/src/functions/injectNavigator.ts +4 -0
  21. package/src/functions/injectOrThrow.ts +5 -1
  22. package/src/functions/injectRoute.ts +17 -8
  23. package/src/functions/injectRouteEnter.ts +5 -10
  24. package/src/functions/injectRouteNode.ts +3 -0
  25. package/src/functions/injectRouteUtils.ts +3 -0
  26. package/src/functions/injectRouter.ts +4 -0
  27. package/src/functions/injectRouterTransition.ts +3 -0
  28. package/src/index.ts +14 -3
  29. package/src/internal/buildActiveRouteOptions.ts +20 -0
  30. package/src/internal/install.ts +77 -0
  31. package/src/internal/subscribeSourceToSignal.ts +48 -0
  32. package/src/providers.ts +11 -38
  33. package/src/providersFactory.ts +298 -0
  34. package/src/sourceToSignal.ts +10 -2
  35. package/src/types.ts +6 -1
  36. package/ssr/components/ClientOnly.ts +27 -0
  37. package/ssr/components/HttpStatusCode.ts +106 -0
  38. package/ssr/components/ServerOnly.ts +27 -0
  39. package/ssr/functions/injectDeferred.ts +92 -0
  40. package/ssr/functions/provideHttpStatusSink.ts +43 -0
  41. package/ssr/ng-package.json +6 -0
  42. package/ssr/public_api.ts +35 -0
  43. package/ssr/utils/createHttpStatusSink.ts +61 -0
@@ -1,12 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, inject, DestroyRef, InjectionToken, provideEnvironmentInitializer, ApplicationRef, makeEnvironmentProviders, assertInInjectionContext, effect, input, TemplateRef, Directive, contentChildren, computed, Component, output, ElementRef } from '@angular/core';
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
4
  import { createRouteSource, createRouteNodeSource, getTransitionSource, createActiveRouteSource, createDismissableError } from '@real-router/sources';
5
- import { getPluginApi } from '@real-router/core/api';
5
+ import { cloneRouter, getPluginApi } from '@real-router/core/api';
6
+ import { hydrateRouter, serializeRouterState } from '@real-router/core/utils';
6
7
  import { getRouteUtils, startsWithSegment } from '@real-router/route-utils';
7
8
  import { NgTemplateOutlet } from '@angular/common';
8
9
 
9
- const NOOP_INSTANCE$2 = Object.freeze({
10
+ const NOOP_INSTANCE$3 = Object.freeze({
10
11
  destroy: () => {
11
12
  /* no-op */
12
13
  },
@@ -34,7 +35,7 @@ const NOOP_INSTANCE$2 = Object.freeze({
34
35
  */
35
36
  function createDirectionTracker(router) {
36
37
  if (typeof document === "undefined") {
37
- return NOOP_INSTANCE$2;
38
+ return NOOP_INSTANCE$3;
38
39
  }
39
40
  let popstateFlag = false;
40
41
  document.documentElement.dataset.navDirection = "forward";
@@ -69,7 +70,24 @@ const SAFARI_READY_DELAY = 100;
69
70
  const ANNOUNCER_ATTR = "data-real-router-announcer";
70
71
  const INTERNAL_ROUTE_PREFIX = "@@";
71
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({
74
+ destroy: () => {
75
+ /* no-op */
76
+ },
77
+ });
72
78
  function createRouteAnnouncer(router, options) {
79
+ // Defensive SSR / non-browser guard: in SSR (Node.js) or non-DOM
80
+ // environments, `document` is undefined and the announcer cannot
81
+ // attach its aria-live region. Return a frozen NOOP_INSTANCE — same
82
+ // pattern as `createDirectionTracker`, `createScrollRestoration`, and
83
+ // `createViewTransitions`. Without this guard, `NavigationAnnouncer`
84
+ // component construction would throw `ReferenceError: document is not
85
+ // defined` under `@angular/ssr` rendering, tearing down the whole SSR
86
+ // bootstrap. Closes review-2026-05-10 §5.10 ⛔ "NavigationAnnouncer
87
+ // SSR mode" MED.
88
+ if (typeof document === "undefined") {
89
+ return NOOP_INSTANCE$2;
90
+ }
73
91
  const prefix = options?.prefix ?? "Navigated to ";
74
92
  const getCustomText = options?.getAnnouncementText;
75
93
  let isInitialNavigation = true;
@@ -150,7 +168,19 @@ function getOrCreateAnnouncer() {
150
168
  element.setAttribute("aria-live", "assertive");
151
169
  element.setAttribute("aria-atomic", "true");
152
170
  element.setAttribute(ANNOUNCER_ATTR, "");
153
- document.body.prepend(element);
171
+ // Defensive SSR / pre-`<body>` guard: in some environments (early
172
+ // injection, deferred-body documents, certain SSR rehydration paths)
173
+ // `document.body` can be null when the announcer is constructed.
174
+ // `document.body.prepend(...)` would throw `TypeError: Cannot read
175
+ // properties of null`, tearing down the consumer's RouterProvider /
176
+ // NavigationAnnouncer mount. Fallback to `documentElement` keeps the
177
+ // announcer working for SR users; visual-hidden styling means there is
178
+ // no visible artifact regardless of mount point.
179
+ //
180
+ // TS dom lib types `document.body` as `HTMLElement` (non-null), but
181
+ // runtime can return null per spec. The `as` cast narrows the type to
182
+ // include null so the `??` short-circuit is type-safe.
183
+ (document.body ?? document.documentElement).prepend(element);
154
184
  return element;
155
185
  }
156
186
  function removeAnnouncer() {
@@ -158,7 +188,27 @@ function removeAnnouncer() {
158
188
  }
159
189
  function resolveText(route, prefix, getCustomText, h1) {
160
190
  if (getCustomText) {
161
- return getCustomText(route);
191
+ try {
192
+ const customText = getCustomText(route);
193
+ // Mini-sprint E.4 (audit-5 §4.2 #4) — empty-string fallback.
194
+ // A consumer pattern like
195
+ // getAnnouncementText: (route) => myMap[route.name] ?? ""
196
+ // returns `""` for routes outside the map. The subscribe loop
197
+ // then sees an empty text and silently no-announces — screen
198
+ // readers stay quiet without any signal to the developer. Treat
199
+ // a falsy custom result (`""` / `null` / `undefined`) as
200
+ // "consumer doesn't have a name for this route" and fall through
201
+ // to the default resolution chain (h1 → title → route name).
202
+ if (customText) {
203
+ return customText;
204
+ }
205
+ }
206
+ catch (error) {
207
+ // A throwing consumer callback inside the router's subscribe loop
208
+ // would tear down sibling listeners — log and fall through to the
209
+ // built-in resolution chain so the announcer keeps working.
210
+ console.error("[real-router] getAnnouncementText threw; falling back to default resolution.", error);
211
+ }
162
212
  }
163
213
  const h1Text = (h1?.textContent ?? "").trim();
164
214
  const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
@@ -201,20 +251,36 @@ function createScrollRestoration(router, options) {
201
251
  const getContainer = options?.scrollContainer;
202
252
  const behavior = options?.behavior ?? "auto";
203
253
  const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
254
+ // Write-through in-memory cache: parse sessionStorage once per provider
255
+ // mount, then mutate in-memory. Avoids a JSON.parse + JSON.stringify pair
256
+ // on every subscribeLeave / pagehide event.
257
+ let store;
204
258
  const loadStore = () => {
259
+ if (store !== undefined) {
260
+ return store;
261
+ }
205
262
  try {
206
263
  const raw = sessionStorage.getItem(storageKey);
207
- return raw ? JSON.parse(raw) : {};
264
+ store = raw ? JSON.parse(raw) : {};
208
265
  }
209
266
  catch {
210
- return {};
267
+ store = {};
211
268
  }
269
+ return store;
212
270
  };
213
271
  const putPos = (key, pos) => {
214
272
  try {
215
- const store = loadStore();
216
- store[key] = pos;
217
- sessionStorage.setItem(storageKey, JSON.stringify(store));
273
+ const cached = loadStore();
274
+ // Skip-same-value: when a route is left at the same scroll position it
275
+ // already holds in the cache (e.g. tab-switching without scrolling),
276
+ // both the in-memory write and the JSON.stringify + setItem pair are
277
+ // no-ops. Eliminates redundant serialization on the navigation hot
278
+ // path for the common "click tabs without scrolling" case.
279
+ if (cached[key] === pos) {
280
+ return;
281
+ }
282
+ cached[key] = pos;
283
+ sessionStorage.setItem(storageKey, JSON.stringify(cached));
218
284
  }
219
285
  catch {
220
286
  // Ignore quota / security errors.
@@ -285,6 +351,27 @@ function createScrollRestoration(router, options) {
285
351
  writePos(0);
286
352
  };
287
353
  let destroyed = false;
354
+ let unserializableWarned = false;
355
+ // `keyOf` defers to `canonicalJson` which calls `JSON.stringify`. Two
356
+ // realistic inputs blow up the serializer and would otherwise crash the
357
+ // subscribe callback (taking scroll-restore offline for the whole session):
358
+ // - `BigInt` params → `TypeError: Do not know how to serialize a BigInt`
359
+ // - cyclic params (reactive proxies, DOM-ref back-pointers) → stack
360
+ // overflow.
361
+ // The defensive wrapper drops capture/restore for that specific navigation
362
+ // and warns once per provider — the rest of the cache stays usable.
363
+ const safeKeyOf = (state) => {
364
+ try {
365
+ return keyOf(state);
366
+ }
367
+ catch {
368
+ if (!unserializableWarned) {
369
+ unserializableWarned = true;
370
+ console.error(`[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.`);
371
+ }
372
+ return null;
373
+ }
374
+ };
288
375
  const unsubscribe = router.subscribe(({ route, previousRoute }) => {
289
376
  const nav = route.context
290
377
  .navigation;
@@ -292,7 +379,10 @@ function createScrollRestoration(router, options) {
292
379
  // previousRoute is undefined and capture is naturally skipped. The
293
380
  // pre-refresh position was already persisted via pagehide.
294
381
  if (previousRoute) {
295
- putPos(keyOf(previousRoute), readPos());
382
+ const prevKey = safeKeyOf(previousRoute);
383
+ if (prevKey !== null) {
384
+ putPos(prevKey, readPos());
385
+ }
296
386
  }
297
387
  // Single rAF so DOM is committed before we read anchors / write scroll.
298
388
  // Guard against destroy() racing with the callback.
@@ -310,7 +400,8 @@ function createScrollRestoration(router, options) {
310
400
  if (nav.direction === "back" ||
311
401
  nav.navigationType === "traverse" ||
312
402
  nav.navigationType === "reload") {
313
- writePos(loadStore()[keyOf(route)] ?? 0);
403
+ const key = safeKeyOf(route);
404
+ writePos(key === null ? 0 : (loadStore()[key] ?? 0));
314
405
  return;
315
406
  }
316
407
  scrollToHashOrTop(route);
@@ -319,7 +410,10 @@ function createScrollRestoration(router, options) {
319
410
  const onPageHide = () => {
320
411
  const current = router.getState();
321
412
  if (current) {
322
- putPos(keyOf(current), readPos());
413
+ const key = safeKeyOf(current);
414
+ if (key !== null) {
415
+ putPos(key, readPos());
416
+ }
323
417
  }
324
418
  };
325
419
  globalThis.addEventListener("pagehide", onPageHide);
@@ -340,15 +434,103 @@ function createScrollRestoration(router, options) {
340
434
  },
341
435
  };
342
436
  }
437
+ /**
438
+ * Internal cache-key builder for scroll-position storage.
439
+ *
440
+ * **Exported for testing only — not part of the public API** (intentionally
441
+ * excluded from `index.ts` barrel). Adapter property tests import it via
442
+ * the direct path to lock the `(name, canonicalJson(params))` key shape
443
+ * as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
444
+ * key format would silently lose scroll positions across an upgrade —
445
+ * the test set is the contract.
446
+ *
447
+ * ## Identity-based memoization (audit-2026-05-17 §8b #2)
448
+ *
449
+ * `State` objects emitted by core are frozen per-navigation: their
450
+ * `name` / `params` are immutable for the lifetime of the snapshot, and
451
+ * any change produces a new `State` reference. A `WeakMap<State, string>`
452
+ * therefore safely caches the canonicalised key by identity — repeat
453
+ * `keyOf(state)` calls on the same snapshot (typical on
454
+ * back/forward/traverse where the same prior `State` is re-emitted)
455
+ * skip the recursive `canonicalJson` pass entirely.
456
+ *
457
+ * The cache key is the `State` reference, so entries auto-release when
458
+ * the snapshot is GC'd — no eviction needed.
459
+ */
460
+ const KEY_CACHE = new WeakMap();
343
461
  function keyOf(state) {
344
- return `${state.name}:${canonicalJson(state.params)}`;
462
+ const cached = KEY_CACHE.get(state);
463
+ if (cached !== undefined) {
464
+ return cached;
465
+ }
466
+ const key = `${state.name}:${canonicalJson(state.params)}`;
467
+ KEY_CACHE.set(state, key);
468
+ return key;
345
469
  }
470
+ /**
471
+ * Stable JSON serializer with sorted object keys.
472
+ *
473
+ * **Exported for testing only — not part of the public API** (intentionally
474
+ * excluded from `index.ts` barrel). Adapter property tests import it via
475
+ * the direct path to lock the key-order-insensitive property
476
+ * (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
477
+ *
478
+ * ## Divergence from `@real-router/sources/canonicalJson` — by design
479
+ *
480
+ * Two independent implementations live in the monorepo:
481
+ *
482
+ * - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
483
+ * cache key builder. Uses `localeCompare` and a plain-object accumulator;
484
+ * tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
485
+ * replacer happens to sort them; relies on `JSON.stringify`'s native cycle
486
+ * detector. Designed to be cheap on the navigation hot path. The
487
+ * surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
488
+ * cyclic) and skips the offending capture/restore.
489
+ *
490
+ * - **`@real-router/sources/canonicalJson`** — sources cache key builder.
491
+ * Uses byte-order compare (`< / >`) for locale-independence, a
492
+ * `Object.create(null)` accumulator to prevent prototype pollution, and a
493
+ * bespoke path-based cycle detector (the native one cannot see the cloned
494
+ * graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
495
+ * back to a non-cached source.
496
+ *
497
+ * **They are intentionally NOT interchangeable.** Aligning them would either
498
+ * regress scroll-restore performance (byte-order + recursive clone is heavier
499
+ * per call) or weaken the sources cache (locale dependence breaks
500
+ * deterministic cache keys across machines). No cross-package equivalence
501
+ * test exists or should be added; the relationship is "different invariants,
502
+ * different costs, different consumers." Audit-2 / audit-2026-05-17 §2
503
+ * documents the choice.
504
+ */
346
505
  function canonicalJson(value) {
347
506
  return JSON.stringify(value, canonicalReplacer);
348
507
  }
349
508
  function canonicalReplacer(_key, val) {
509
+ // audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
510
+ // `JSON.stringify` silently drops function and symbol values from
511
+ // object output. Two routes that differ ONLY in a function/Symbol
512
+ // value would canonicalize to the same string → silent scroll-cache
513
+ // key collision (positions clobber each other). Replacing the value
514
+ // with a sentinel string breaks the collision while keeping the
515
+ // canonical form deterministic. The sentinels are intentionally
516
+ // ASCII-only and lexically distinct from valid JSON-stringified
517
+ // values; consumers will see `"<fn>"` / `"<sym>"` if they ever
518
+ // round-trip the cache key, signalling the substitution clearly.
519
+ if (typeof val === "function") {
520
+ return "<fn>";
521
+ }
522
+ if (typeof val === "symbol") {
523
+ return "<sym>";
524
+ }
350
525
  if (val !== null && typeof val === "object" && !Array.isArray(val)) {
351
- const sorted = {};
526
+ // Null-prototype accumulator: a plain `{}` would interpret
527
+ // `sorted["__proto__"] = x` as a prototype assignment (silently dropped
528
+ // from JSON.stringify output AND a prototype-pollution vector). Mirrors
529
+ // the same guard in `@real-router/sources/canonicalJson`. The two
530
+ // implementations are still intentionally divergent (see the doc-block
531
+ // on [[canonicalJson]] above), but prototype-safety is non-negotiable
532
+ // on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
533
+ const sorted = Object.create(null);
352
534
  // eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
353
535
  const keys = Object.keys(val).sort((left, right) => left.localeCompare(right));
354
536
  for (const key of keys) {
@@ -485,14 +667,39 @@ function shouldNavigate(evt) {
485
667
  !evt.ctrlKey &&
486
668
  !evt.shiftKey);
487
669
  }
670
+ // Matches a single percent-escape triple (`%` + two hex digits). Used as
671
+ // the "already-encoded" probe in `encodeFragmentInline` below — see the
672
+ // idempotency rationale there.
673
+ const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
488
674
  /**
489
675
  * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
490
676
  * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
491
677
  * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
492
678
  * `shared/browser-env/url-context.ts` — duplicated here because the
493
679
  * shared/dom-utils symlink graph does not reach shared/browser-env.
680
+ *
681
+ * **Idempotency for pre-encoded input (audit-2026-05-17 §5 MEDIUM E.1).**
682
+ * The doc-comment on `<Link hash>` says the value is a "decoded fragment
683
+ * without leading #". But realistic consumers copy hashes out of
684
+ * `location.hash` (which is percent-encoded) and pass them back, so the
685
+ * naive `encodeURI("%20")` would double-encode into `"%2520"` and break
686
+ * anchor lookup. We detect a percent-escape triple in the input and, if
687
+ * present, decode + re-encode for idempotency. Malformed `%XX` (e.g.
688
+ * `"%2"` or `"%ZZ"`) makes `decodeURIComponent` throw — in that case we
689
+ * fall through to plain `encodeURI`, which never throws.
494
690
  */
495
691
  function encodeFragmentInline(decoded) {
692
+ if (PERCENT_ESCAPE_PROBE.test(decoded)) {
693
+ try {
694
+ const roundtrip = decodeURIComponent(decoded);
695
+ return encodeURI(roundtrip).replaceAll("#", "%23");
696
+ }
697
+ catch {
698
+ // Malformed `%XX` — fall through to the plain encoding path.
699
+ // encodeURI does not throw on malformed escapes; it treats the
700
+ // `%` as a literal and percent-encodes it (`%2` → `%252`).
701
+ }
702
+ }
496
703
  return encodeURI(decoded).replaceAll("#", "%23");
497
704
  }
498
705
  /**
@@ -518,11 +725,28 @@ function buildHref(router, routeName, routeParams, options) {
518
725
  const buildUrl = router.buildUrl;
519
726
  if (buildUrl) {
520
727
  const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
521
- if (url !== undefined) {
728
+ // Accept only non-empty strings. The BuildUrlFn type contract is
729
+ // `string | undefined`, but defensive against:
730
+ // - `""` (empty string) → would render `<a href="">`, which resolves
731
+ // to the current page URL → silent self-navigation on click.
732
+ // - `null` (type-contract violation) → would render `<a href={null}>`,
733
+ // stringified to `"null"` in some renderers.
734
+ // Either case falls through to the `router.buildPath` fallback below.
735
+ if (typeof url === "string" && url.length > 0) {
522
736
  return url;
523
737
  }
524
738
  }
525
739
  const path = router.buildPath(routeName, routeParams);
740
+ // Symmetric to the buildUrl guard above (#S1 audit, Invariant 12).
741
+ // `router.buildPath` is typed `string`, but defends against:
742
+ // - `""` (empty string) — would render `<a href="">`, which resolves
743
+ // to the current page URL → silent self-navigation on click.
744
+ // - non-string type-contract violations from custom path-matchers.
745
+ // Both yield `undefined` (renderer drops the attribute) with a warning.
746
+ if (typeof path !== "string" || path.length === 0) {
747
+ console.error(`[real-router] Route "${routeName}" yielded an empty path. The element will render without an href attribute.`);
748
+ return undefined;
749
+ }
526
750
  return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
527
751
  }
528
752
  catch {
@@ -548,8 +772,25 @@ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
548
772
  }
549
773
  return router.navigate(routeName, routeParams, opts);
550
774
  }
775
+ // Match-any-whitespace regex shared across calls. RegExp literals at
776
+ // call-site recompile in some engines; lifting it avoids that microcost
777
+ // for the slow-path branch.
778
+ const WHITESPACE_PROBE = /\s/;
779
+ const WHITESPACE_SPLIT = /\S+/g;
551
780
  function parseTokens(value) {
552
- return value ? (value.match(/\S+/g) ?? []) : [];
781
+ if (!value) {
782
+ return [];
783
+ }
784
+ // Hot-path fast-path (audit-2026-05-17 §8b #1): >99% of active-class
785
+ // inputs at `<Link>` emit are single-token strings like `"active"` or
786
+ // `"is-current"` — no whitespace, no leading/trailing pad. Skip the
787
+ // regex match and Array result allocation: a literal `[value]` works
788
+ // because the slow-path `match(/\S+/g)` would return exactly `[value]`
789
+ // for the same input. PBT lock: linkUtils.properties.ts Invariant 13.
790
+ if (!WHITESPACE_PROBE.test(value)) {
791
+ return [value];
792
+ }
793
+ return value.match(WHITESPACE_SPLIT) ?? [];
553
794
  }
554
795
  function buildActiveClassName(isActive, activeClassName, baseClassName) {
555
796
  if (isActive && activeClassName) {
@@ -572,6 +813,29 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
572
813
  }
573
814
  return baseClassName ?? undefined;
574
815
  }
816
+ /**
817
+ * One-level structural equality using `Object.is` per key.
818
+ *
819
+ * **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
820
+ * Implementation walks `Object.keys()` which by spec returns only
821
+ * enumerable own STRING keys. Symbol-keyed properties — created via
822
+ * `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
823
+ * NOT compared. Two records that differ only in a Symbol-keyed value
824
+ * will compare as equal.
825
+ *
826
+ * This is intentional: route params and Link options are documented as
827
+ * string-keyed primitives (string | number | boolean) — Symbol-keyed
828
+ * metadata (e.g. brand markers, private state) doesn't belong in a
829
+ * cache-key comparison. Switching to `Reflect.ownKeys()` would extend
830
+ * the contract to symbols at the cost of one extra allocation per call
831
+ * (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
832
+ * consumer relies on symbol-keyed metadata for navigation
833
+ * disambiguation, they should encode it into a string key instead.
834
+ *
835
+ * Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
836
+ * both the string-keys-only semantics and the `hasOwnProperty` guard
837
+ * below.
838
+ */
575
839
  function shallowEqual(prev, next) {
576
840
  if (Object.is(prev, next)) {
577
841
  return true;
@@ -586,7 +850,11 @@ function shallowEqual(prev, next) {
586
850
  const prevRecord = prev;
587
851
  const nextRecord = next;
588
852
  for (const key of prevKeys) {
589
- if (!Object.is(prevRecord[key], nextRecord[key])) {
853
+ // hasOwnProperty guard: without it, a key missing in `next` reads as
854
+ // `undefined` and falsely matches `prev[key] === undefined`. Same shape
855
+ // as React's shallowEqual (packages/shared/shallowEqual.js).
856
+ if (!Object.prototype.hasOwnProperty.call(next, key) ||
857
+ !Object.is(prevRecord[key], nextRecord[key])) {
590
858
  return false;
591
859
  }
592
860
  }
@@ -596,8 +864,23 @@ function applyLinkA11y(element) {
596
864
  if (!element) {
597
865
  return;
598
866
  }
599
- if (element instanceof HTMLAnchorElement ||
600
- element instanceof HTMLButtonElement) {
867
+ // Cross-realm safety (audit-2026-05-17 §5 HIGH #4):
868
+ // `instanceof HTMLAnchorElement` compares against the constructor from
869
+ // the CURRENT realm. An element created in a different window (iframe
870
+ // contentDocument, micro-frontend, embedded widget) fails the check
871
+ // even when it IS a real anchor — the helper would then inject
872
+ // role="link" + tabindex="0" on top of native anchor semantics,
873
+ // breaking screen reader output ("link link") and focus order.
874
+ //
875
+ // tagName is realm-agnostic and is uppercase for HTML-namespaced
876
+ // elements in any document. SVG `<a>` has lowercase tagName plus a
877
+ // different prototype (SVGAElement) — skipping it here is wrong by
878
+ // accident: SVG anchors don't have keyboard activation semantics the
879
+ // helper would add. But they also don't reach this helper in
880
+ // practice (router Link components emit HTML anchors). Lock the
881
+ // uppercase compare to keep the contract narrow.
882
+ const tag = element.tagName;
883
+ if (tag === "A" || tag === "BUTTON") {
601
884
  return;
602
885
  }
603
886
  if (!element.hasAttribute("role")) {
@@ -608,6 +891,65 @@ function applyLinkA11y(element) {
608
891
  }
609
892
  }
610
893
 
894
+ /**
895
+ * Shared installation helpers for `provideRealRouter` and
896
+ * `provideRealRouterFactory`. Must be called inside the body of a
897
+ * `provideEnvironmentInitializer(() => { ... })` callback so the active
898
+ * injection context resolves `ROUTER`, `ApplicationRef`, and `DestroyRef`.
899
+ *
900
+ * Closes review-2026-05-10 §8.1 MED — eliminates duplicate wiring between
901
+ * `providers.ts` and `providersFactory.ts` (high drift risk noted in the
902
+ * audit: the comment blocks were identical down to the punctuation).
903
+ */
904
+ function installScrollRestoration(options) {
905
+ const router = inject(ROUTER);
906
+ const sr = createScrollRestoration(router, options);
907
+ inject(DestroyRef).onDestroy(() => {
908
+ sr.destroy();
909
+ });
910
+ }
911
+ function installViewTransitions() {
912
+ const router = inject(ROUTER);
913
+ // Feature-detect `document.startViewTransition` once at install time. The
914
+ // `appRef.tick()` listener exists ONLY to feed Angular's zoneless CD into
915
+ // the VT utility's `setTimeout(0)`-driven snapshot capture (see comment
916
+ // below). When `startViewTransition` is unavailable (Firefox as of 2026-04,
917
+ // SSR, older browsers), `createViewTransitions` short-circuits to its
918
+ // frozen NOOP_INSTANCE — no leave subscriber registered, no
919
+ // `setTimeout(0)` invariant to satisfy. Installing the per-navigation
920
+ // tick listener anyway would force a synchronous CD pass on every
921
+ // navigation with zero benefit, doubling CD work in zoneless apps.
922
+ // Closes review-2026-05-10 §8.2 MED (view-transitions hot path).
923
+ const vtAvailable = typeof document !== "undefined" &&
924
+ typeof document.startViewTransition === "function";
925
+ let offTick;
926
+ if (vtAvailable) {
927
+ // Force synchronous change detection on every transition success BEFORE
928
+ // the VT utility resolves its deferred. The utility uses `setTimeout(0)`
929
+ // to release the new-snapshot capture, which is load-bearing because
930
+ // Chromium blocks rAF callbacks while VT sits in the
931
+ // `update-callback-called` phase. Angular's zoneless CD is rAF-driven by
932
+ // default — without this synchronous tick the new DOM is not committed
933
+ // when the browser captures the new snapshot, so old and new snapshots
934
+ // end up identical and animations finish in ~0 ms with no visible work
935
+ // (the inner-route `products.list ↔ products.detail` morph in the
936
+ // example app was the canary).
937
+ //
938
+ // Subscribers fire in registration order; this one runs BEFORE
939
+ // `createViewTransitions` registers its own subscriber, guaranteeing CD
940
+ // completes first.
941
+ const appRef = inject(ApplicationRef);
942
+ offTick = router.subscribe(() => {
943
+ appRef.tick();
944
+ });
945
+ }
946
+ const vt = createViewTransitions(router);
947
+ inject(DestroyRef).onDestroy(() => {
948
+ offTick?.();
949
+ vt.destroy();
950
+ });
951
+ }
952
+
611
953
  /** Must be called within an injection context (constructor, field initializer, runInInjectionContext). */
612
954
  function sourceToSignal(source) {
613
955
  const sig = signal(source.getSnapshot(), ...(ngDevMode ? [{ debugName: "sig" }] : /* istanbul ignore next */ []));
@@ -616,8 +958,17 @@ function sourceToSignal(source) {
616
958
  sig.set(source.getSnapshot());
617
959
  });
618
960
  destroyRef.onDestroy(() => {
619
- unsubscribe();
620
- source.destroy();
961
+ // `try/finally` guarantees `source.destroy()` runs even if `unsubscribe`
962
+ // throws. Cached sources from `@real-router/sources` keep `destroy()` as
963
+ // a no-op (so they survive multi-consumer teardown), but non-cached
964
+ // sources rely on this call to release their router subscription —
965
+ // skipping it on an unsubscribe throw would leak the listener.
966
+ try {
967
+ unsubscribe();
968
+ }
969
+ finally {
970
+ source.destroy();
971
+ }
621
972
  });
622
973
  return sig.asReadonly();
623
974
  }
@@ -627,6 +978,11 @@ const NAVIGATOR = new InjectionToken("NAVIGATOR");
627
978
  const ROUTE = new InjectionToken("ROUTE");
628
979
  function provideRealRouter(router, options) {
629
980
  const navigator = getNavigator(router);
981
+ // `Parameters<typeof makeEnvironmentProviders>[0]` is the actual union
982
+ // `(Provider | EnvironmentProviders | EnvironmentProviders[])[]` —
983
+ // `provideEnvironmentInitializer()` returns `EnvironmentProviders`, so the
984
+ // narrower `Provider[]` would force a cast at every push (review §8a — the
985
+ // proposed Provider[] swap was retracted after discovering this).
630
986
  const providers = [
631
987
  { provide: ROUTER, useValue: router },
632
988
  { provide: NAVIGATOR, useValue: navigator },
@@ -641,66 +997,213 @@ function provideRealRouter(router, options) {
641
997
  if (options?.scrollRestoration) {
642
998
  const scrollOpts = options.scrollRestoration;
643
999
  providers.push(provideEnvironmentInitializer(() => {
644
- const sr = createScrollRestoration(router, scrollOpts);
645
- inject(DestroyRef).onDestroy(() => {
646
- sr.destroy();
647
- });
1000
+ installScrollRestoration(scrollOpts);
648
1001
  }));
649
1002
  }
650
1003
  if (options?.viewTransitions === true) {
1004
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
1005
+ }
1006
+ return makeEnvironmentProviders(providers);
1007
+ }
1008
+
1009
+ /**
1010
+ * `TransferState` key carrying the SSR-resolved router state from server to
1011
+ * client as an XSS-safe JSON string (produced by `serializeRouterState`).
1012
+ * Populated server-side by the `provideAppInitializer` callback after
1013
+ * `router.start()` resolves; consumed client-side after hydration. Mirrors the
1014
+ * `<script>window.__SSR_STATE__ = …</script>` pattern used by every other
1015
+ * adapter — Angular's idiomatic transport is `TransferState` (#599).
1016
+ *
1017
+ * Stored as `string`: `serializeRouterState(state)` already produces JSON;
1018
+ * `hydrateRouter(router, json)` accepts a JSON string and parses it once
1019
+ * internally. Storing the parsed object would force a double round-trip
1020
+ * (TransferState wraps every value in JSON for transport).
1021
+ *
1022
+ * Internal implementation detail. Not re-exported.
1023
+ */
1024
+ const ROUTER_STATE_KEY = makeStateKey("@real-router/angular:ssrState");
1025
+ /**
1026
+ * `provideRealRouterFactory` — environment providers for SSR / SSG scenarios.
1027
+ *
1028
+ * Unlike `provideRealRouter(router)` (single instance via `useValue`), this
1029
+ * factory uses `useFactory` to produce a per-request router clone:
1030
+ *
1031
+ * 1. Reads Angular's `REQUEST` token (`{ optional: true }`).
1032
+ * 2. Calls `cloneRouter(baseRouter, deps?.(request))` to create a request-scoped clone.
1033
+ * 3. Applies plugins (`plugins` array or `plugins(request)` factory).
1034
+ * 4. Registers `provideAppInitializer` that calls `await router.start(url)`.
1035
+ * 5. Schedules `router.dispose()` via `DestroyRef.onDestroy` — the request
1036
+ * Injector is destroyed after the response is sent, releasing all
1037
+ * subscriptions and plugins.
1038
+ *
1039
+ * Use cases:
1040
+ * - Angular SSR with `@angular/ssr` (`outputMode: "server"`).
1041
+ * - SSG build-time render via `renderApplication` + `platformProviders` `REQUEST` mock.
1042
+ * - Multi-tenant request-scoped routing.
1043
+ *
1044
+ * Existing single-instance scenarios (SPA, SSG client after hydration) continue
1045
+ * to use `provideRealRouter(router)` — both APIs ship in parallel.
1046
+ *
1047
+ * @param options - Factory configuration — see `RealRouterFactoryOptions`.
1048
+ * @returns `EnvironmentProviders` to spread into `ApplicationConfig.providers`.
1049
+ */
1050
+ function provideRealRouterFactory(options) {
1051
+ const { baseRouter, plugins, deps, scrollRestoration, viewTransitions } = options;
1052
+ const providers = [
1053
+ {
1054
+ provide: ROUTER,
1055
+ useFactory: () => {
1056
+ const request = inject(REQUEST, { optional: true });
1057
+ const requestDeps = deps?.(request);
1058
+ const router = cloneRouter(baseRouter, requestDeps);
1059
+ const pluginList = typeof plugins === "function" ? plugins(request) : plugins;
1060
+ if (pluginList && pluginList.length > 0) {
1061
+ // Variadic — `usePlugin` accepts `(PluginFactory<D> | false | null | undefined)[]`.
1062
+ router.usePlugin(...pluginList);
1063
+ }
1064
+ // Per-request cleanup. The application Injector is destroyed:
1065
+ // - On server: after `writeResponseToNodeResponse` finishes the response
1066
+ // (request scope ends).
1067
+ // - On client: at `ApplicationRef.destroy` (rare in SPA, common in TestBed).
1068
+ // - In SSG build: after each `renderApplication` resolves.
1069
+ inject(DestroyRef).onDestroy(() => {
1070
+ router.dispose();
1071
+ });
1072
+ return router;
1073
+ },
1074
+ },
1075
+ {
1076
+ provide: NAVIGATOR,
1077
+ useFactory: () => getNavigator(inject(ROUTER)),
1078
+ },
1079
+ {
1080
+ provide: ROUTE,
1081
+ useFactory: () => {
1082
+ const router = inject(ROUTER);
1083
+ return {
1084
+ routeState: sourceToSignal(createRouteSource(router)),
1085
+ navigator: inject(NAVIGATOR),
1086
+ };
1087
+ },
1088
+ },
1089
+ // Async bootstrap — runs before the first component renders. Three
1090
+ // branches based on TransferState population:
1091
+ //
1092
+ // 1. **Client after hydration** — server populated TransferState with
1093
+ // the SSR-resolved router state. Consume it via `hydrateRouter`,
1094
+ // which deposits the parsed state into the one-shot
1095
+ // `RouterInternals.hydrationState` scratchpad before invoking
1096
+ // `router.start(state.path)`. SSR loader plugins
1097
+ // (`@real-router/ssr-data-plugin`, `@real-router/rsc-server-plugin`)
1098
+ // read the scratchpad and skip the loader on first paint — parity
1099
+ // with the other 5 adapters that consume `<script>__SSR_STATE__</script>` (#596, #599).
1100
+ //
1101
+ // 2. **Server / SSG** — TransferState empty; run the regular
1102
+ // `router.start(path)`. After it resolves, write the serialized
1103
+ // state back into TransferState so the matching client run lands
1104
+ // in branch 1. Angular's `TransferState` infrastructure
1105
+ // (provided by `provideClientHydration()`) carries this blob to
1106
+ // the client as a `<script id="ng-state">` payload.
1107
+ //
1108
+ // 3. **Pure CSR** — TransferState empty (never populated by a server
1109
+ // pass), and `inject(REQUEST, { optional: true })` returns null.
1110
+ // Falls into the same `router.start(path)` branch as server-side
1111
+ // but skips the TransferState write (no client to hand off to).
1112
+ //
1113
+ // Errors propagate (Option A from RFC §10): the bootstrap fails and the
1114
+ // server returns 500. Custom error pages should be wired via
1115
+ // `RouterErrorBoundary` on subsequent renders.
1116
+ provideAppInitializer(async () => {
1117
+ const router = inject(ROUTER);
1118
+ const request = inject(REQUEST, { optional: true });
1119
+ const transferState = inject(TransferState);
1120
+ const ssrJson = transferState.get(ROUTER_STATE_KEY, null);
1121
+ if (ssrJson !== null) {
1122
+ // Branch 1: client after hydration — reuse server-resolved state.
1123
+ await hydrateRouter(router, ssrJson);
1124
+ // One-shot semantic, parity with `delete window.__SSR_STATE__`.
1125
+ transferState.remove(ROUTER_STATE_KEY);
1126
+ return;
1127
+ }
1128
+ // Branches 2 & 3: regular start.
1129
+ // Browser-plugin's `start` interceptor (when registered) wraps this call
1130
+ // with location-derived path. We always pass an explicit string — the
1131
+ // interceptor uses the explicit value because `next(path ?? location)`
1132
+ // short-circuits when `path` is non-nullish.
1133
+ const path = deriveStartPath(request);
1134
+ const state = await router.start(path);
1135
+ if (request !== null) {
1136
+ // Branch 2: running inside `@angular/ssr`'s request handler — write
1137
+ // serialized state to TransferState so the matching client run can
1138
+ // skip the loader on first paint.
1139
+ transferState.set(ROUTER_STATE_KEY, serializeRouterState(state));
1140
+ }
1141
+ }),
1142
+ ];
1143
+ if (scrollRestoration) {
651
1144
  providers.push(provideEnvironmentInitializer(() => {
652
- const appRef = inject(ApplicationRef);
653
- // Force synchronous change detection on every transition success
654
- // BEFORE the VT utility resolves its deferred. The utility uses
655
- // `setTimeout(0)` to release the new-snapshot capture, which is
656
- // load-bearing because Chromium blocks rAF callbacks while VT sits
657
- // in the `update-callback-called` phase. Angular's zoneless CD is
658
- // rAF-driven by default — without this synchronous tick the new
659
- // DOM is not committed when the browser captures the new snapshot,
660
- // so old and new snapshots end up identical and animations finish
661
- // in ~0 ms with no visible work (the inner-route `products.list ↔
662
- // products.detail` morph in the example example was the canary).
663
- // Subscribers fire in registration order; this one runs BEFORE
664
- // `createViewTransitions` registers its own subscriber,
665
- // guaranteeing CD completes first.
666
- const offTick = router.subscribe(() => {
667
- appRef.tick();
668
- });
669
- const vt = createViewTransitions(router);
670
- inject(DestroyRef).onDestroy(() => {
671
- offTick();
672
- vt.destroy();
673
- });
1145
+ installScrollRestoration(scrollRestoration);
674
1146
  }));
675
1147
  }
1148
+ if (viewTransitions === true) {
1149
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
1150
+ }
676
1151
  return makeEnvironmentProviders(providers);
677
1152
  }
1153
+ /**
1154
+ * Derive the path passed to `router.start(path)`:
1155
+ * - Server / SSG: `request.url` → pathname + search.
1156
+ * - Client: `window.location` if available.
1157
+ * - Fallback: `"/"` (only reachable in synthetic non-browser non-SSR setups).
1158
+ */
1159
+ function deriveStartPath(request) {
1160
+ if (request) {
1161
+ const url = new URL(request.url);
1162
+ return url.pathname + url.search;
1163
+ }
1164
+ if (typeof globalThis.window !== "undefined") {
1165
+ return globalThis.location.pathname + globalThis.location.search;
1166
+ }
1167
+ return "/";
1168
+ }
678
1169
 
679
1170
  function injectOrThrow(token, fnName) {
680
1171
  const value = inject(token, { optional: true });
681
- if (!value) {
1172
+ // Explicit null / undefined check — falsy guard would misfire on
1173
+ // legitimately falsy values (`0`, `""`, `false`) if the token were ever
1174
+ // typed for primitives. Today all our tokens hold object instances, but
1175
+ // pinning the check keeps the function safe for future typing changes.
1176
+ if (value === null || value === undefined) {
682
1177
  throw new Error(`${fnName} must be used within a provideRealRouter context`);
683
1178
  }
684
1179
  return value;
685
1180
  }
686
1181
 
687
1182
  function injectRouter() {
1183
+ assertInInjectionContext(injectRouter);
688
1184
  return injectOrThrow(ROUTER, "injectRouter");
689
1185
  }
690
1186
 
691
1187
  function injectNavigator() {
1188
+ assertInInjectionContext(injectNavigator);
692
1189
  return injectOrThrow(NAVIGATOR, "injectNavigator");
693
1190
  }
694
1191
 
695
1192
  function injectRoute() {
1193
+ assertInInjectionContext(injectRoute);
696
1194
  const signals = injectOrThrow(ROUTE, "injectRoute");
697
- if (!signals.routeState().route) {
1195
+ // Read the snapshot once: the signal is reactive, but the throw-guard
1196
+ // and any future use of the snapshot within this call should observe the
1197
+ // SAME value to avoid races.
1198
+ const snapshot = signals.routeState();
1199
+ if (!snapshot.route) {
698
1200
  throw new Error("injectRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?");
699
1201
  }
700
1202
  return signals;
701
1203
  }
702
1204
 
703
1205
  function injectRouteNode(nodeName) {
1206
+ assertInInjectionContext(injectRouteNode);
704
1207
  const router = injectRouter();
705
1208
  const navigator = getNavigator(router);
706
1209
  const source = createRouteNodeSource(router, nodeName);
@@ -709,26 +1212,37 @@ function injectRouteNode(nodeName) {
709
1212
  }
710
1213
 
711
1214
  function injectRouteUtils() {
1215
+ assertInInjectionContext(injectRouteUtils);
712
1216
  const router = injectRouter();
713
1217
  return getRouteUtils(getPluginApi(router).getTree());
714
1218
  }
715
1219
 
716
1220
  function injectRouterTransition() {
1221
+ assertInInjectionContext(injectRouterTransition);
717
1222
  const router = injectRouter();
718
1223
  const source = getTransitionSource(router);
719
1224
  return sourceToSignal(source);
720
1225
  }
721
1226
 
1227
+ /**
1228
+ * Build the `options` literal for `createActiveRouteSource` while honoring
1229
+ * `exactOptionalPropertyTypes` — the type forbids passing `{ hash: undefined }`
1230
+ * literally (#532), so callers must conditionally include the `hash` key only
1231
+ * when a value was provided.
1232
+ *
1233
+ * Used by `RealLink`, `RealLinkActive`, and `injectIsActiveRoute` — extracted
1234
+ * from three identical ternaries (review-2026-05-16 §8a LOW).
1235
+ */
1236
+ function buildActiveRouteOptions(strict, ignoreQueryParams, hash) {
1237
+ return hash === undefined
1238
+ ? { strict, ignoreQueryParams }
1239
+ : { strict, ignoreQueryParams, hash };
1240
+ }
1241
+
722
1242
  function injectIsActiveRoute(routeName, params, options) {
1243
+ assertInInjectionContext(injectIsActiveRoute);
723
1244
  const router = injectRouter();
724
- const strict = options?.strict ?? false;
725
- const ignoreQueryParams = options?.ignoreQueryParams ?? true;
726
- const hash = options?.hash;
727
- // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — pass
728
- // the field only when a value was provided. (#532)
729
- const source = createActiveRouteSource(router, routeName, params, hash === undefined
730
- ? { strict, ignoreQueryParams }
731
- : { strict, ignoreQueryParams, hash });
1245
+ const source = createActiveRouteSource(router, routeName, params, buildActiveRouteOptions(options?.strict ?? false, options?.ignoreQueryParams ?? true, options?.hash));
732
1246
  return sourceToSignal(source);
733
1247
  }
734
1248
 
@@ -870,7 +1384,6 @@ function injectRouteEnter(handler, options) {
870
1384
  assertInInjectionContext(injectRouteEnter);
871
1385
  const { routeState } = injectRoute();
872
1386
  const skipSameRoute = options?.skipSameRoute ?? true;
873
- let lastHandledRoute = null;
874
1387
  effect(() => {
875
1388
  const { route, previousRoute } = routeState();
876
1389
  // Early-exit guards, top-down:
@@ -880,23 +1393,19 @@ function injectRouteEnter(handler, options) {
880
1393
  // - **Skip-same-route**: query-only navigations have
881
1394
  // `transition.from === route.name`. Opt-out via
882
1395
  // `skipSameRoute: false`.
883
- // - **Defensive dedupe + missing `previousRoute`**: same `route`
884
- // ref between effect re-runs is unexpected on Angular (the
885
- // signal only fires on real reference changes); `!previousRoute`
886
- // is unreachable once `transition.from` is set (core populates
887
- // them together). Both kept for parity with React; v8-ignored.
888
1396
  if (!route.transition.from) {
889
1397
  return;
890
1398
  }
891
1399
  if (skipSameRoute && route.transition.from === route.name) {
892
1400
  return;
893
1401
  }
894
- /* v8 ignore start */
895
- if (lastHandledRoute === route || !previousRoute) {
1402
+ // `previousRoute` is guaranteed populated whenever `route.transition.from`
1403
+ // is set core writes them together. The dead-code throw-guard that used
1404
+ // to live here (review §8a LOW) is removed; the narrowing below is the
1405
+ // type-safe equivalent and avoids the no-non-null-assertion lint.
1406
+ if (!previousRoute) {
896
1407
  return;
897
1408
  }
898
- /* v8 ignore stop */
899
- lastHandledRoute = route;
900
1409
  handler({ route, previousRoute });
901
1410
  });
902
1411
  }
@@ -904,60 +1413,131 @@ function injectRouteEnter(handler, options) {
904
1413
  class RouteMatch {
905
1414
  routeMatch = input.required(...(ngDevMode ? [{ debugName: "routeMatch" }] : /* istanbul ignore next */ []));
906
1415
  templateRef = inject(TemplateRef);
907
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteMatch, deps: [], target: i0.ɵɵFactoryTarget.Directive });
908
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: RouteMatch, isStandalone: true, selector: "ng-template[routeMatch]", inputs: { routeMatch: { classPropertyName: "routeMatch", publicName: "routeMatch", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
1416
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteMatch, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1417
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RouteMatch, isStandalone: true, selector: "ng-template[routeMatch]", inputs: { routeMatch: { classPropertyName: "routeMatch", publicName: "routeMatch", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
909
1418
  }
910
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteMatch, decorators: [{
1419
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteMatch, decorators: [{
911
1420
  type: Directive,
912
1421
  args: [{ selector: "ng-template[routeMatch]" }]
913
1422
  }], propDecorators: { routeMatch: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeMatch", required: true }] }] } });
914
1423
 
915
1424
  class RouteNotFound {
916
1425
  templateRef = inject(TemplateRef);
917
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteNotFound, deps: [], target: i0.ɵɵFactoryTarget.Directive });
918
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.8", type: RouteNotFound, isStandalone: true, selector: "ng-template[routeNotFound]", ngImport: i0 });
1426
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteNotFound, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1427
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RouteNotFound, isStandalone: true, selector: "ng-template[routeNotFound]", ngImport: i0 });
919
1428
  }
920
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteNotFound, decorators: [{
1429
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteNotFound, decorators: [{
921
1430
  type: Directive,
922
1431
  args: [{ selector: "ng-template[routeNotFound]" }]
923
1432
  }] });
924
1433
 
925
1434
  class RouteSelf {
926
1435
  templateRef = inject(TemplateRef);
927
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteSelf, deps: [], target: i0.ɵɵFactoryTarget.Directive });
928
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.8", type: RouteSelf, isStandalone: true, selector: "ng-template[routeSelf]", ngImport: i0 });
1436
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteSelf, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1437
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RouteSelf, isStandalone: true, selector: "ng-template[routeSelf]", ngImport: i0 });
929
1438
  }
930
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteSelf, decorators: [{
1439
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteSelf, decorators: [{
931
1440
  type: Directive,
932
1441
  args: [{ selector: "ng-template[routeSelf]" }]
933
1442
  }] });
934
1443
 
935
- const EMPTY_SNAPSHOT = Object.freeze({
1444
+ /**
1445
+ * Subscribe a `RouterSource<T>` to a write-callback and return a cleanup
1446
+ * function. The shape is the per-effect-run pattern that `RealLink`,
1447
+ * `RealLinkActive`, and `RouteView` all share inside their constructor
1448
+ * `effect(...)` (review-2026-05-16 §8a MEDIUM — identical 8-line block
1449
+ * repeated in 3 directives):
1450
+ *
1451
+ * 1. Read initial snapshot and apply it via `onSnapshot(snap)`.
1452
+ * 2. Subscribe — every subsequent emission calls `onSnapshot(snap)` again.
1453
+ * 3. Return a cleanup that unsubscribes and destroys the source. For
1454
+ * cached factories from `@real-router/sources` (`createActiveRouteSource`,
1455
+ * `createRouteNodeSource`, `getTransitionSource`, `getErrorSource`,
1456
+ * `createDismissableError`) `destroy()` is a no-op on the shared
1457
+ * wrapper, so this helper is safe to invoke from rapid effect re-runs
1458
+ * under signal-input changes.
1459
+ *
1460
+ * Callers pass the result to `onCleanup(...)` from Angular's `effect()`.
1461
+ *
1462
+ * @example
1463
+ * ```ts
1464
+ * effect((onCleanup) => {
1465
+ * const source = createActiveRouteSource(router, routeName(), params());
1466
+ * onCleanup(
1467
+ * subscribeSourceToSignal(source, (snap) => {
1468
+ * this.isActive.set(snap);
1469
+ * this.updateDom();
1470
+ * }),
1471
+ * );
1472
+ * });
1473
+ * ```
1474
+ */
1475
+ function subscribeSourceToSignal(source, onSnapshot) {
1476
+ onSnapshot(source.getSnapshot());
1477
+ const unsub = source.subscribe(() => {
1478
+ onSnapshot(source.getSnapshot());
1479
+ });
1480
+ return () => {
1481
+ unsub();
1482
+ source.destroy();
1483
+ };
1484
+ }
1485
+
1486
+ const EMPTY_SNAPSHOT = {
936
1487
  route: undefined,
937
1488
  previousRoute: undefined,
938
- });
1489
+ };
939
1490
  class RouteView {
940
1491
  nodeName = input("", { ...(ngDevMode ? { debugName: "nodeName" } : /* istanbul ignore next */ {}), alias: "routeNode" });
941
1492
  matches = contentChildren(RouteMatch, { ...(ngDevMode ? { debugName: "matches" } : /* istanbul ignore next */ {}), descendants: true });
942
1493
  selfs = contentChildren(RouteSelf, { ...(ngDevMode ? { debugName: "selfs" } : /* istanbul ignore next */ {}), descendants: true });
943
1494
  notFounds = contentChildren(RouteNotFound, { ...(ngDevMode ? { debugName: "notFounds" } : /* istanbul ignore next */ {}), descendants: true });
944
- activeTemplate = computed(() => {
945
- const snapshot = this.routeState();
946
- const route = snapshot.route;
1495
+ activeTemplate = computed(() => this.matchedTemplate() ?? this.fallbackTemplate(), ...(ngDevMode ? [{ debugName: "activeTemplate" }] : /* istanbul ignore next */ []));
1496
+ router = injectRouter();
1497
+ routeState = signal(EMPTY_SNAPSHOT, ...(ngDevMode ? [{ debugName: "routeState" }] : /* istanbul ignore next */ []));
1498
+ matchEntries = computed(() => {
1499
+ const nodeName = this.nodeName();
1500
+ return this.matches().map((match) => {
1501
+ const segment = match.routeMatch();
1502
+ return {
1503
+ match,
1504
+ fullSegmentName: nodeName ? `${nodeName}.${segment}` : segment,
1505
+ };
1506
+ });
1507
+ }, ...(ngDevMode ? [{ debugName: "matchEntries" }] : /* istanbul ignore next */ []));
1508
+ // The matched template (Match priority) is independent of the Self /
1509
+ // NotFound fallback chain. Splitting the two paths into separate computeds
1510
+ // localises re-runs: a change to `selfs()` / `notFounds()` no longer
1511
+ // re-evaluates the Match loop (review §8a LOW — RouteView activeTemplate
1512
+ // split).
1513
+ matchedTemplate = computed(() => {
1514
+ const route = this.routeState().route;
947
1515
  if (!route) {
948
1516
  return null;
949
1517
  }
950
1518
  const routeName = route.name;
951
- const entries = this.matchEntries();
952
- for (const { match, fullSegmentName } of entries) {
1519
+ for (const { match, fullSegmentName } of this.matchEntries()) {
953
1520
  if (startsWithSegment(routeName, fullSegmentName)) {
954
1521
  return match.templateRef;
955
1522
  }
956
1523
  }
957
- // Self has priority over NotFound. First-wins to mirror NotFound's
958
- // last-wins inversion would be inconsistent with React/Preact/Solid/Vue
959
- // adapters where Self is "first wins"; Angular's contentChildren returns
960
- // declaration order, so picking [0] gives first-wins.
1524
+ return null;
1525
+ }, ...(ngDevMode ? [{ debugName: "matchedTemplate" }] : /* istanbul ignore next */ []));
1526
+ // Fallback chain only consulted when `matchedTemplate()` returned `null`.
1527
+ // Template priority: Self NotFound. Selection rules differ on purpose:
1528
+ // - **Self uses first-wins** (`.at(0)`) for parity with React / Preact /
1529
+ // Solid / Vue, where the first matching `<Self>` token in declaration
1530
+ // order wins.
1531
+ // - **NotFound uses last-wins** (`.at(-1)`) intentionally — the fallback
1532
+ // should be the most-recently-declared template so that consumers can
1533
+ // override an inherited `<ng-template routeNotFound>` simply by
1534
+ // re-declaring it lower in the projected content.
1535
+ fallbackTemplate = computed(() => {
1536
+ const route = this.routeState().route;
1537
+ if (!route) {
1538
+ return null;
1539
+ }
1540
+ const routeName = route.name;
961
1541
  if (routeName === this.nodeName()) {
962
1542
  const first = this.selfs().at(0);
963
1543
  if (first) {
@@ -971,39 +1551,25 @@ class RouteView {
971
1551
  }
972
1552
  }
973
1553
  return null;
974
- }, ...(ngDevMode ? [{ debugName: "activeTemplate" }] : /* istanbul ignore next */ []));
975
- matchEntries = computed(() => {
976
- const nodeName = this.nodeName();
977
- return this.matches().map((match) => {
978
- const segment = match.routeMatch();
979
- return {
980
- match,
981
- fullSegmentName: nodeName ? `${nodeName}.${segment}` : segment,
982
- };
983
- });
984
- }, ...(ngDevMode ? [{ debugName: "matchEntries" }] : /* istanbul ignore next */ []));
985
- router = injectRouter();
986
- destroyRef = inject(DestroyRef);
987
- routeState = signal(EMPTY_SNAPSHOT, ...(ngDevMode ? [{ debugName: "routeState" }] : /* istanbul ignore next */ []));
988
- ngOnInit() {
989
- const source = createRouteNodeSource(this.router, this.nodeName());
990
- this.routeState.set(source.getSnapshot());
991
- const unsub = source.subscribe(() => {
992
- this.routeState.set(source.getSnapshot());
993
- });
994
- this.destroyRef.onDestroy(() => {
995
- unsub();
996
- source.destroy();
1554
+ }, ...(ngDevMode ? [{ debugName: "fallbackTemplate" }] : /* istanbul ignore next */ []));
1555
+ constructor() {
1556
+ // Reactive source-creation effect (#630 fix) — see
1557
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1558
+ effect((onCleanup) => {
1559
+ const source = createRouteNodeSource(this.router, this.nodeName());
1560
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1561
+ this.routeState.set(snap);
1562
+ }));
997
1563
  });
998
1564
  }
999
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteView, deps: [], target: i0.ɵɵFactoryTarget.Component });
1000
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: RouteView, isStandalone: true, selector: "route-view", inputs: { nodeName: { classPropertyName: "nodeName", publicName: "routeNode", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "matches", predicate: RouteMatch, descendants: true, isSignal: true }, { propertyName: "selfs", predicate: RouteSelf, descendants: true, isSignal: true }, { propertyName: "notFounds", predicate: RouteNotFound, descendants: true, isSignal: true }], ngImport: i0, template: `
1565
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteView, deps: [], target: i0.ɵɵFactoryTarget.Component });
1566
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: RouteView, isStandalone: true, selector: "route-view", inputs: { nodeName: { classPropertyName: "nodeName", publicName: "routeNode", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "matches", predicate: RouteMatch, descendants: true, isSignal: true }, { propertyName: "selfs", predicate: RouteSelf, descendants: true, isSignal: true }, { propertyName: "notFounds", predicate: RouteNotFound, descendants: true, isSignal: true }], ngImport: i0, template: `
1001
1567
  @if (activeTemplate()) {
1002
1568
  <ng-container [ngTemplateOutlet]="activeTemplate()!" />
1003
1569
  }
1004
1570
  `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1005
1571
  }
1006
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteView, decorators: [{
1572
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouteView, decorators: [{
1007
1573
  type: Component,
1008
1574
  args: [{
1009
1575
  selector: "route-view",
@@ -1014,7 +1580,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1014
1580
  `,
1015
1581
  imports: [NgTemplateOutlet],
1016
1582
  }]
1017
- }], propDecorators: { nodeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeNode", required: false }] }], matches: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteMatch), { ...{ descendants: true }, isSignal: true }] }], selfs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteSelf), { ...{ descendants: true }, isSignal: true }] }], notFounds: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteNotFound), { ...{ descendants: true }, isSignal: true }] }] } });
1583
+ }], ctorParameters: () => [], propDecorators: { nodeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeNode", required: false }] }], matches: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteMatch), { ...{ descendants: true }, isSignal: true }] }], selfs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteSelf), { ...{ descendants: true }, isSignal: true }] }], notFounds: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => RouteNotFound), { ...{ descendants: true }, isSignal: true }] }] } });
1018
1584
 
1019
1585
  class RouterErrorBoundary {
1020
1586
  errorTemplate = input(...(ngDevMode ? [undefined, { debugName: "errorTemplate" }] : /* istanbul ignore next */ []));
@@ -1032,6 +1598,12 @@ class RouterErrorBoundary {
1032
1598
  router = injectRouter();
1033
1599
  snapshot = sourceToSignal(createDismissableError(this.router));
1034
1600
  constructor() {
1601
+ // `effect()` registers itself with the current injection context's
1602
+ // `DestroyRef` and tears down automatically when the component is
1603
+ // destroyed. The earlier manual `effectRef.destroy()` wired through
1604
+ // `inject(DestroyRef).onDestroy(...)` duplicated that built-in cleanup
1605
+ // (audit §8.1 LOW — confirmed: no behavior change without the manual
1606
+ // path).
1035
1607
  effect(() => {
1036
1608
  const snap = this.snapshot();
1037
1609
  if (snap.error) {
@@ -1043,8 +1615,8 @@ class RouterErrorBoundary {
1043
1615
  }
1044
1616
  });
1045
1617
  }
1046
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouterErrorBoundary, deps: [], target: i0.ɵɵFactoryTarget.Component });
1047
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: RouterErrorBoundary, isStandalone: true, selector: "router-error-boundary", inputs: { errorTemplate: { classPropertyName: "errorTemplate", publicName: "errorTemplate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onError: "onError" }, ngImport: i0, template: `
1618
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouterErrorBoundary, deps: [], target: i0.ɵɵFactoryTarget.Component });
1619
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: RouterErrorBoundary, isStandalone: true, selector: "router-error-boundary", inputs: { errorTemplate: { classPropertyName: "errorTemplate", publicName: "errorTemplate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onError: "onError" }, ngImport: i0, template: `
1048
1620
  <ng-content />
1049
1621
  @if (errorContext() && errorTemplate()) {
1050
1622
  <ng-container
@@ -1054,7 +1626,7 @@ class RouterErrorBoundary {
1054
1626
  }
1055
1627
  `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1056
1628
  }
1057
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouterErrorBoundary, decorators: [{
1629
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RouterErrorBoundary, decorators: [{
1058
1630
  type: Component,
1059
1631
  args: [{
1060
1632
  selector: "router-error-boundary",
@@ -1078,10 +1650,10 @@ class NavigationAnnouncer {
1078
1650
  this.announcer.destroy();
1079
1651
  });
1080
1652
  }
1081
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NavigationAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1082
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.8", type: NavigationAnnouncer, isStandalone: true, selector: "navigation-announcer", ngImport: i0, template: "", isInline: true });
1653
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: NavigationAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1654
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.9", type: NavigationAnnouncer, isStandalone: true, selector: "navigation-announcer", ngImport: i0, template: "", isInline: true });
1083
1655
  }
1084
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NavigationAnnouncer, decorators: [{
1656
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: NavigationAnnouncer, decorators: [{
1085
1657
  type: Component,
1086
1658
  args: [{
1087
1659
  selector: "navigation-announcer",
@@ -1089,6 +1661,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1089
1661
  }]
1090
1662
  }], ctorParameters: () => [] });
1091
1663
 
1664
+ const NOOP_CATCH = () => { };
1092
1665
  class RealLink {
1093
1666
  routeName = input("", ...(ngDevMode ? [{ debugName: "routeName" }] : /* istanbul ignore next */ []));
1094
1667
  routeParams = input({}, ...(ngDevMode ? [{ debugName: "routeParams" }] : /* istanbul ignore next */ []));
@@ -1104,38 +1677,45 @@ class RealLink {
1104
1677
  */
1105
1678
  hash = input(undefined, ...(ngDevMode ? [{ debugName: "hash" }] : /* istanbul ignore next */ []));
1106
1679
  router = injectRouter();
1107
- destroyRef = inject(DestroyRef);
1108
1680
  anchor = inject(ElementRef)
1109
1681
  .nativeElement;
1110
1682
  isActive = signal(false, ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1683
+ // `href` is computed from signal inputs only — Angular's default Object.is
1684
+ // equality already collapses repeated `string` results, so no custom
1685
+ // comparator is required (review §8b note 3 — applies after verifying that
1686
+ // `buildHref` returns a primitive).
1111
1687
  href = computed(() => {
1112
1688
  const hashValue = this.hash();
1113
1689
  return buildHref(this.router, this.routeName(), this.routeParams(), hashValue === undefined ? undefined : { hash: hashValue });
1114
1690
  }, ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
1115
1691
  prevActiveClass = "";
1116
- ngOnInit() {
1117
- // Hash-aware active state (#532): pass `hash` so that tab-style links
1118
- // (same routeName, different `hash` input) only mark the active variant.
1119
- const hashValue = this.hash();
1120
- const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), hashValue === undefined
1121
- ? {
1122
- strict: this.activeStrict(),
1123
- ignoreQueryParams: this.ignoreQueryParams(),
1124
- }
1125
- : {
1126
- strict: this.activeStrict(),
1127
- ignoreQueryParams: this.ignoreQueryParams(),
1128
- hash: hashValue,
1129
- });
1130
- this.isActive.set(source.getSnapshot());
1131
- this.updateDom();
1132
- const unsub = source.subscribe(() => {
1133
- this.isActive.set(source.getSnapshot());
1134
- this.updateDom();
1135
- });
1136
- this.destroyRef.onDestroy(() => {
1137
- unsub();
1138
- source.destroy();
1692
+ prevHref = undefined;
1693
+ // Skip-same-value: only re-touch the DOM `class` list when the active state
1694
+ // actually flipped. Without this, every navigation that re-fires the active
1695
+ // source still issues a `classList.toggle` no-op (review §8b MEDIUM).
1696
+ prevActive = undefined;
1697
+ constructor() {
1698
+ // Reactive source-creation effect (#630 fix) — see
1699
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1700
+ // Reading signal inputs inside `effect()` re-creates the active-route
1701
+ // source whenever any input changes; `onCleanup` tears the previous
1702
+ // subscription down.
1703
+ effect((onCleanup) => {
1704
+ const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), buildActiveRouteOptions(this.activeStrict(), this.ignoreQueryParams(), this.hash()));
1705
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1706
+ // Pure-href refresh: when the active flag did not change, only the
1707
+ // href may have moved (e.g. param-only update on a parent route).
1708
+ // Skip the classList work in that branch (review §8b MEDIUM).
1709
+ if (snap === this.prevActive) {
1710
+ this.isActive.set(snap);
1711
+ this.updateHref();
1712
+ return;
1713
+ }
1714
+ this.prevActive = snap;
1715
+ this.isActive.set(snap);
1716
+ this.updateHref();
1717
+ this.updateActiveClass();
1718
+ }));
1139
1719
  });
1140
1720
  }
1141
1721
  onClick(event) {
@@ -1143,13 +1723,16 @@ class RealLink {
1143
1723
  return;
1144
1724
  }
1145
1725
  event.preventDefault();
1146
- navigateWithHash(this.router, this.routeName(), this.routeParams(), this.hash(), this.routeOptions()).catch(() => { });
1726
+ navigateWithHash(this.router, this.routeName(), this.routeParams(), this.hash(), this.routeOptions()).catch(NOOP_CATCH);
1147
1727
  }
1148
- updateDom() {
1728
+ updateHref() {
1149
1729
  const href = this.href();
1150
- if (href !== undefined) {
1730
+ if (href !== undefined && href !== this.prevHref) {
1151
1731
  this.anchor.setAttribute("href", href);
1152
1732
  }
1733
+ this.prevHref = href;
1734
+ }
1735
+ updateActiveClass() {
1153
1736
  const activeClass = this.activeClassName();
1154
1737
  if (this.prevActiveClass && this.prevActiveClass !== activeClass) {
1155
1738
  this.anchor.classList.remove(this.prevActiveClass);
@@ -1159,10 +1742,10 @@ class RealLink {
1159
1742
  }
1160
1743
  this.prevActiveClass = activeClass;
1161
1744
  }
1162
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLink, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1163
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: RealLink, isStandalone: true, selector: "a[realLink]", inputs: { routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, routeOptions: { classPropertyName: "routeOptions", publicName: "routeOptions", isSignal: true, isRequired: false, transformFunction: null }, activeClassName: { classPropertyName: "activeClassName", publicName: "activeClassName", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null }, hash: { classPropertyName: "hash", publicName: "hash", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "click": "onClick($event)" } }, ngImport: i0 });
1745
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RealLink, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1746
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RealLink, isStandalone: true, selector: "a[realLink]", inputs: { routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, routeOptions: { classPropertyName: "routeOptions", publicName: "routeOptions", isSignal: true, isRequired: false, transformFunction: null }, activeClassName: { classPropertyName: "activeClassName", publicName: "activeClassName", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null }, hash: { classPropertyName: "hash", publicName: "hash", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "click": "onClick($event)" } }, ngImport: i0 });
1164
1747
  }
1165
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLink, decorators: [{
1748
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RealLink, decorators: [{
1166
1749
  type: Directive,
1167
1750
  args: [{
1168
1751
  selector: "a[realLink]",
@@ -1170,7 +1753,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1170
1753
  "(click)": "onClick($event)",
1171
1754
  },
1172
1755
  }]
1173
- }], propDecorators: { routeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeName", required: false }] }], routeParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeParams", required: false }] }], routeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeOptions", required: false }] }], activeClassName: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeClassName", required: false }] }], activeStrict: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeStrict", required: false }] }], ignoreQueryParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreQueryParams", required: false }] }], hash: [{ type: i0.Input, args: [{ isSignal: true, alias: "hash", required: false }] }] } });
1756
+ }], ctorParameters: () => [], propDecorators: { routeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeName", required: false }] }], routeParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeParams", required: false }] }], routeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeOptions", required: false }] }], activeClassName: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeClassName", required: false }] }], activeStrict: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeStrict", required: false }] }], ignoreQueryParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreQueryParams", required: false }] }], hash: [{ type: i0.Input, args: [{ isSignal: true, alias: "hash", required: false }] }] } });
1174
1757
 
1175
1758
  class RealLinkActive {
1176
1759
  realLinkActive = input("", ...(ngDevMode ? [{ debugName: "realLinkActive" }] : /* istanbul ignore next */ []));
@@ -1179,26 +1762,29 @@ class RealLinkActive {
1179
1762
  activeStrict = input(false, ...(ngDevMode ? [{ debugName: "activeStrict" }] : /* istanbul ignore next */ []));
1180
1763
  ignoreQueryParams = input(true, ...(ngDevMode ? [{ debugName: "ignoreQueryParams" }] : /* istanbul ignore next */ []));
1181
1764
  router = injectRouter();
1182
- destroyRef = inject(DestroyRef);
1183
1765
  element = inject(ElementRef).nativeElement;
1184
1766
  isActive = signal(false, ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1767
+ // Skip-same-value: only touch `classList.toggle` when the active flag
1768
+ // actually flipped. Saves one DOM write per RealLinkActive per unrelated
1769
+ // navigation (review §8b MEDIUM).
1770
+ prevActive = undefined;
1185
1771
  constructor() {
1772
+ // One-time a11y setup — doesn't depend on signal inputs, stays in
1773
+ // constructor body. `applyLinkA11y` is idempotent so re-running would
1774
+ // be safe, but we only need it once per element.
1186
1775
  applyLinkA11y(this.element);
1187
- }
1188
- ngOnInit() {
1189
- const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), {
1190
- strict: this.activeStrict(),
1191
- ignoreQueryParams: this.ignoreQueryParams(),
1192
- });
1193
- this.isActive.set(source.getSnapshot());
1194
- this.updateClass();
1195
- const unsub = source.subscribe(() => {
1196
- this.isActive.set(source.getSnapshot());
1197
- this.updateClass();
1198
- });
1199
- this.destroyRef.onDestroy(() => {
1200
- unsub();
1201
- source.destroy();
1776
+ // Reactive source-creation effect (#630 fix) — see
1777
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1778
+ effect((onCleanup) => {
1779
+ const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), buildActiveRouteOptions(this.activeStrict(), this.ignoreQueryParams(), undefined));
1780
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1781
+ if (snap === this.prevActive) {
1782
+ return;
1783
+ }
1784
+ this.prevActive = snap;
1785
+ this.isActive.set(snap);
1786
+ this.updateClass();
1787
+ }));
1202
1788
  });
1203
1789
  }
1204
1790
  updateClass() {
@@ -1208,10 +1794,10 @@ class RealLinkActive {
1208
1794
  }
1209
1795
  this.element.classList.toggle(className, this.isActive());
1210
1796
  }
1211
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLinkActive, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1212
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: RealLinkActive, isStandalone: true, selector: "[realLinkActive]", inputs: { realLinkActive: { classPropertyName: "realLinkActive", publicName: "realLinkActive", isSignal: true, isRequired: false, transformFunction: null }, routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1797
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RealLinkActive, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1798
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RealLinkActive, isStandalone: true, selector: "[realLinkActive]", inputs: { realLinkActive: { classPropertyName: "realLinkActive", publicName: "realLinkActive", isSignal: true, isRequired: false, transformFunction: null }, routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1213
1799
  }
1214
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLinkActive, decorators: [{
1800
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RealLinkActive, decorators: [{
1215
1801
  type: Directive,
1216
1802
  args: [{ selector: "[realLinkActive]" }]
1217
1803
  }], ctorParameters: () => [], propDecorators: { realLinkActive: [{ type: i0.Input, args: [{ isSignal: true, alias: "realLinkActive", required: false }] }], routeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeName", required: false }] }], routeParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeParams", required: false }] }], activeStrict: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeStrict", required: false }] }], ignoreQueryParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreQueryParams", required: false }] }] } });
@@ -1220,5 +1806,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1220
1806
  * Generated bundle index. Do not edit.
1221
1807
  */
1222
1808
 
1223
- export { NAVIGATOR, NavigationAnnouncer, ROUTE, ROUTER, RealLink, RealLinkActive, RouteMatch, RouteNotFound, RouteSelf, RouteView, RouterErrorBoundary, injectIsActiveRoute, injectNavigator, injectRoute, injectRouteEnter, injectRouteExit, injectRouteNode, injectRouteUtils, injectRouter, injectRouterTransition, provideRealRouter, sourceToSignal };
1809
+ export { NAVIGATOR, NavigationAnnouncer, ROUTE, ROUTER, RealLink, RealLinkActive, RouteMatch, RouteNotFound, RouteSelf, RouteView, RouterErrorBoundary, injectIsActiveRoute, injectNavigator, injectRoute, injectRouteEnter, injectRouteExit, injectRouteNode, injectRouteUtils, injectRouter, injectRouterTransition, provideRealRouter, provideRealRouterFactory, sourceToSignal };
1224
1810
  //# sourceMappingURL=real-router-angular.mjs.map