@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.
- package/README.md +184 -5
- package/dist/README.md +184 -5
- package/dist/fesm2022/real-router-angular-ssr.mjs +323 -0
- package/dist/fesm2022/real-router-angular-ssr.mjs.map +1 -0
- package/dist/fesm2022/real-router-angular.mjs +773 -180
- package/dist/fesm2022/real-router-angular.mjs.map +1 -1
- package/dist/types/real-router-angular-ssr.d.ts +227 -0
- package/dist/types/real-router-angular-ssr.d.ts.map +1 -0
- package/dist/types/real-router-angular.d.ts +119 -20
- package/dist/types/real-router-angular.d.ts.map +1 -1
- package/package.json +17 -10
- package/src/components/RouteView.ts +81 -56
- package/src/components/RouterErrorBoundary.ts +7 -5
- package/src/directives/RealLink.ts +57 -37
- package/src/directives/RealLinkActive.ts +34 -25
- package/src/dom-utils/link-utils.ts +119 -7
- package/src/dom-utils/route-announcer.ts +58 -2
- package/src/dom-utils/scroll-restore.ts +179 -23
- package/src/functions/injectIsActiveRoute.ts +9 -8
- package/src/functions/injectNavigator.ts +4 -0
- package/src/functions/injectOrThrow.ts +5 -1
- package/src/functions/injectRoute.ts +17 -8
- package/src/functions/injectRouteEnter.ts +5 -10
- package/src/functions/injectRouteNode.ts +3 -0
- package/src/functions/injectRouteUtils.ts +3 -0
- package/src/functions/injectRouter.ts +4 -0
- package/src/functions/injectRouterTransition.ts +3 -0
- package/src/index.ts +14 -3
- package/src/internal/buildActiveRouteOptions.ts +20 -0
- package/src/internal/install.ts +77 -0
- package/src/internal/subscribeSourceToSignal.ts +48 -0
- package/src/providers.ts +11 -38
- package/src/providersFactory.ts +298 -0
- package/src/sourceToSignal.ts +10 -2
- package/src/types.ts +6 -1
- package/ssr/components/ClientOnly.ts +27 -0
- package/ssr/components/HttpStatusCode.ts +106 -0
- package/ssr/components/ServerOnly.ts +27 -0
- package/ssr/functions/injectDeferred.ts +92 -0
- package/ssr/functions/provideHttpStatusSink.ts +43 -0
- package/ssr/ng-package.json +6 -0
- package/ssr/public_api.ts +35 -0
- package/ssr/utils/createHttpStatusSink.ts +61 -0
|
@@ -3,11 +3,9 @@ import {
|
|
|
3
3
|
Component,
|
|
4
4
|
computed,
|
|
5
5
|
contentChildren,
|
|
6
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
44
|
+
readonly activeTemplate = computed<TemplateRef<unknown> | null>(
|
|
45
|
+
() => this.matchedTemplate() ?? this.fallbackTemplate(),
|
|
46
|
+
);
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
const source = createRouteNodeSource(this.router, this.nodeName());
|
|
77
|
+
const routeName = route.name;
|
|
104
78
|
|
|
105
|
-
|
|
79
|
+
for (const { match, fullSegmentName } of this.matchEntries()) {
|
|
80
|
+
if (startsWithSegment(routeName, fullSegmentName)) {
|
|
81
|
+
return match.templateRef;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
106
84
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
onCleanup(
|
|
55
|
+
subscribeSourceToSignal(source, (snap) => {
|
|
56
|
+
if (snap === this.prevActive) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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")) {
|