@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
@@ -3,11 +3,9 @@ import {
3
3
  Component,
4
4
  computed,
5
5
  contentChildren,
6
- inject,
6
+ effect,
7
7
  input,
8
8
  signal,
9
- DestroyRef,
10
- type OnInit,
11
9
  type TemplateRef,
12
10
  } from "@angular/core";
13
11
  import { UNKNOWN_ROUTE } from "@real-router/core";
@@ -18,13 +16,14 @@ import { RouteMatch } from "../directives/RouteMatch";
18
16
  import { RouteNotFound } from "../directives/RouteNotFound";
19
17
  import { RouteSelf } from "../directives/RouteSelf";
20
18
  import { injectRouter } from "../functions/injectRouter";
19
+ import { subscribeSourceToSignal } from "../internal/subscribeSourceToSignal";
21
20
 
22
21
  import type { RouteSnapshot } from "@real-router/sources";
23
22
 
24
- const EMPTY_SNAPSHOT: RouteSnapshot = Object.freeze({
23
+ const EMPTY_SNAPSHOT: RouteSnapshot = {
25
24
  route: undefined,
26
25
  previousRoute: undefined,
27
- });
26
+ };
28
27
 
29
28
  @Component({
30
29
  selector: "route-view",
@@ -35,52 +34,19 @@ const EMPTY_SNAPSHOT: RouteSnapshot = Object.freeze({
35
34
  `,
36
35
  imports: [NgTemplateOutlet],
37
36
  })
38
- export class RouteView implements OnInit {
37
+ export class RouteView {
39
38
  readonly nodeName = input<string>("", { alias: "routeNode" });
40
39
 
41
40
  readonly matches = contentChildren(RouteMatch, { descendants: true });
42
41
  readonly selfs = contentChildren(RouteSelf, { descendants: true });
43
42
  readonly notFounds = contentChildren(RouteNotFound, { descendants: true });
44
43
 
45
- readonly activeTemplate = computed<TemplateRef<unknown> | null>(() => {
46
- const snapshot = this.routeState();
47
- const route = snapshot.route;
44
+ readonly activeTemplate = computed<TemplateRef<unknown> | null>(
45
+ () => this.matchedTemplate() ?? this.fallbackTemplate(),
46
+ );
48
47
 
49
- if (!route) {
50
- return null;
51
- }
52
-
53
- const routeName = route.name;
54
- const entries = this.matchEntries();
55
-
56
- for (const { match, fullSegmentName } of entries) {
57
- if (startsWithSegment(routeName, fullSegmentName)) {
58
- return match.templateRef;
59
- }
60
- }
61
-
62
- // Self has priority over NotFound. First-wins to mirror NotFound's
63
- // last-wins inversion would be inconsistent with React/Preact/Solid/Vue
64
- // adapters where Self is "first wins"; Angular's contentChildren returns
65
- // declaration order, so picking [0] gives first-wins.
66
- if (routeName === this.nodeName()) {
67
- const first = this.selfs().at(0);
68
-
69
- if (first) {
70
- return first.templateRef;
71
- }
72
- }
73
-
74
- if (routeName === UNKNOWN_ROUTE) {
75
- const last = this.notFounds().at(-1);
76
-
77
- if (last) {
78
- return last.templateRef;
79
- }
80
- }
81
-
82
- return null;
83
- });
48
+ private readonly router = injectRouter();
49
+ private readonly routeState = signal<RouteSnapshot>(EMPTY_SNAPSHOT);
84
50
 
85
51
  private readonly matchEntries = computed(() => {
86
52
  const nodeName = this.nodeName();
@@ -95,22 +61,81 @@ export class RouteView implements OnInit {
95
61
  });
96
62
  });
97
63
 
98
- private readonly router = injectRouter();
99
- private readonly destroyRef = inject(DestroyRef);
100
- private readonly routeState = signal<RouteSnapshot>(EMPTY_SNAPSHOT);
64
+ // The matched template (Match priority) is independent of the Self /
65
+ // NotFound fallback chain. Splitting the two paths into separate computeds
66
+ // localises re-runs: a change to `selfs()` / `notFounds()` no longer
67
+ // re-evaluates the Match loop (review §8a LOW — RouteView activeTemplate
68
+ // split).
69
+ private readonly matchedTemplate = computed<TemplateRef<unknown> | null>(
70
+ () => {
71
+ const route = this.routeState().route;
72
+
73
+ if (!route) {
74
+ return null;
75
+ }
101
76
 
102
- ngOnInit(): void {
103
- const source = createRouteNodeSource(this.router, this.nodeName());
77
+ const routeName = route.name;
104
78
 
105
- this.routeState.set(source.getSnapshot());
79
+ for (const { match, fullSegmentName } of this.matchEntries()) {
80
+ if (startsWithSegment(routeName, fullSegmentName)) {
81
+ return match.templateRef;
82
+ }
83
+ }
106
84
 
107
- const unsub = source.subscribe(() => {
108
- this.routeState.set(source.getSnapshot());
109
- });
85
+ return null;
86
+ },
87
+ );
88
+
89
+ // Fallback chain — only consulted when `matchedTemplate()` returned `null`.
90
+ // Template priority: Self → NotFound. Selection rules differ on purpose:
91
+ // - **Self uses first-wins** (`.at(0)`) for parity with React / Preact /
92
+ // Solid / Vue, where the first matching `<Self>` token in declaration
93
+ // order wins.
94
+ // - **NotFound uses last-wins** (`.at(-1)`) intentionally — the fallback
95
+ // should be the most-recently-declared template so that consumers can
96
+ // override an inherited `<ng-template routeNotFound>` simply by
97
+ // re-declaring it lower in the projected content.
98
+ private readonly fallbackTemplate = computed<TemplateRef<unknown> | null>(
99
+ () => {
100
+ const route = this.routeState().route;
101
+
102
+ if (!route) {
103
+ return null;
104
+ }
110
105
 
111
- this.destroyRef.onDestroy(() => {
112
- unsub();
113
- source.destroy();
106
+ const routeName = route.name;
107
+
108
+ if (routeName === this.nodeName()) {
109
+ const first = this.selfs().at(0);
110
+
111
+ if (first) {
112
+ return first.templateRef;
113
+ }
114
+ }
115
+
116
+ if (routeName === UNKNOWN_ROUTE) {
117
+ const last = this.notFounds().at(-1);
118
+
119
+ if (last) {
120
+ return last.templateRef;
121
+ }
122
+ }
123
+
124
+ return null;
125
+ },
126
+ );
127
+
128
+ constructor() {
129
+ // Reactive source-creation effect (#630 fix) — see
130
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
131
+ effect((onCleanup) => {
132
+ const source = createRouteNodeSource(this.router, this.nodeName());
133
+
134
+ onCleanup(
135
+ subscribeSourceToSignal(source, (snap) => {
136
+ this.routeState.set(snap);
137
+ }),
138
+ );
114
139
  });
115
140
  }
116
141
  }
@@ -5,15 +5,11 @@ import { createDismissableError } from "@real-router/sources";
5
5
  import { injectRouter } from "../functions/injectRouter";
6
6
  import { sourceToSignal } from "../sourceToSignal";
7
7
 
8
+ import type { ErrorContext } from "../types";
8
9
  import type { TemplateRef } from "@angular/core";
9
10
  import type { RouterError, State } from "@real-router/core";
10
11
  import type { DismissableErrorSnapshot } from "@real-router/sources";
11
12
 
12
- export interface ErrorContext {
13
- $implicit: RouterError;
14
- resetError: () => void;
15
- }
16
-
17
13
  @Component({
18
14
  selector: "router-error-boundary",
19
15
  template: `
@@ -55,6 +51,12 @@ export class RouterErrorBoundary {
55
51
  );
56
52
 
57
53
  constructor() {
54
+ // `effect()` registers itself with the current injection context's
55
+ // `DestroyRef` and tears down automatically when the component is
56
+ // destroyed. The earlier manual `effectRef.destroy()` wired through
57
+ // `inject(DestroyRef).onDestroy(...)` duplicated that built-in cleanup
58
+ // (audit §8.1 LOW — confirmed: no behavior change without the manual
59
+ // path).
58
60
  effect(() => {
59
61
  const snap = this.snapshot();
60
62
 
@@ -2,26 +2,29 @@ import {
2
2
  Directive,
3
3
  ElementRef,
4
4
  computed,
5
+ effect,
5
6
  inject,
6
7
  input,
7
8
  signal,
8
- DestroyRef,
9
- type OnInit,
10
9
  } from "@angular/core";
11
10
  import { createActiveRouteSource } from "@real-router/sources";
12
11
 
13
12
  import { buildHref, navigateWithHash, shouldNavigate } from "../dom-utils";
14
13
  import { injectRouter } from "../functions/injectRouter";
14
+ import { buildActiveRouteOptions } from "../internal/buildActiveRouteOptions";
15
+ import { subscribeSourceToSignal } from "../internal/subscribeSourceToSignal";
15
16
 
16
17
  import type { Params, NavigationOptions } from "@real-router/core";
17
18
 
19
+ const NOOP_CATCH = (): void => {};
20
+
18
21
  @Directive({
19
22
  selector: "a[realLink]",
20
23
  host: {
21
24
  "(click)": "onClick($event)",
22
25
  },
23
26
  })
24
- export class RealLink implements OnInit {
27
+ export class RealLink {
25
28
  readonly routeName = input<string>("");
26
29
  readonly routeParams = input<Params>({});
27
30
  readonly routeOptions = input<NavigationOptions>({});
@@ -37,10 +40,13 @@ export class RealLink implements OnInit {
37
40
  readonly hash = input<string | undefined>(undefined);
38
41
 
39
42
  private readonly router = injectRouter();
40
- private readonly destroyRef = inject(DestroyRef);
41
43
  private readonly anchor = inject(ElementRef)
42
44
  .nativeElement as HTMLAnchorElement;
43
45
  private readonly isActive = signal(false);
46
+ // `href` is computed from signal inputs only — Angular's default Object.is
47
+ // equality already collapses repeated `string` results, so no custom
48
+ // comparator is required (review §8b note 3 — applies after verifying that
49
+ // `buildHref` returns a primitive).
44
50
  private readonly href = computed(() => {
45
51
  const hashValue = this.hash();
46
52
 
@@ -52,38 +58,48 @@ export class RealLink implements OnInit {
52
58
  );
53
59
  });
54
60
  private prevActiveClass = "";
55
-
56
- ngOnInit(): void {
57
- // Hash-aware active state (#532): pass `hash` so that tab-style links
58
- // (same routeName, different `hash` input) only mark the active variant.
59
- const hashValue = this.hash();
60
- const source = createActiveRouteSource(
61
- this.router,
62
- this.routeName(),
63
- this.routeParams(),
64
- hashValue === undefined
65
- ? {
66
- strict: this.activeStrict(),
67
- ignoreQueryParams: this.ignoreQueryParams(),
61
+ private prevHref: string | undefined = undefined;
62
+ // Skip-same-value: only re-touch the DOM `class` list when the active state
63
+ // actually flipped. Without this, every navigation that re-fires the active
64
+ // source still issues a `classList.toggle` no-op (review §8b MEDIUM).
65
+ private prevActive: boolean | undefined = undefined;
66
+
67
+ constructor() {
68
+ // Reactive source-creation effect (#630 fix) — see
69
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
70
+ // Reading signal inputs inside `effect()` re-creates the active-route
71
+ // source whenever any input changes; `onCleanup` tears the previous
72
+ // subscription down.
73
+ effect((onCleanup) => {
74
+ const source = createActiveRouteSource(
75
+ this.router,
76
+ this.routeName(),
77
+ this.routeParams(),
78
+ buildActiveRouteOptions(
79
+ this.activeStrict(),
80
+ this.ignoreQueryParams(),
81
+ this.hash(),
82
+ ),
83
+ );
84
+
85
+ onCleanup(
86
+ subscribeSourceToSignal(source, (snap) => {
87
+ // Pure-href refresh: when the active flag did not change, only the
88
+ // href may have moved (e.g. param-only update on a parent route).
89
+ // Skip the classList work in that branch (review §8b MEDIUM).
90
+ if (snap === this.prevActive) {
91
+ this.isActive.set(snap);
92
+ this.updateHref();
93
+
94
+ return;
68
95
  }
69
- : {
70
- strict: this.activeStrict(),
71
- ignoreQueryParams: this.ignoreQueryParams(),
72
- hash: hashValue,
73
- },
74
- );
75
-
76
- this.isActive.set(source.getSnapshot());
77
- this.updateDom();
78
-
79
- const unsub = source.subscribe(() => {
80
- this.isActive.set(source.getSnapshot());
81
- this.updateDom();
82
- });
83
96
 
84
- this.destroyRef.onDestroy(() => {
85
- unsub();
86
- source.destroy();
97
+ this.prevActive = snap;
98
+ this.isActive.set(snap);
99
+ this.updateHref();
100
+ this.updateActiveClass();
101
+ }),
102
+ );
87
103
  });
88
104
  }
89
105
 
@@ -99,16 +115,20 @@ export class RealLink implements OnInit {
99
115
  this.routeParams(),
100
116
  this.hash(),
101
117
  this.routeOptions(),
102
- ).catch(() => {});
118
+ ).catch(NOOP_CATCH);
103
119
  }
104
120
 
105
- private updateDom(): void {
121
+ private updateHref(): void {
106
122
  const href = this.href();
107
123
 
108
- if (href !== undefined) {
124
+ if (href !== undefined && href !== this.prevHref) {
109
125
  this.anchor.setAttribute("href", href);
110
126
  }
111
127
 
128
+ this.prevHref = href;
129
+ }
130
+
131
+ private updateActiveClass(): void {
112
132
  const activeClass = this.activeClassName();
113
133
 
114
134
  if (this.prevActiveClass && this.prevActiveClass !== activeClass) {
@@ -1,21 +1,22 @@
1
1
  import {
2
2
  Directive,
3
3
  ElementRef,
4
+ effect,
4
5
  inject,
5
6
  input,
6
7
  signal,
7
- DestroyRef,
8
- type OnInit,
9
8
  } from "@angular/core";
10
9
  import { createActiveRouteSource } from "@real-router/sources";
11
10
 
12
11
  import { applyLinkA11y } from "../dom-utils";
13
12
  import { injectRouter } from "../functions/injectRouter";
13
+ import { buildActiveRouteOptions } from "../internal/buildActiveRouteOptions";
14
+ import { subscribeSourceToSignal } from "../internal/subscribeSourceToSignal";
14
15
 
15
16
  import type { Params } from "@real-router/core";
16
17
 
17
18
  @Directive({ selector: "[realLinkActive]" })
18
- export class RealLinkActive implements OnInit {
19
+ export class RealLinkActive {
19
20
  readonly realLinkActive = input<string>("");
20
21
  readonly routeName = input<string>("");
21
22
  readonly routeParams = input<Params>({});
@@ -23,36 +24,44 @@ export class RealLinkActive implements OnInit {
23
24
  readonly ignoreQueryParams = input(true);
24
25
 
25
26
  private readonly router = injectRouter();
26
- private readonly destroyRef = inject(DestroyRef);
27
27
  private readonly element = inject(ElementRef).nativeElement as HTMLElement;
28
28
  private readonly isActive = signal(false);
29
+ // Skip-same-value: only touch `classList.toggle` when the active flag
30
+ // actually flipped. Saves one DOM write per RealLinkActive per unrelated
31
+ // navigation (review §8b MEDIUM).
32
+ private prevActive: boolean | undefined = undefined;
29
33
 
30
34
  constructor() {
35
+ // One-time a11y setup — doesn't depend on signal inputs, stays in
36
+ // constructor body. `applyLinkA11y` is idempotent so re-running would
37
+ // be safe, but we only need it once per element.
31
38
  applyLinkA11y(this.element);
32
- }
33
-
34
- ngOnInit(): void {
35
- const source = createActiveRouteSource(
36
- this.router,
37
- this.routeName(),
38
- this.routeParams(),
39
- {
40
- strict: this.activeStrict(),
41
- ignoreQueryParams: this.ignoreQueryParams(),
42
- },
43
- );
44
39
 
45
- this.isActive.set(source.getSnapshot());
46
- this.updateClass();
40
+ // Reactive source-creation effect (#630 fix) — see
41
+ // `packages/angular/CLAUDE.md` → "Directives use constructor + effect()".
42
+ effect((onCleanup) => {
43
+ const source = createActiveRouteSource(
44
+ this.router,
45
+ this.routeName(),
46
+ this.routeParams(),
47
+ buildActiveRouteOptions(
48
+ this.activeStrict(),
49
+ this.ignoreQueryParams(),
50
+ undefined,
51
+ ),
52
+ );
47
53
 
48
- const unsub = source.subscribe(() => {
49
- this.isActive.set(source.getSnapshot());
50
- this.updateClass();
51
- });
54
+ onCleanup(
55
+ subscribeSourceToSignal(source, (snap) => {
56
+ if (snap === this.prevActive) {
57
+ return;
58
+ }
52
59
 
53
- this.destroyRef.onDestroy(() => {
54
- unsub();
55
- source.destroy();
60
+ this.prevActive = snap;
61
+ this.isActive.set(snap);
62
+ this.updateClass();
63
+ }),
64
+ );
56
65
  });
57
66
  }
58
67
 
@@ -15,14 +15,41 @@ export function shouldNavigate(evt: MouseEvent): boolean {
15
15
  );
16
16
  }
17
17
 
18
+ // Matches a single percent-escape triple (`%` + two hex digits). Used as
19
+ // the "already-encoded" probe in `encodeFragmentInline` below — see the
20
+ // idempotency rationale there.
21
+ const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
22
+
18
23
  /**
19
24
  * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
20
25
  * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
21
26
  * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
22
27
  * `shared/browser-env/url-context.ts` — duplicated here because the
23
28
  * shared/dom-utils symlink graph does not reach shared/browser-env.
29
+ *
30
+ * **Idempotency for pre-encoded input (audit-2026-05-17 §5 MEDIUM E.1).**
31
+ * The doc-comment on `<Link hash>` says the value is a "decoded fragment
32
+ * without leading #". But realistic consumers copy hashes out of
33
+ * `location.hash` (which is percent-encoded) and pass them back, so the
34
+ * naive `encodeURI("%20")` would double-encode into `"%2520"` and break
35
+ * anchor lookup. We detect a percent-escape triple in the input and, if
36
+ * present, decode + re-encode for idempotency. Malformed `%XX` (e.g.
37
+ * `"%2"` or `"%ZZ"`) makes `decodeURIComponent` throw — in that case we
38
+ * fall through to plain `encodeURI`, which never throws.
24
39
  */
25
40
  function encodeFragmentInline(decoded: string): string {
41
+ if (PERCENT_ESCAPE_PROBE.test(decoded)) {
42
+ try {
43
+ const roundtrip = decodeURIComponent(decoded);
44
+
45
+ return encodeURI(roundtrip).replaceAll("#", "%23");
46
+ } catch {
47
+ // Malformed `%XX` — fall through to the plain encoding path.
48
+ // encodeURI does not throw on malformed escapes; it treats the
49
+ // `%` as a literal and percent-encodes it (`%2` → `%252`).
50
+ }
51
+ }
52
+
26
53
  return encodeURI(decoded).replaceAll("#", "%23");
27
54
  }
28
55
 
@@ -68,13 +95,34 @@ export function buildHref(
68
95
  normHash === undefined ? undefined : { hash: normHash },
69
96
  );
70
97
 
71
- if (url !== undefined) {
98
+ // Accept only non-empty strings. The BuildUrlFn type contract is
99
+ // `string | undefined`, but defensive against:
100
+ // - `""` (empty string) → would render `<a href="">`, which resolves
101
+ // to the current page URL → silent self-navigation on click.
102
+ // - `null` (type-contract violation) → would render `<a href={null}>`,
103
+ // stringified to `"null"` in some renderers.
104
+ // Either case falls through to the `router.buildPath` fallback below.
105
+ if (typeof url === "string" && url.length > 0) {
72
106
  return url;
73
107
  }
74
108
  }
75
109
 
76
110
  const path = router.buildPath(routeName, routeParams);
77
111
 
112
+ // Symmetric to the buildUrl guard above (#S1 audit, Invariant 12).
113
+ // `router.buildPath` is typed `string`, but defends against:
114
+ // - `""` (empty string) — would render `<a href="">`, which resolves
115
+ // to the current page URL → silent self-navigation on click.
116
+ // - non-string type-contract violations from custom path-matchers.
117
+ // Both yield `undefined` (renderer drops the attribute) with a warning.
118
+ if (typeof path !== "string" || path.length === 0) {
119
+ console.error(
120
+ `[real-router] Route "${routeName}" yielded an empty path. The element will render without an href attribute.`,
121
+ );
122
+
123
+ return undefined;
124
+ }
125
+
78
126
  return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
79
127
  } catch {
80
128
  console.error(
@@ -144,8 +192,28 @@ export function navigateWithHash(
144
192
  return router.navigate(routeName, routeParams, opts);
145
193
  }
146
194
 
195
+ // Match-any-whitespace regex shared across calls. RegExp literals at
196
+ // call-site recompile in some engines; lifting it avoids that microcost
197
+ // for the slow-path branch.
198
+ const WHITESPACE_PROBE = /\s/;
199
+ const WHITESPACE_SPLIT = /\S+/g;
200
+
147
201
  function parseTokens(value: string | undefined): string[] {
148
- return value ? (value.match(/\S+/g) ?? []) : [];
202
+ if (!value) {
203
+ return [];
204
+ }
205
+
206
+ // Hot-path fast-path (audit-2026-05-17 §8b #1): >99% of active-class
207
+ // inputs at `<Link>` emit are single-token strings like `"active"` or
208
+ // `"is-current"` — no whitespace, no leading/trailing pad. Skip the
209
+ // regex match and Array result allocation: a literal `[value]` works
210
+ // because the slow-path `match(/\S+/g)` would return exactly `[value]`
211
+ // for the same input. PBT lock: linkUtils.properties.ts Invariant 13.
212
+ if (!WHITESPACE_PROBE.test(value)) {
213
+ return [value];
214
+ }
215
+
216
+ return value.match(WHITESPACE_SPLIT) ?? [];
149
217
  }
150
218
 
151
219
  export function buildActiveClassName(
@@ -179,6 +247,29 @@ export function buildActiveClassName(
179
247
  return baseClassName ?? undefined;
180
248
  }
181
249
 
250
+ /**
251
+ * One-level structural equality using `Object.is` per key.
252
+ *
253
+ * **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
254
+ * Implementation walks `Object.keys()` which by spec returns only
255
+ * enumerable own STRING keys. Symbol-keyed properties — created via
256
+ * `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
257
+ * NOT compared. Two records that differ only in a Symbol-keyed value
258
+ * will compare as equal.
259
+ *
260
+ * This is intentional: route params and Link options are documented as
261
+ * string-keyed primitives (string | number | boolean) — Symbol-keyed
262
+ * metadata (e.g. brand markers, private state) doesn't belong in a
263
+ * cache-key comparison. Switching to `Reflect.ownKeys()` would extend
264
+ * the contract to symbols at the cost of one extra allocation per call
265
+ * (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
266
+ * consumer relies on symbol-keyed metadata for navigation
267
+ * disambiguation, they should encode it into a string key instead.
268
+ *
269
+ * Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
270
+ * both the string-keys-only semantics and the `hasOwnProperty` guard
271
+ * below.
272
+ */
182
273
  export function shallowEqual(
183
274
  prev: object | undefined,
184
275
  next: object | undefined,
@@ -200,7 +291,13 @@ export function shallowEqual(
200
291
  const nextRecord = next as Record<string, unknown>;
201
292
 
202
293
  for (const key of prevKeys) {
203
- if (!Object.is(prevRecord[key], nextRecord[key])) {
294
+ // hasOwnProperty guard: without it, a key missing in `next` reads as
295
+ // `undefined` and falsely matches `prev[key] === undefined`. Same shape
296
+ // as React's shallowEqual (packages/shared/shallowEqual.js).
297
+ if (
298
+ !Object.prototype.hasOwnProperty.call(next, key) ||
299
+ !Object.is(prevRecord[key], nextRecord[key])
300
+ ) {
204
301
  return false;
205
302
  }
206
303
  }
@@ -212,10 +309,25 @@ export function applyLinkA11y(element: HTMLElement | null | undefined): void {
212
309
  if (!element) {
213
310
  return;
214
311
  }
215
- if (
216
- element instanceof HTMLAnchorElement ||
217
- element instanceof HTMLButtonElement
218
- ) {
312
+
313
+ // Cross-realm safety (audit-2026-05-17 §5 HIGH #4):
314
+ // `instanceof HTMLAnchorElement` compares against the constructor from
315
+ // the CURRENT realm. An element created in a different window (iframe
316
+ // contentDocument, micro-frontend, embedded widget) fails the check
317
+ // even when it IS a real anchor — the helper would then inject
318
+ // role="link" + tabindex="0" on top of native anchor semantics,
319
+ // breaking screen reader output ("link link") and focus order.
320
+ //
321
+ // tagName is realm-agnostic and is uppercase for HTML-namespaced
322
+ // elements in any document. SVG `<a>` has lowercase tagName plus a
323
+ // different prototype (SVGAElement) — skipping it here is wrong by
324
+ // accident: SVG anchors don't have keyboard activation semantics the
325
+ // helper would add. But they also don't reach this helper in
326
+ // practice (router Link components emit HTML anchors). Lock the
327
+ // uppercase compare to keep the contract narrow.
328
+ const tag = element.tagName;
329
+
330
+ if (tag === "A" || tag === "BUTTON") {
219
331
  return;
220
332
  }
221
333
  if (!element.hasAttribute("role")) {