@real-router/angular 0.8.1 → 0.10.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 +184 -5
  2. package/dist/README.md +184 -5
  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 +773 -180
  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 +17 -10
  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 +179 -23
  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,25 +379,36 @@ 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
- // Single rAF so DOM is committed before we read anchors / write scroll.
298
- // Guard against destroy() racing with the callback.
299
387
  requestAnimationFrame(() => {
300
388
  if (destroyed) {
301
389
  return;
302
390
  }
303
- if (mode === "top" || !nav) {
391
+ if (mode === "top") {
304
392
  scrollToHashOrTop(route);
305
393
  return;
306
394
  }
307
- if (nav.navigationType === "replace") {
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).
404
+ if (route.transition.reload || nav?.navigationType === "reload") {
405
+ const key = safeKeyOf(route);
406
+ writePos(key === null ? 0 : (loadStore()[key] ?? 0));
308
407
  return;
309
408
  }
310
- if (nav.direction === "back" ||
311
- nav.navigationType === "traverse" ||
312
- nav.navigationType === "reload") {
313
- writePos(loadStore()[keyOf(route)] ?? 0);
409
+ if (nav?.direction === "back" || nav?.navigationType === "traverse") {
410
+ const key = safeKeyOf(route);
411
+ writePos(key === null ? 0 : (loadStore()[key] ?? 0));
314
412
  return;
315
413
  }
316
414
  scrollToHashOrTop(route);
@@ -319,7 +417,10 @@ function createScrollRestoration(router, options) {
319
417
  const onPageHide = () => {
320
418
  const current = router.getState();
321
419
  if (current) {
322
- putPos(keyOf(current), readPos());
420
+ const key = safeKeyOf(current);
421
+ if (key !== null) {
422
+ putPos(key, readPos());
423
+ }
323
424
  }
324
425
  };
325
426
  globalThis.addEventListener("pagehide", onPageHide);
@@ -340,15 +441,103 @@ function createScrollRestoration(router, options) {
340
441
  },
341
442
  };
342
443
  }
444
+ /**
445
+ * Internal cache-key builder for scroll-position storage.
446
+ *
447
+ * **Exported for testing only — not part of the public API** (intentionally
448
+ * excluded from `index.ts` barrel). Adapter property tests import it via
449
+ * the direct path to lock the `(name, canonicalJson(params))` key shape
450
+ * as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
451
+ * key format would silently lose scroll positions across an upgrade —
452
+ * the test set is the contract.
453
+ *
454
+ * ## Identity-based memoization (audit-2026-05-17 §8b #2)
455
+ *
456
+ * `State` objects emitted by core are frozen per-navigation: their
457
+ * `name` / `params` are immutable for the lifetime of the snapshot, and
458
+ * any change produces a new `State` reference. A `WeakMap<State, string>`
459
+ * therefore safely caches the canonicalised key by identity — repeat
460
+ * `keyOf(state)` calls on the same snapshot (typical on
461
+ * back/forward/traverse where the same prior `State` is re-emitted)
462
+ * skip the recursive `canonicalJson` pass entirely.
463
+ *
464
+ * The cache key is the `State` reference, so entries auto-release when
465
+ * the snapshot is GC'd — no eviction needed.
466
+ */
467
+ const KEY_CACHE = new WeakMap();
343
468
  function keyOf(state) {
344
- return `${state.name}:${canonicalJson(state.params)}`;
469
+ const cached = KEY_CACHE.get(state);
470
+ if (cached !== undefined) {
471
+ return cached;
472
+ }
473
+ const key = `${state.name}:${canonicalJson(state.params)}`;
474
+ KEY_CACHE.set(state, key);
475
+ return key;
345
476
  }
477
+ /**
478
+ * Stable JSON serializer with sorted object keys.
479
+ *
480
+ * **Exported for testing only — not part of the public API** (intentionally
481
+ * excluded from `index.ts` barrel). Adapter property tests import it via
482
+ * the direct path to lock the key-order-insensitive property
483
+ * (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
484
+ *
485
+ * ## Divergence from `@real-router/sources/canonicalJson` — by design
486
+ *
487
+ * Two independent implementations live in the monorepo:
488
+ *
489
+ * - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
490
+ * cache key builder. Uses `localeCompare` and a plain-object accumulator;
491
+ * tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
492
+ * replacer happens to sort them; relies on `JSON.stringify`'s native cycle
493
+ * detector. Designed to be cheap on the navigation hot path. The
494
+ * surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
495
+ * cyclic) and skips the offending capture/restore.
496
+ *
497
+ * - **`@real-router/sources/canonicalJson`** — sources cache key builder.
498
+ * Uses byte-order compare (`< / >`) for locale-independence, a
499
+ * `Object.create(null)` accumulator to prevent prototype pollution, and a
500
+ * bespoke path-based cycle detector (the native one cannot see the cloned
501
+ * graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
502
+ * back to a non-cached source.
503
+ *
504
+ * **They are intentionally NOT interchangeable.** Aligning them would either
505
+ * regress scroll-restore performance (byte-order + recursive clone is heavier
506
+ * per call) or weaken the sources cache (locale dependence breaks
507
+ * deterministic cache keys across machines). No cross-package equivalence
508
+ * test exists or should be added; the relationship is "different invariants,
509
+ * different costs, different consumers." Audit-2 / audit-2026-05-17 §2
510
+ * documents the choice.
511
+ */
346
512
  function canonicalJson(value) {
347
513
  return JSON.stringify(value, canonicalReplacer);
348
514
  }
349
515
  function canonicalReplacer(_key, val) {
516
+ // audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
517
+ // `JSON.stringify` silently drops function and symbol values from
518
+ // object output. Two routes that differ ONLY in a function/Symbol
519
+ // value would canonicalize to the same string → silent scroll-cache
520
+ // key collision (positions clobber each other). Replacing the value
521
+ // with a sentinel string breaks the collision while keeping the
522
+ // canonical form deterministic. The sentinels are intentionally
523
+ // ASCII-only and lexically distinct from valid JSON-stringified
524
+ // values; consumers will see `"<fn>"` / `"<sym>"` if they ever
525
+ // round-trip the cache key, signalling the substitution clearly.
526
+ if (typeof val === "function") {
527
+ return "<fn>";
528
+ }
529
+ if (typeof val === "symbol") {
530
+ return "<sym>";
531
+ }
350
532
  if (val !== null && typeof val === "object" && !Array.isArray(val)) {
351
- const sorted = {};
533
+ // Null-prototype accumulator: a plain `{}` would interpret
534
+ // `sorted["__proto__"] = x` as a prototype assignment (silently dropped
535
+ // from JSON.stringify output AND a prototype-pollution vector). Mirrors
536
+ // the same guard in `@real-router/sources/canonicalJson`. The two
537
+ // implementations are still intentionally divergent (see the doc-block
538
+ // on [[canonicalJson]] above), but prototype-safety is non-negotiable
539
+ // on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
540
+ const sorted = Object.create(null);
352
541
  // eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
353
542
  const keys = Object.keys(val).sort((left, right) => left.localeCompare(right));
354
543
  for (const key of keys) {
@@ -485,14 +674,39 @@ function shouldNavigate(evt) {
485
674
  !evt.ctrlKey &&
486
675
  !evt.shiftKey);
487
676
  }
677
+ // Matches a single percent-escape triple (`%` + two hex digits). Used as
678
+ // the "already-encoded" probe in `encodeFragmentInline` below — see the
679
+ // idempotency rationale there.
680
+ const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
488
681
  /**
489
682
  * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
490
683
  * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
491
684
  * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
492
685
  * `shared/browser-env/url-context.ts` — duplicated here because the
493
686
  * shared/dom-utils symlink graph does not reach shared/browser-env.
687
+ *
688
+ * **Idempotency for pre-encoded input (audit-2026-05-17 §5 MEDIUM E.1).**
689
+ * The doc-comment on `<Link hash>` says the value is a "decoded fragment
690
+ * without leading #". But realistic consumers copy hashes out of
691
+ * `location.hash` (which is percent-encoded) and pass them back, so the
692
+ * naive `encodeURI("%20")` would double-encode into `"%2520"` and break
693
+ * anchor lookup. We detect a percent-escape triple in the input and, if
694
+ * present, decode + re-encode for idempotency. Malformed `%XX` (e.g.
695
+ * `"%2"` or `"%ZZ"`) makes `decodeURIComponent` throw — in that case we
696
+ * fall through to plain `encodeURI`, which never throws.
494
697
  */
495
698
  function encodeFragmentInline(decoded) {
699
+ if (PERCENT_ESCAPE_PROBE.test(decoded)) {
700
+ try {
701
+ const roundtrip = decodeURIComponent(decoded);
702
+ return encodeURI(roundtrip).replaceAll("#", "%23");
703
+ }
704
+ catch {
705
+ // Malformed `%XX` — fall through to the plain encoding path.
706
+ // encodeURI does not throw on malformed escapes; it treats the
707
+ // `%` as a literal and percent-encodes it (`%2` → `%252`).
708
+ }
709
+ }
496
710
  return encodeURI(decoded).replaceAll("#", "%23");
497
711
  }
498
712
  /**
@@ -518,11 +732,28 @@ function buildHref(router, routeName, routeParams, options) {
518
732
  const buildUrl = router.buildUrl;
519
733
  if (buildUrl) {
520
734
  const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
521
- if (url !== undefined) {
735
+ // Accept only non-empty strings. The BuildUrlFn type contract is
736
+ // `string | undefined`, but defensive against:
737
+ // - `""` (empty string) → would render `<a href="">`, which resolves
738
+ // to the current page URL → silent self-navigation on click.
739
+ // - `null` (type-contract violation) → would render `<a href={null}>`,
740
+ // stringified to `"null"` in some renderers.
741
+ // Either case falls through to the `router.buildPath` fallback below.
742
+ if (typeof url === "string" && url.length > 0) {
522
743
  return url;
523
744
  }
524
745
  }
525
746
  const path = router.buildPath(routeName, routeParams);
747
+ // Symmetric to the buildUrl guard above (#S1 audit, Invariant 12).
748
+ // `router.buildPath` is typed `string`, but defends against:
749
+ // - `""` (empty string) — would render `<a href="">`, which resolves
750
+ // to the current page URL → silent self-navigation on click.
751
+ // - non-string type-contract violations from custom path-matchers.
752
+ // Both yield `undefined` (renderer drops the attribute) with a warning.
753
+ if (typeof path !== "string" || path.length === 0) {
754
+ console.error(`[real-router] Route "${routeName}" yielded an empty path. The element will render without an href attribute.`);
755
+ return undefined;
756
+ }
526
757
  return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
527
758
  }
528
759
  catch {
@@ -548,8 +779,25 @@ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
548
779
  }
549
780
  return router.navigate(routeName, routeParams, opts);
550
781
  }
782
+ // Match-any-whitespace regex shared across calls. RegExp literals at
783
+ // call-site recompile in some engines; lifting it avoids that microcost
784
+ // for the slow-path branch.
785
+ const WHITESPACE_PROBE = /\s/;
786
+ const WHITESPACE_SPLIT = /\S+/g;
551
787
  function parseTokens(value) {
552
- return value ? (value.match(/\S+/g) ?? []) : [];
788
+ if (!value) {
789
+ return [];
790
+ }
791
+ // Hot-path fast-path (audit-2026-05-17 §8b #1): >99% of active-class
792
+ // inputs at `<Link>` emit are single-token strings like `"active"` or
793
+ // `"is-current"` — no whitespace, no leading/trailing pad. Skip the
794
+ // regex match and Array result allocation: a literal `[value]` works
795
+ // because the slow-path `match(/\S+/g)` would return exactly `[value]`
796
+ // for the same input. PBT lock: linkUtils.properties.ts Invariant 13.
797
+ if (!WHITESPACE_PROBE.test(value)) {
798
+ return [value];
799
+ }
800
+ return value.match(WHITESPACE_SPLIT) ?? [];
553
801
  }
554
802
  function buildActiveClassName(isActive, activeClassName, baseClassName) {
555
803
  if (isActive && activeClassName) {
@@ -572,6 +820,29 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
572
820
  }
573
821
  return baseClassName ?? undefined;
574
822
  }
823
+ /**
824
+ * One-level structural equality using `Object.is` per key.
825
+ *
826
+ * **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
827
+ * Implementation walks `Object.keys()` which by spec returns only
828
+ * enumerable own STRING keys. Symbol-keyed properties — created via
829
+ * `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
830
+ * NOT compared. Two records that differ only in a Symbol-keyed value
831
+ * will compare as equal.
832
+ *
833
+ * This is intentional: route params and Link options are documented as
834
+ * string-keyed primitives (string | number | boolean) — Symbol-keyed
835
+ * metadata (e.g. brand markers, private state) doesn't belong in a
836
+ * cache-key comparison. Switching to `Reflect.ownKeys()` would extend
837
+ * the contract to symbols at the cost of one extra allocation per call
838
+ * (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
839
+ * consumer relies on symbol-keyed metadata for navigation
840
+ * disambiguation, they should encode it into a string key instead.
841
+ *
842
+ * Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
843
+ * both the string-keys-only semantics and the `hasOwnProperty` guard
844
+ * below.
845
+ */
575
846
  function shallowEqual(prev, next) {
576
847
  if (Object.is(prev, next)) {
577
848
  return true;
@@ -586,7 +857,11 @@ function shallowEqual(prev, next) {
586
857
  const prevRecord = prev;
587
858
  const nextRecord = next;
588
859
  for (const key of prevKeys) {
589
- if (!Object.is(prevRecord[key], nextRecord[key])) {
860
+ // hasOwnProperty guard: without it, a key missing in `next` reads as
861
+ // `undefined` and falsely matches `prev[key] === undefined`. Same shape
862
+ // as React's shallowEqual (packages/shared/shallowEqual.js).
863
+ if (!Object.prototype.hasOwnProperty.call(next, key) ||
864
+ !Object.is(prevRecord[key], nextRecord[key])) {
590
865
  return false;
591
866
  }
592
867
  }
@@ -596,8 +871,23 @@ function applyLinkA11y(element) {
596
871
  if (!element) {
597
872
  return;
598
873
  }
599
- if (element instanceof HTMLAnchorElement ||
600
- element instanceof HTMLButtonElement) {
874
+ // Cross-realm safety (audit-2026-05-17 §5 HIGH #4):
875
+ // `instanceof HTMLAnchorElement` compares against the constructor from
876
+ // the CURRENT realm. An element created in a different window (iframe
877
+ // contentDocument, micro-frontend, embedded widget) fails the check
878
+ // even when it IS a real anchor — the helper would then inject
879
+ // role="link" + tabindex="0" on top of native anchor semantics,
880
+ // breaking screen reader output ("link link") and focus order.
881
+ //
882
+ // tagName is realm-agnostic and is uppercase for HTML-namespaced
883
+ // elements in any document. SVG `<a>` has lowercase tagName plus a
884
+ // different prototype (SVGAElement) — skipping it here is wrong by
885
+ // accident: SVG anchors don't have keyboard activation semantics the
886
+ // helper would add. But they also don't reach this helper in
887
+ // practice (router Link components emit HTML anchors). Lock the
888
+ // uppercase compare to keep the contract narrow.
889
+ const tag = element.tagName;
890
+ if (tag === "A" || tag === "BUTTON") {
601
891
  return;
602
892
  }
603
893
  if (!element.hasAttribute("role")) {
@@ -608,6 +898,65 @@ function applyLinkA11y(element) {
608
898
  }
609
899
  }
610
900
 
901
+ /**
902
+ * Shared installation helpers for `provideRealRouter` and
903
+ * `provideRealRouterFactory`. Must be called inside the body of a
904
+ * `provideEnvironmentInitializer(() => { ... })` callback so the active
905
+ * injection context resolves `ROUTER`, `ApplicationRef`, and `DestroyRef`.
906
+ *
907
+ * Closes review-2026-05-10 §8.1 MED — eliminates duplicate wiring between
908
+ * `providers.ts` and `providersFactory.ts` (high drift risk noted in the
909
+ * audit: the comment blocks were identical down to the punctuation).
910
+ */
911
+ function installScrollRestoration(options) {
912
+ const router = inject(ROUTER);
913
+ const sr = createScrollRestoration(router, options);
914
+ inject(DestroyRef).onDestroy(() => {
915
+ sr.destroy();
916
+ });
917
+ }
918
+ function installViewTransitions() {
919
+ const router = inject(ROUTER);
920
+ // Feature-detect `document.startViewTransition` once at install time. The
921
+ // `appRef.tick()` listener exists ONLY to feed Angular's zoneless CD into
922
+ // the VT utility's `setTimeout(0)`-driven snapshot capture (see comment
923
+ // below). When `startViewTransition` is unavailable (Firefox as of 2026-04,
924
+ // SSR, older browsers), `createViewTransitions` short-circuits to its
925
+ // frozen NOOP_INSTANCE — no leave subscriber registered, no
926
+ // `setTimeout(0)` invariant to satisfy. Installing the per-navigation
927
+ // tick listener anyway would force a synchronous CD pass on every
928
+ // navigation with zero benefit, doubling CD work in zoneless apps.
929
+ // Closes review-2026-05-10 §8.2 MED (view-transitions hot path).
930
+ const vtAvailable = typeof document !== "undefined" &&
931
+ typeof document.startViewTransition === "function";
932
+ let offTick;
933
+ if (vtAvailable) {
934
+ // Force synchronous change detection on every transition success BEFORE
935
+ // the VT utility resolves its deferred. The utility uses `setTimeout(0)`
936
+ // to release the new-snapshot capture, which is load-bearing because
937
+ // Chromium blocks rAF callbacks while VT sits in the
938
+ // `update-callback-called` phase. Angular's zoneless CD is rAF-driven by
939
+ // default — without this synchronous tick the new DOM is not committed
940
+ // when the browser captures the new snapshot, so old and new snapshots
941
+ // end up identical and animations finish in ~0 ms with no visible work
942
+ // (the inner-route `products.list ↔ products.detail` morph in the
943
+ // example app was the canary).
944
+ //
945
+ // Subscribers fire in registration order; this one runs BEFORE
946
+ // `createViewTransitions` registers its own subscriber, guaranteeing CD
947
+ // completes first.
948
+ const appRef = inject(ApplicationRef);
949
+ offTick = router.subscribe(() => {
950
+ appRef.tick();
951
+ });
952
+ }
953
+ const vt = createViewTransitions(router);
954
+ inject(DestroyRef).onDestroy(() => {
955
+ offTick?.();
956
+ vt.destroy();
957
+ });
958
+ }
959
+
611
960
  /** Must be called within an injection context (constructor, field initializer, runInInjectionContext). */
612
961
  function sourceToSignal(source) {
613
962
  const sig = signal(source.getSnapshot(), ...(ngDevMode ? [{ debugName: "sig" }] : /* istanbul ignore next */ []));
@@ -616,8 +965,17 @@ function sourceToSignal(source) {
616
965
  sig.set(source.getSnapshot());
617
966
  });
618
967
  destroyRef.onDestroy(() => {
619
- unsubscribe();
620
- source.destroy();
968
+ // `try/finally` guarantees `source.destroy()` runs even if `unsubscribe`
969
+ // throws. Cached sources from `@real-router/sources` keep `destroy()` as
970
+ // a no-op (so they survive multi-consumer teardown), but non-cached
971
+ // sources rely on this call to release their router subscription —
972
+ // skipping it on an unsubscribe throw would leak the listener.
973
+ try {
974
+ unsubscribe();
975
+ }
976
+ finally {
977
+ source.destroy();
978
+ }
621
979
  });
622
980
  return sig.asReadonly();
623
981
  }
@@ -627,6 +985,11 @@ const NAVIGATOR = new InjectionToken("NAVIGATOR");
627
985
  const ROUTE = new InjectionToken("ROUTE");
628
986
  function provideRealRouter(router, options) {
629
987
  const navigator = getNavigator(router);
988
+ // `Parameters<typeof makeEnvironmentProviders>[0]` is the actual union
989
+ // `(Provider | EnvironmentProviders | EnvironmentProviders[])[]` —
990
+ // `provideEnvironmentInitializer()` returns `EnvironmentProviders`, so the
991
+ // narrower `Provider[]` would force a cast at every push (review §8a — the
992
+ // proposed Provider[] swap was retracted after discovering this).
630
993
  const providers = [
631
994
  { provide: ROUTER, useValue: router },
632
995
  { provide: NAVIGATOR, useValue: navigator },
@@ -641,66 +1004,213 @@ function provideRealRouter(router, options) {
641
1004
  if (options?.scrollRestoration) {
642
1005
  const scrollOpts = options.scrollRestoration;
643
1006
  providers.push(provideEnvironmentInitializer(() => {
644
- const sr = createScrollRestoration(router, scrollOpts);
645
- inject(DestroyRef).onDestroy(() => {
646
- sr.destroy();
647
- });
1007
+ installScrollRestoration(scrollOpts);
648
1008
  }));
649
1009
  }
650
1010
  if (options?.viewTransitions === true) {
1011
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
1012
+ }
1013
+ return makeEnvironmentProviders(providers);
1014
+ }
1015
+
1016
+ /**
1017
+ * `TransferState` key carrying the SSR-resolved router state from server to
1018
+ * client as an XSS-safe JSON string (produced by `serializeRouterState`).
1019
+ * Populated server-side by the `provideAppInitializer` callback after
1020
+ * `router.start()` resolves; consumed client-side after hydration. Mirrors the
1021
+ * `<script>window.__SSR_STATE__ = …</script>` pattern used by every other
1022
+ * adapter — Angular's idiomatic transport is `TransferState` (#599).
1023
+ *
1024
+ * Stored as `string`: `serializeRouterState(state)` already produces JSON;
1025
+ * `hydrateRouter(router, json)` accepts a JSON string and parses it once
1026
+ * internally. Storing the parsed object would force a double round-trip
1027
+ * (TransferState wraps every value in JSON for transport).
1028
+ *
1029
+ * Internal implementation detail. Not re-exported.
1030
+ */
1031
+ const ROUTER_STATE_KEY = makeStateKey("@real-router/angular:ssrState");
1032
+ /**
1033
+ * `provideRealRouterFactory` — environment providers for SSR / SSG scenarios.
1034
+ *
1035
+ * Unlike `provideRealRouter(router)` (single instance via `useValue`), this
1036
+ * factory uses `useFactory` to produce a per-request router clone:
1037
+ *
1038
+ * 1. Reads Angular's `REQUEST` token (`{ optional: true }`).
1039
+ * 2. Calls `cloneRouter(baseRouter, deps?.(request))` to create a request-scoped clone.
1040
+ * 3. Applies plugins (`plugins` array or `plugins(request)` factory).
1041
+ * 4. Registers `provideAppInitializer` that calls `await router.start(url)`.
1042
+ * 5. Schedules `router.dispose()` via `DestroyRef.onDestroy` — the request
1043
+ * Injector is destroyed after the response is sent, releasing all
1044
+ * subscriptions and plugins.
1045
+ *
1046
+ * Use cases:
1047
+ * - Angular SSR with `@angular/ssr` (`outputMode: "server"`).
1048
+ * - SSG build-time render via `renderApplication` + `platformProviders` `REQUEST` mock.
1049
+ * - Multi-tenant request-scoped routing.
1050
+ *
1051
+ * Existing single-instance scenarios (SPA, SSG client after hydration) continue
1052
+ * to use `provideRealRouter(router)` — both APIs ship in parallel.
1053
+ *
1054
+ * @param options - Factory configuration — see `RealRouterFactoryOptions`.
1055
+ * @returns `EnvironmentProviders` to spread into `ApplicationConfig.providers`.
1056
+ */
1057
+ function provideRealRouterFactory(options) {
1058
+ const { baseRouter, plugins, deps, scrollRestoration, viewTransitions } = options;
1059
+ const providers = [
1060
+ {
1061
+ provide: ROUTER,
1062
+ useFactory: () => {
1063
+ const request = inject(REQUEST, { optional: true });
1064
+ const requestDeps = deps?.(request);
1065
+ const router = cloneRouter(baseRouter, requestDeps);
1066
+ const pluginList = typeof plugins === "function" ? plugins(request) : plugins;
1067
+ if (pluginList && pluginList.length > 0) {
1068
+ // Variadic — `usePlugin` accepts `(PluginFactory<D> | false | null | undefined)[]`.
1069
+ router.usePlugin(...pluginList);
1070
+ }
1071
+ // Per-request cleanup. The application Injector is destroyed:
1072
+ // - On server: after `writeResponseToNodeResponse` finishes the response
1073
+ // (request scope ends).
1074
+ // - On client: at `ApplicationRef.destroy` (rare in SPA, common in TestBed).
1075
+ // - In SSG build: after each `renderApplication` resolves.
1076
+ inject(DestroyRef).onDestroy(() => {
1077
+ router.dispose();
1078
+ });
1079
+ return router;
1080
+ },
1081
+ },
1082
+ {
1083
+ provide: NAVIGATOR,
1084
+ useFactory: () => getNavigator(inject(ROUTER)),
1085
+ },
1086
+ {
1087
+ provide: ROUTE,
1088
+ useFactory: () => {
1089
+ const router = inject(ROUTER);
1090
+ return {
1091
+ routeState: sourceToSignal(createRouteSource(router)),
1092
+ navigator: inject(NAVIGATOR),
1093
+ };
1094
+ },
1095
+ },
1096
+ // Async bootstrap — runs before the first component renders. Three
1097
+ // branches based on TransferState population:
1098
+ //
1099
+ // 1. **Client after hydration** — server populated TransferState with
1100
+ // the SSR-resolved router state. Consume it via `hydrateRouter`,
1101
+ // which deposits the parsed state into the one-shot
1102
+ // `RouterInternals.hydrationState` scratchpad before invoking
1103
+ // `router.start(state.path)`. SSR loader plugins
1104
+ // (`@real-router/ssr-data-plugin`, `@real-router/rsc-server-plugin`)
1105
+ // read the scratchpad and skip the loader on first paint — parity
1106
+ // with the other 5 adapters that consume `<script>__SSR_STATE__</script>` (#596, #599).
1107
+ //
1108
+ // 2. **Server / SSG** — TransferState empty; run the regular
1109
+ // `router.start(path)`. After it resolves, write the serialized
1110
+ // state back into TransferState so the matching client run lands
1111
+ // in branch 1. Angular's `TransferState` infrastructure
1112
+ // (provided by `provideClientHydration()`) carries this blob to
1113
+ // the client as a `<script id="ng-state">` payload.
1114
+ //
1115
+ // 3. **Pure CSR** — TransferState empty (never populated by a server
1116
+ // pass), and `inject(REQUEST, { optional: true })` returns null.
1117
+ // Falls into the same `router.start(path)` branch as server-side
1118
+ // but skips the TransferState write (no client to hand off to).
1119
+ //
1120
+ // Errors propagate (Option A from RFC §10): the bootstrap fails and the
1121
+ // server returns 500. Custom error pages should be wired via
1122
+ // `RouterErrorBoundary` on subsequent renders.
1123
+ provideAppInitializer(async () => {
1124
+ const router = inject(ROUTER);
1125
+ const request = inject(REQUEST, { optional: true });
1126
+ const transferState = inject(TransferState);
1127
+ const ssrJson = transferState.get(ROUTER_STATE_KEY, null);
1128
+ if (ssrJson !== null) {
1129
+ // Branch 1: client after hydration — reuse server-resolved state.
1130
+ await hydrateRouter(router, ssrJson);
1131
+ // One-shot semantic, parity with `delete window.__SSR_STATE__`.
1132
+ transferState.remove(ROUTER_STATE_KEY);
1133
+ return;
1134
+ }
1135
+ // Branches 2 & 3: regular start.
1136
+ // Browser-plugin's `start` interceptor (when registered) wraps this call
1137
+ // with location-derived path. We always pass an explicit string — the
1138
+ // interceptor uses the explicit value because `next(path ?? location)`
1139
+ // short-circuits when `path` is non-nullish.
1140
+ const path = deriveStartPath(request);
1141
+ const state = await router.start(path);
1142
+ if (request !== null) {
1143
+ // Branch 2: running inside `@angular/ssr`'s request handler — write
1144
+ // serialized state to TransferState so the matching client run can
1145
+ // skip the loader on first paint.
1146
+ transferState.set(ROUTER_STATE_KEY, serializeRouterState(state));
1147
+ }
1148
+ }),
1149
+ ];
1150
+ if (scrollRestoration) {
651
1151
  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
- });
1152
+ installScrollRestoration(scrollRestoration);
674
1153
  }));
675
1154
  }
1155
+ if (viewTransitions === true) {
1156
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
1157
+ }
676
1158
  return makeEnvironmentProviders(providers);
677
1159
  }
1160
+ /**
1161
+ * Derive the path passed to `router.start(path)`:
1162
+ * - Server / SSG: `request.url` → pathname + search.
1163
+ * - Client: `window.location` if available.
1164
+ * - Fallback: `"/"` (only reachable in synthetic non-browser non-SSR setups).
1165
+ */
1166
+ function deriveStartPath(request) {
1167
+ if (request) {
1168
+ const url = new URL(request.url);
1169
+ return url.pathname + url.search;
1170
+ }
1171
+ if (typeof globalThis.window !== "undefined") {
1172
+ return globalThis.location.pathname + globalThis.location.search;
1173
+ }
1174
+ return "/";
1175
+ }
678
1176
 
679
1177
  function injectOrThrow(token, fnName) {
680
1178
  const value = inject(token, { optional: true });
681
- if (!value) {
1179
+ // Explicit null / undefined check — falsy guard would misfire on
1180
+ // legitimately falsy values (`0`, `""`, `false`) if the token were ever
1181
+ // typed for primitives. Today all our tokens hold object instances, but
1182
+ // pinning the check keeps the function safe for future typing changes.
1183
+ if (value === null || value === undefined) {
682
1184
  throw new Error(`${fnName} must be used within a provideRealRouter context`);
683
1185
  }
684
1186
  return value;
685
1187
  }
686
1188
 
687
1189
  function injectRouter() {
1190
+ assertInInjectionContext(injectRouter);
688
1191
  return injectOrThrow(ROUTER, "injectRouter");
689
1192
  }
690
1193
 
691
1194
  function injectNavigator() {
1195
+ assertInInjectionContext(injectNavigator);
692
1196
  return injectOrThrow(NAVIGATOR, "injectNavigator");
693
1197
  }
694
1198
 
695
1199
  function injectRoute() {
1200
+ assertInInjectionContext(injectRoute);
696
1201
  const signals = injectOrThrow(ROUTE, "injectRoute");
697
- if (!signals.routeState().route) {
1202
+ // Read the snapshot once: the signal is reactive, but the throw-guard
1203
+ // and any future use of the snapshot within this call should observe the
1204
+ // SAME value to avoid races.
1205
+ const snapshot = signals.routeState();
1206
+ if (!snapshot.route) {
698
1207
  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
1208
  }
700
1209
  return signals;
701
1210
  }
702
1211
 
703
1212
  function injectRouteNode(nodeName) {
1213
+ assertInInjectionContext(injectRouteNode);
704
1214
  const router = injectRouter();
705
1215
  const navigator = getNavigator(router);
706
1216
  const source = createRouteNodeSource(router, nodeName);
@@ -709,26 +1219,37 @@ function injectRouteNode(nodeName) {
709
1219
  }
710
1220
 
711
1221
  function injectRouteUtils() {
1222
+ assertInInjectionContext(injectRouteUtils);
712
1223
  const router = injectRouter();
713
1224
  return getRouteUtils(getPluginApi(router).getTree());
714
1225
  }
715
1226
 
716
1227
  function injectRouterTransition() {
1228
+ assertInInjectionContext(injectRouterTransition);
717
1229
  const router = injectRouter();
718
1230
  const source = getTransitionSource(router);
719
1231
  return sourceToSignal(source);
720
1232
  }
721
1233
 
1234
+ /**
1235
+ * Build the `options` literal for `createActiveRouteSource` while honoring
1236
+ * `exactOptionalPropertyTypes` — the type forbids passing `{ hash: undefined }`
1237
+ * literally (#532), so callers must conditionally include the `hash` key only
1238
+ * when a value was provided.
1239
+ *
1240
+ * Used by `RealLink`, `RealLinkActive`, and `injectIsActiveRoute` — extracted
1241
+ * from three identical ternaries (review-2026-05-16 §8a LOW).
1242
+ */
1243
+ function buildActiveRouteOptions(strict, ignoreQueryParams, hash) {
1244
+ return hash === undefined
1245
+ ? { strict, ignoreQueryParams }
1246
+ : { strict, ignoreQueryParams, hash };
1247
+ }
1248
+
722
1249
  function injectIsActiveRoute(routeName, params, options) {
1250
+ assertInInjectionContext(injectIsActiveRoute);
723
1251
  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 });
1252
+ const source = createActiveRouteSource(router, routeName, params, buildActiveRouteOptions(options?.strict ?? false, options?.ignoreQueryParams ?? true, options?.hash));
732
1253
  return sourceToSignal(source);
733
1254
  }
734
1255
 
@@ -870,7 +1391,6 @@ function injectRouteEnter(handler, options) {
870
1391
  assertInInjectionContext(injectRouteEnter);
871
1392
  const { routeState } = injectRoute();
872
1393
  const skipSameRoute = options?.skipSameRoute ?? true;
873
- let lastHandledRoute = null;
874
1394
  effect(() => {
875
1395
  const { route, previousRoute } = routeState();
876
1396
  // Early-exit guards, top-down:
@@ -880,23 +1400,19 @@ function injectRouteEnter(handler, options) {
880
1400
  // - **Skip-same-route**: query-only navigations have
881
1401
  // `transition.from === route.name`. Opt-out via
882
1402
  // `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
1403
  if (!route.transition.from) {
889
1404
  return;
890
1405
  }
891
1406
  if (skipSameRoute && route.transition.from === route.name) {
892
1407
  return;
893
1408
  }
894
- /* v8 ignore start */
895
- if (lastHandledRoute === route || !previousRoute) {
1409
+ // `previousRoute` is guaranteed populated whenever `route.transition.from`
1410
+ // is set core writes them together. The dead-code throw-guard that used
1411
+ // to live here (review §8a LOW) is removed; the narrowing below is the
1412
+ // type-safe equivalent and avoids the no-non-null-assertion lint.
1413
+ if (!previousRoute) {
896
1414
  return;
897
1415
  }
898
- /* v8 ignore stop */
899
- lastHandledRoute = route;
900
1416
  handler({ route, previousRoute });
901
1417
  });
902
1418
  }
@@ -904,60 +1420,131 @@ function injectRouteEnter(handler, options) {
904
1420
  class RouteMatch {
905
1421
  routeMatch = input.required(...(ngDevMode ? [{ debugName: "routeMatch" }] : /* istanbul ignore next */ []));
906
1422
  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 });
1423
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteMatch, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1424
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.13", type: RouteMatch, isStandalone: true, selector: "ng-template[routeMatch]", inputs: { routeMatch: { classPropertyName: "routeMatch", publicName: "routeMatch", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
909
1425
  }
910
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteMatch, decorators: [{
1426
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteMatch, decorators: [{
911
1427
  type: Directive,
912
1428
  args: [{ selector: "ng-template[routeMatch]" }]
913
1429
  }], propDecorators: { routeMatch: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeMatch", required: true }] }] } });
914
1430
 
915
1431
  class RouteNotFound {
916
1432
  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 });
1433
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteNotFound, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1434
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.13", type: RouteNotFound, isStandalone: true, selector: "ng-template[routeNotFound]", ngImport: i0 });
919
1435
  }
920
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteNotFound, decorators: [{
1436
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteNotFound, decorators: [{
921
1437
  type: Directive,
922
1438
  args: [{ selector: "ng-template[routeNotFound]" }]
923
1439
  }] });
924
1440
 
925
1441
  class RouteSelf {
926
1442
  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 });
1443
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteSelf, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1444
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.13", type: RouteSelf, isStandalone: true, selector: "ng-template[routeSelf]", ngImport: i0 });
929
1445
  }
930
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteSelf, decorators: [{
1446
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteSelf, decorators: [{
931
1447
  type: Directive,
932
1448
  args: [{ selector: "ng-template[routeSelf]" }]
933
1449
  }] });
934
1450
 
935
- const EMPTY_SNAPSHOT = Object.freeze({
1451
+ /**
1452
+ * Subscribe a `RouterSource<T>` to a write-callback and return a cleanup
1453
+ * function. The shape is the per-effect-run pattern that `RealLink`,
1454
+ * `RealLinkActive`, and `RouteView` all share inside their constructor
1455
+ * `effect(...)` (review-2026-05-16 §8a MEDIUM — identical 8-line block
1456
+ * repeated in 3 directives):
1457
+ *
1458
+ * 1. Read initial snapshot and apply it via `onSnapshot(snap)`.
1459
+ * 2. Subscribe — every subsequent emission calls `onSnapshot(snap)` again.
1460
+ * 3. Return a cleanup that unsubscribes and destroys the source. For
1461
+ * cached factories from `@real-router/sources` (`createActiveRouteSource`,
1462
+ * `createRouteNodeSource`, `getTransitionSource`, `getErrorSource`,
1463
+ * `createDismissableError`) `destroy()` is a no-op on the shared
1464
+ * wrapper, so this helper is safe to invoke from rapid effect re-runs
1465
+ * under signal-input changes.
1466
+ *
1467
+ * Callers pass the result to `onCleanup(...)` from Angular's `effect()`.
1468
+ *
1469
+ * @example
1470
+ * ```ts
1471
+ * effect((onCleanup) => {
1472
+ * const source = createActiveRouteSource(router, routeName(), params());
1473
+ * onCleanup(
1474
+ * subscribeSourceToSignal(source, (snap) => {
1475
+ * this.isActive.set(snap);
1476
+ * this.updateDom();
1477
+ * }),
1478
+ * );
1479
+ * });
1480
+ * ```
1481
+ */
1482
+ function subscribeSourceToSignal(source, onSnapshot) {
1483
+ onSnapshot(source.getSnapshot());
1484
+ const unsub = source.subscribe(() => {
1485
+ onSnapshot(source.getSnapshot());
1486
+ });
1487
+ return () => {
1488
+ unsub();
1489
+ source.destroy();
1490
+ };
1491
+ }
1492
+
1493
+ const EMPTY_SNAPSHOT = {
936
1494
  route: undefined,
937
1495
  previousRoute: undefined,
938
- });
1496
+ };
939
1497
  class RouteView {
940
1498
  nodeName = input("", { ...(ngDevMode ? { debugName: "nodeName" } : /* istanbul ignore next */ {}), alias: "routeNode" });
941
1499
  matches = contentChildren(RouteMatch, { ...(ngDevMode ? { debugName: "matches" } : /* istanbul ignore next */ {}), descendants: true });
942
1500
  selfs = contentChildren(RouteSelf, { ...(ngDevMode ? { debugName: "selfs" } : /* istanbul ignore next */ {}), descendants: true });
943
1501
  notFounds = contentChildren(RouteNotFound, { ...(ngDevMode ? { debugName: "notFounds" } : /* istanbul ignore next */ {}), descendants: true });
944
- activeTemplate = computed(() => {
945
- const snapshot = this.routeState();
946
- const route = snapshot.route;
1502
+ activeTemplate = computed(() => this.matchedTemplate() ?? this.fallbackTemplate(), ...(ngDevMode ? [{ debugName: "activeTemplate" }] : /* istanbul ignore next */ []));
1503
+ router = injectRouter();
1504
+ routeState = signal(EMPTY_SNAPSHOT, ...(ngDevMode ? [{ debugName: "routeState" }] : /* istanbul ignore next */ []));
1505
+ matchEntries = computed(() => {
1506
+ const nodeName = this.nodeName();
1507
+ return this.matches().map((match) => {
1508
+ const segment = match.routeMatch();
1509
+ return {
1510
+ match,
1511
+ fullSegmentName: nodeName ? `${nodeName}.${segment}` : segment,
1512
+ };
1513
+ });
1514
+ }, ...(ngDevMode ? [{ debugName: "matchEntries" }] : /* istanbul ignore next */ []));
1515
+ // The matched template (Match priority) is independent of the Self /
1516
+ // NotFound fallback chain. Splitting the two paths into separate computeds
1517
+ // localises re-runs: a change to `selfs()` / `notFounds()` no longer
1518
+ // re-evaluates the Match loop (review §8a LOW — RouteView activeTemplate
1519
+ // split).
1520
+ matchedTemplate = computed(() => {
1521
+ const route = this.routeState().route;
947
1522
  if (!route) {
948
1523
  return null;
949
1524
  }
950
1525
  const routeName = route.name;
951
- const entries = this.matchEntries();
952
- for (const { match, fullSegmentName } of entries) {
1526
+ for (const { match, fullSegmentName } of this.matchEntries()) {
953
1527
  if (startsWithSegment(routeName, fullSegmentName)) {
954
1528
  return match.templateRef;
955
1529
  }
956
1530
  }
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.
1531
+ return null;
1532
+ }, ...(ngDevMode ? [{ debugName: "matchedTemplate" }] : /* istanbul ignore next */ []));
1533
+ // Fallback chain only consulted when `matchedTemplate()` returned `null`.
1534
+ // Template priority: Self NotFound. Selection rules differ on purpose:
1535
+ // - **Self uses first-wins** (`.at(0)`) for parity with React / Preact /
1536
+ // Solid / Vue, where the first matching `<Self>` token in declaration
1537
+ // order wins.
1538
+ // - **NotFound uses last-wins** (`.at(-1)`) intentionally — the fallback
1539
+ // should be the most-recently-declared template so that consumers can
1540
+ // override an inherited `<ng-template routeNotFound>` simply by
1541
+ // re-declaring it lower in the projected content.
1542
+ fallbackTemplate = computed(() => {
1543
+ const route = this.routeState().route;
1544
+ if (!route) {
1545
+ return null;
1546
+ }
1547
+ const routeName = route.name;
961
1548
  if (routeName === this.nodeName()) {
962
1549
  const first = this.selfs().at(0);
963
1550
  if (first) {
@@ -971,39 +1558,25 @@ class RouteView {
971
1558
  }
972
1559
  }
973
1560
  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();
1561
+ }, ...(ngDevMode ? [{ debugName: "fallbackTemplate" }] : /* istanbul ignore next */ []));
1562
+ constructor() {
1563
+ // Reactive source-creation effect (#630 fix) — see
1564
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1565
+ effect((onCleanup) => {
1566
+ const source = createRouteNodeSource(this.router, this.nodeName());
1567
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1568
+ this.routeState.set(snap);
1569
+ }));
997
1570
  });
998
1571
  }
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: `
1572
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteView, deps: [], target: i0.ɵɵFactoryTarget.Component });
1573
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", 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
1574
  @if (activeTemplate()) {
1002
1575
  <ng-container [ngTemplateOutlet]="activeTemplate()!" />
1003
1576
  }
1004
1577
  `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1005
1578
  }
1006
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouteView, decorators: [{
1579
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouteView, decorators: [{
1007
1580
  type: Component,
1008
1581
  args: [{
1009
1582
  selector: "route-view",
@@ -1014,7 +1587,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1014
1587
  `,
1015
1588
  imports: [NgTemplateOutlet],
1016
1589
  }]
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 }] }] } });
1590
+ }], 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
1591
 
1019
1592
  class RouterErrorBoundary {
1020
1593
  errorTemplate = input(...(ngDevMode ? [undefined, { debugName: "errorTemplate" }] : /* istanbul ignore next */ []));
@@ -1032,6 +1605,12 @@ class RouterErrorBoundary {
1032
1605
  router = injectRouter();
1033
1606
  snapshot = sourceToSignal(createDismissableError(this.router));
1034
1607
  constructor() {
1608
+ // `effect()` registers itself with the current injection context's
1609
+ // `DestroyRef` and tears down automatically when the component is
1610
+ // destroyed. The earlier manual `effectRef.destroy()` wired through
1611
+ // `inject(DestroyRef).onDestroy(...)` duplicated that built-in cleanup
1612
+ // (audit §8.1 LOW — confirmed: no behavior change without the manual
1613
+ // path).
1035
1614
  effect(() => {
1036
1615
  const snap = this.snapshot();
1037
1616
  if (snap.error) {
@@ -1043,8 +1622,8 @@ class RouterErrorBoundary {
1043
1622
  }
1044
1623
  });
1045
1624
  }
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: `
1625
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouterErrorBoundary, deps: [], target: i0.ɵɵFactoryTarget.Component });
1626
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", 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
1627
  <ng-content />
1049
1628
  @if (errorContext() && errorTemplate()) {
1050
1629
  <ng-container
@@ -1054,7 +1633,7 @@ class RouterErrorBoundary {
1054
1633
  }
1055
1634
  `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1056
1635
  }
1057
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RouterErrorBoundary, decorators: [{
1636
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RouterErrorBoundary, decorators: [{
1058
1637
  type: Component,
1059
1638
  args: [{
1060
1639
  selector: "router-error-boundary",
@@ -1078,10 +1657,10 @@ class NavigationAnnouncer {
1078
1657
  this.announcer.destroy();
1079
1658
  });
1080
1659
  }
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 });
1660
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: NavigationAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1661
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.13", type: NavigationAnnouncer, isStandalone: true, selector: "navigation-announcer", ngImport: i0, template: "", isInline: true });
1083
1662
  }
1084
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NavigationAnnouncer, decorators: [{
1663
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: NavigationAnnouncer, decorators: [{
1085
1664
  type: Component,
1086
1665
  args: [{
1087
1666
  selector: "navigation-announcer",
@@ -1089,6 +1668,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1089
1668
  }]
1090
1669
  }], ctorParameters: () => [] });
1091
1670
 
1671
+ const NOOP_CATCH = () => { };
1092
1672
  class RealLink {
1093
1673
  routeName = input("", ...(ngDevMode ? [{ debugName: "routeName" }] : /* istanbul ignore next */ []));
1094
1674
  routeParams = input({}, ...(ngDevMode ? [{ debugName: "routeParams" }] : /* istanbul ignore next */ []));
@@ -1104,38 +1684,45 @@ class RealLink {
1104
1684
  */
1105
1685
  hash = input(undefined, ...(ngDevMode ? [{ debugName: "hash" }] : /* istanbul ignore next */ []));
1106
1686
  router = injectRouter();
1107
- destroyRef = inject(DestroyRef);
1108
1687
  anchor = inject(ElementRef)
1109
1688
  .nativeElement;
1110
1689
  isActive = signal(false, ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1690
+ // `href` is computed from signal inputs only — Angular's default Object.is
1691
+ // equality already collapses repeated `string` results, so no custom
1692
+ // comparator is required (review §8b note 3 — applies after verifying that
1693
+ // `buildHref` returns a primitive).
1111
1694
  href = computed(() => {
1112
1695
  const hashValue = this.hash();
1113
1696
  return buildHref(this.router, this.routeName(), this.routeParams(), hashValue === undefined ? undefined : { hash: hashValue });
1114
1697
  }, ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
1115
1698
  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();
1699
+ prevHref = undefined;
1700
+ // Skip-same-value: only re-touch the DOM `class` list when the active state
1701
+ // actually flipped. Without this, every navigation that re-fires the active
1702
+ // source still issues a `classList.toggle` no-op (review §8b MEDIUM).
1703
+ prevActive = undefined;
1704
+ constructor() {
1705
+ // Reactive source-creation effect (#630 fix) — see
1706
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1707
+ // Reading signal inputs inside `effect()` re-creates the active-route
1708
+ // source whenever any input changes; `onCleanup` tears the previous
1709
+ // subscription down.
1710
+ effect((onCleanup) => {
1711
+ const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), buildActiveRouteOptions(this.activeStrict(), this.ignoreQueryParams(), this.hash()));
1712
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1713
+ // Pure-href refresh: when the active flag did not change, only the
1714
+ // href may have moved (e.g. param-only update on a parent route).
1715
+ // Skip the classList work in that branch (review §8b MEDIUM).
1716
+ if (snap === this.prevActive) {
1717
+ this.isActive.set(snap);
1718
+ this.updateHref();
1719
+ return;
1720
+ }
1721
+ this.prevActive = snap;
1722
+ this.isActive.set(snap);
1723
+ this.updateHref();
1724
+ this.updateActiveClass();
1725
+ }));
1139
1726
  });
1140
1727
  }
1141
1728
  onClick(event) {
@@ -1143,13 +1730,16 @@ class RealLink {
1143
1730
  return;
1144
1731
  }
1145
1732
  event.preventDefault();
1146
- navigateWithHash(this.router, this.routeName(), this.routeParams(), this.hash(), this.routeOptions()).catch(() => { });
1733
+ navigateWithHash(this.router, this.routeName(), this.routeParams(), this.hash(), this.routeOptions()).catch(NOOP_CATCH);
1147
1734
  }
1148
- updateDom() {
1735
+ updateHref() {
1149
1736
  const href = this.href();
1150
- if (href !== undefined) {
1737
+ if (href !== undefined && href !== this.prevHref) {
1151
1738
  this.anchor.setAttribute("href", href);
1152
1739
  }
1740
+ this.prevHref = href;
1741
+ }
1742
+ updateActiveClass() {
1153
1743
  const activeClass = this.activeClassName();
1154
1744
  if (this.prevActiveClass && this.prevActiveClass !== activeClass) {
1155
1745
  this.anchor.classList.remove(this.prevActiveClass);
@@ -1159,10 +1749,10 @@ class RealLink {
1159
1749
  }
1160
1750
  this.prevActiveClass = activeClass;
1161
1751
  }
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 });
1752
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RealLink, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1753
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.13", 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
1754
  }
1165
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLink, decorators: [{
1755
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RealLink, decorators: [{
1166
1756
  type: Directive,
1167
1757
  args: [{
1168
1758
  selector: "a[realLink]",
@@ -1170,7 +1760,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1170
1760
  "(click)": "onClick($event)",
1171
1761
  },
1172
1762
  }]
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 }] }] } });
1763
+ }], 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
1764
 
1175
1765
  class RealLinkActive {
1176
1766
  realLinkActive = input("", ...(ngDevMode ? [{ debugName: "realLinkActive" }] : /* istanbul ignore next */ []));
@@ -1179,26 +1769,29 @@ class RealLinkActive {
1179
1769
  activeStrict = input(false, ...(ngDevMode ? [{ debugName: "activeStrict" }] : /* istanbul ignore next */ []));
1180
1770
  ignoreQueryParams = input(true, ...(ngDevMode ? [{ debugName: "ignoreQueryParams" }] : /* istanbul ignore next */ []));
1181
1771
  router = injectRouter();
1182
- destroyRef = inject(DestroyRef);
1183
1772
  element = inject(ElementRef).nativeElement;
1184
1773
  isActive = signal(false, ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1774
+ // Skip-same-value: only touch `classList.toggle` when the active flag
1775
+ // actually flipped. Saves one DOM write per RealLinkActive per unrelated
1776
+ // navigation (review §8b MEDIUM).
1777
+ prevActive = undefined;
1185
1778
  constructor() {
1779
+ // One-time a11y setup — doesn't depend on signal inputs, stays in
1780
+ // constructor body. `applyLinkA11y` is idempotent so re-running would
1781
+ // be safe, but we only need it once per element.
1186
1782
  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();
1783
+ // Reactive source-creation effect (#630 fix) — see
1784
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
1785
+ effect((onCleanup) => {
1786
+ const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), buildActiveRouteOptions(this.activeStrict(), this.ignoreQueryParams(), undefined));
1787
+ onCleanup(subscribeSourceToSignal(source, (snap) => {
1788
+ if (snap === this.prevActive) {
1789
+ return;
1790
+ }
1791
+ this.prevActive = snap;
1792
+ this.isActive.set(snap);
1793
+ this.updateClass();
1794
+ }));
1202
1795
  });
1203
1796
  }
1204
1797
  updateClass() {
@@ -1208,10 +1801,10 @@ class RealLinkActive {
1208
1801
  }
1209
1802
  this.element.classList.toggle(className, this.isActive());
1210
1803
  }
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 });
1804
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RealLinkActive, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1805
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.13", 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
1806
  }
1214
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLinkActive, decorators: [{
1807
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: RealLinkActive, decorators: [{
1215
1808
  type: Directive,
1216
1809
  args: [{ selector: "[realLinkActive]" }]
1217
1810
  }], 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 +1813,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1220
1813
  * Generated bundle index. Do not edit.
1221
1814
  */
1222
1815
 
1223
- export { NAVIGATOR, NavigationAnnouncer, ROUTE, ROUTER, RealLink, RealLinkActive, RouteMatch, RouteNotFound, RouteSelf, RouteView, RouterErrorBoundary, injectIsActiveRoute, injectNavigator, injectRoute, injectRouteEnter, injectRouteExit, injectRouteNode, injectRouteUtils, injectRouter, injectRouterTransition, provideRealRouter, sourceToSignal };
1816
+ 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
1817
  //# sourceMappingURL=real-router-angular.mjs.map