@real-router/angular 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -4
- package/dist/README.md +183 -4
- 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 +759 -173
- 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 +18 -11
- 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 +160 -12
- 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
|
@@ -12,10 +12,29 @@ export interface RouteAnnouncerOptions {
|
|
|
12
12
|
getAnnouncementText?: (route: State) => string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
|
|
16
|
+
destroy: () => {
|
|
17
|
+
/* no-op */
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
15
21
|
export function createRouteAnnouncer(
|
|
16
22
|
router: Router,
|
|
17
23
|
options?: RouteAnnouncerOptions,
|
|
18
24
|
): { destroy: () => void } {
|
|
25
|
+
// Defensive SSR / non-browser guard: in SSR (Node.js) or non-DOM
|
|
26
|
+
// environments, `document` is undefined and the announcer cannot
|
|
27
|
+
// attach its aria-live region. Return a frozen NOOP_INSTANCE — same
|
|
28
|
+
// pattern as `createDirectionTracker`, `createScrollRestoration`, and
|
|
29
|
+
// `createViewTransitions`. Without this guard, `NavigationAnnouncer`
|
|
30
|
+
// component construction would throw `ReferenceError: document is not
|
|
31
|
+
// defined` under `@angular/ssr` rendering, tearing down the whole SSR
|
|
32
|
+
// bootstrap. Closes review-2026-05-10 §5.10 ⛔ "NavigationAnnouncer
|
|
33
|
+
// SSR mode" MED.
|
|
34
|
+
if (typeof document === "undefined") {
|
|
35
|
+
return NOOP_INSTANCE;
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
const prefix = options?.prefix ?? "Navigated to ";
|
|
20
39
|
const getCustomText = options?.getAnnouncementText;
|
|
21
40
|
|
|
@@ -117,7 +136,21 @@ function getOrCreateAnnouncer(): HTMLElement {
|
|
|
117
136
|
element.setAttribute("aria-atomic", "true");
|
|
118
137
|
element.setAttribute(ANNOUNCER_ATTR, "");
|
|
119
138
|
|
|
120
|
-
|
|
139
|
+
// Defensive SSR / pre-`<body>` guard: in some environments (early
|
|
140
|
+
// injection, deferred-body documents, certain SSR rehydration paths)
|
|
141
|
+
// `document.body` can be null when the announcer is constructed.
|
|
142
|
+
// `document.body.prepend(...)` would throw `TypeError: Cannot read
|
|
143
|
+
// properties of null`, tearing down the consumer's RouterProvider /
|
|
144
|
+
// NavigationAnnouncer mount. Fallback to `documentElement` keeps the
|
|
145
|
+
// announcer working for SR users; visual-hidden styling means there is
|
|
146
|
+
// no visible artifact regardless of mount point.
|
|
147
|
+
//
|
|
148
|
+
// TS dom lib types `document.body` as `HTMLElement` (non-null), but
|
|
149
|
+
// runtime can return null per spec. The `as` cast narrows the type to
|
|
150
|
+
// include null so the `??` short-circuit is type-safe.
|
|
151
|
+
((document.body as HTMLElement | null) ?? document.documentElement).prepend(
|
|
152
|
+
element,
|
|
153
|
+
);
|
|
121
154
|
|
|
122
155
|
return element;
|
|
123
156
|
}
|
|
@@ -133,7 +166,30 @@ function resolveText(
|
|
|
133
166
|
h1: HTMLElement | null,
|
|
134
167
|
): string {
|
|
135
168
|
if (getCustomText) {
|
|
136
|
-
|
|
169
|
+
try {
|
|
170
|
+
const customText = getCustomText(route);
|
|
171
|
+
|
|
172
|
+
// Mini-sprint E.4 (audit-5 §4.2 #4) — empty-string fallback.
|
|
173
|
+
// A consumer pattern like
|
|
174
|
+
// getAnnouncementText: (route) => myMap[route.name] ?? ""
|
|
175
|
+
// returns `""` for routes outside the map. The subscribe loop
|
|
176
|
+
// then sees an empty text and silently no-announces — screen
|
|
177
|
+
// readers stay quiet without any signal to the developer. Treat
|
|
178
|
+
// a falsy custom result (`""` / `null` / `undefined`) as
|
|
179
|
+
// "consumer doesn't have a name for this route" and fall through
|
|
180
|
+
// to the default resolution chain (h1 → title → route name).
|
|
181
|
+
if (customText) {
|
|
182
|
+
return customText;
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// A throwing consumer callback inside the router's subscribe loop
|
|
186
|
+
// would tear down sibling listeners — log and fall through to the
|
|
187
|
+
// built-in resolution chain so the announcer keeps working.
|
|
188
|
+
console.error(
|
|
189
|
+
"[real-router] getAnnouncementText threw; falling back to default resolution.",
|
|
190
|
+
error,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
137
193
|
}
|
|
138
194
|
|
|
139
195
|
const h1Text = (h1?.textContent ?? "").trim();
|
|
@@ -67,22 +67,42 @@ export function createScrollRestoration(
|
|
|
67
67
|
const behavior: ScrollBehavior = options?.behavior ?? "auto";
|
|
68
68
|
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
69
69
|
|
|
70
|
+
// Write-through in-memory cache: parse sessionStorage once per provider
|
|
71
|
+
// mount, then mutate in-memory. Avoids a JSON.parse + JSON.stringify pair
|
|
72
|
+
// on every subscribeLeave / pagehide event.
|
|
73
|
+
let store: Record<string, number> | undefined;
|
|
74
|
+
|
|
70
75
|
const loadStore = (): Record<string, number> => {
|
|
76
|
+
if (store !== undefined) {
|
|
77
|
+
return store;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
try {
|
|
72
81
|
const raw = sessionStorage.getItem(storageKey);
|
|
73
82
|
|
|
74
|
-
|
|
83
|
+
store = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
75
84
|
} catch {
|
|
76
|
-
|
|
85
|
+
store = {};
|
|
77
86
|
}
|
|
87
|
+
|
|
88
|
+
return store;
|
|
78
89
|
};
|
|
79
90
|
|
|
80
91
|
const putPos = (key: string, pos: number): void => {
|
|
81
92
|
try {
|
|
82
|
-
const
|
|
93
|
+
const cached = loadStore();
|
|
94
|
+
|
|
95
|
+
// Skip-same-value: when a route is left at the same scroll position it
|
|
96
|
+
// already holds in the cache (e.g. tab-switching without scrolling),
|
|
97
|
+
// both the in-memory write and the JSON.stringify + setItem pair are
|
|
98
|
+
// no-ops. Eliminates redundant serialization on the navigation hot
|
|
99
|
+
// path for the common "click tabs without scrolling" case.
|
|
100
|
+
if (cached[key] === pos) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
83
103
|
|
|
84
|
-
|
|
85
|
-
sessionStorage.setItem(storageKey, JSON.stringify(
|
|
104
|
+
cached[key] = pos;
|
|
105
|
+
sessionStorage.setItem(storageKey, JSON.stringify(cached));
|
|
86
106
|
} catch {
|
|
87
107
|
// Ignore quota / security errors.
|
|
88
108
|
}
|
|
@@ -169,6 +189,30 @@ export function createScrollRestoration(
|
|
|
169
189
|
};
|
|
170
190
|
|
|
171
191
|
let destroyed = false;
|
|
192
|
+
let unserializableWarned = false;
|
|
193
|
+
|
|
194
|
+
// `keyOf` defers to `canonicalJson` which calls `JSON.stringify`. Two
|
|
195
|
+
// realistic inputs blow up the serializer and would otherwise crash the
|
|
196
|
+
// subscribe callback (taking scroll-restore offline for the whole session):
|
|
197
|
+
// - `BigInt` params → `TypeError: Do not know how to serialize a BigInt`
|
|
198
|
+
// - cyclic params (reactive proxies, DOM-ref back-pointers) → stack
|
|
199
|
+
// overflow.
|
|
200
|
+
// The defensive wrapper drops capture/restore for that specific navigation
|
|
201
|
+
// and warns once per provider — the rest of the cache stays usable.
|
|
202
|
+
const safeKeyOf = (state: State): string | null => {
|
|
203
|
+
try {
|
|
204
|
+
return keyOf(state);
|
|
205
|
+
} catch {
|
|
206
|
+
if (!unserializableWarned) {
|
|
207
|
+
unserializableWarned = true;
|
|
208
|
+
console.error(
|
|
209
|
+
`[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.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
172
216
|
|
|
173
217
|
const unsubscribe = router.subscribe(({ route, previousRoute }) => {
|
|
174
218
|
const nav = (route.context as { navigation?: NavigationContext })
|
|
@@ -178,7 +222,11 @@ export function createScrollRestoration(
|
|
|
178
222
|
// previousRoute is undefined and capture is naturally skipped. The
|
|
179
223
|
// pre-refresh position was already persisted via pagehide.
|
|
180
224
|
if (previousRoute) {
|
|
181
|
-
|
|
225
|
+
const prevKey = safeKeyOf(previousRoute);
|
|
226
|
+
|
|
227
|
+
if (prevKey !== null) {
|
|
228
|
+
putPos(prevKey, readPos());
|
|
229
|
+
}
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
// Single rAF so DOM is committed before we read anchors / write scroll.
|
|
@@ -203,7 +251,9 @@ export function createScrollRestoration(
|
|
|
203
251
|
nav.navigationType === "traverse" ||
|
|
204
252
|
nav.navigationType === "reload"
|
|
205
253
|
) {
|
|
206
|
-
|
|
254
|
+
const key = safeKeyOf(route);
|
|
255
|
+
|
|
256
|
+
writePos(key === null ? 0 : (loadStore()[key] ?? 0));
|
|
207
257
|
|
|
208
258
|
return;
|
|
209
259
|
}
|
|
@@ -216,7 +266,11 @@ export function createScrollRestoration(
|
|
|
216
266
|
const current = router.getState();
|
|
217
267
|
|
|
218
268
|
if (current) {
|
|
219
|
-
|
|
269
|
+
const key = safeKeyOf(current);
|
|
270
|
+
|
|
271
|
+
if (key !== null) {
|
|
272
|
+
putPos(key, readPos());
|
|
273
|
+
}
|
|
220
274
|
}
|
|
221
275
|
};
|
|
222
276
|
|
|
@@ -241,17 +295,111 @@ export function createScrollRestoration(
|
|
|
241
295
|
};
|
|
242
296
|
}
|
|
243
297
|
|
|
244
|
-
|
|
245
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Internal cache-key builder for scroll-position storage.
|
|
300
|
+
*
|
|
301
|
+
* **Exported for testing only — not part of the public API** (intentionally
|
|
302
|
+
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
303
|
+
* the direct path to lock the `(name, canonicalJson(params))` key shape
|
|
304
|
+
* as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
|
|
305
|
+
* key format would silently lose scroll positions across an upgrade —
|
|
306
|
+
* the test set is the contract.
|
|
307
|
+
*
|
|
308
|
+
* ## Identity-based memoization (audit-2026-05-17 §8b #2)
|
|
309
|
+
*
|
|
310
|
+
* `State` objects emitted by core are frozen per-navigation: their
|
|
311
|
+
* `name` / `params` are immutable for the lifetime of the snapshot, and
|
|
312
|
+
* any change produces a new `State` reference. A `WeakMap<State, string>`
|
|
313
|
+
* therefore safely caches the canonicalised key by identity — repeat
|
|
314
|
+
* `keyOf(state)` calls on the same snapshot (typical on
|
|
315
|
+
* back/forward/traverse where the same prior `State` is re-emitted)
|
|
316
|
+
* skip the recursive `canonicalJson` pass entirely.
|
|
317
|
+
*
|
|
318
|
+
* The cache key is the `State` reference, so entries auto-release when
|
|
319
|
+
* the snapshot is GC'd — no eviction needed.
|
|
320
|
+
*/
|
|
321
|
+
const KEY_CACHE = new WeakMap<State, string>();
|
|
322
|
+
|
|
323
|
+
export function keyOf(state: State): string {
|
|
324
|
+
const cached = KEY_CACHE.get(state);
|
|
325
|
+
|
|
326
|
+
if (cached !== undefined) {
|
|
327
|
+
return cached;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const key = `${state.name}:${canonicalJson(state.params)}`;
|
|
331
|
+
|
|
332
|
+
KEY_CACHE.set(state, key);
|
|
333
|
+
|
|
334
|
+
return key;
|
|
246
335
|
}
|
|
247
336
|
|
|
248
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Stable JSON serializer with sorted object keys.
|
|
339
|
+
*
|
|
340
|
+
* **Exported for testing only — not part of the public API** (intentionally
|
|
341
|
+
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
342
|
+
* the direct path to lock the key-order-insensitive property
|
|
343
|
+
* (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
|
|
344
|
+
*
|
|
345
|
+
* ## Divergence from `@real-router/sources/canonicalJson` — by design
|
|
346
|
+
*
|
|
347
|
+
* Two independent implementations live in the monorepo:
|
|
348
|
+
*
|
|
349
|
+
* - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
|
|
350
|
+
* cache key builder. Uses `localeCompare` and a plain-object accumulator;
|
|
351
|
+
* tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
|
|
352
|
+
* replacer happens to sort them; relies on `JSON.stringify`'s native cycle
|
|
353
|
+
* detector. Designed to be cheap on the navigation hot path. The
|
|
354
|
+
* surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
|
|
355
|
+
* cyclic) and skips the offending capture/restore.
|
|
356
|
+
*
|
|
357
|
+
* - **`@real-router/sources/canonicalJson`** — sources cache key builder.
|
|
358
|
+
* Uses byte-order compare (`< / >`) for locale-independence, a
|
|
359
|
+
* `Object.create(null)` accumulator to prevent prototype pollution, and a
|
|
360
|
+
* bespoke path-based cycle detector (the native one cannot see the cloned
|
|
361
|
+
* graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
|
|
362
|
+
* back to a non-cached source.
|
|
363
|
+
*
|
|
364
|
+
* **They are intentionally NOT interchangeable.** Aligning them would either
|
|
365
|
+
* regress scroll-restore performance (byte-order + recursive clone is heavier
|
|
366
|
+
* per call) or weaken the sources cache (locale dependence breaks
|
|
367
|
+
* deterministic cache keys across machines). No cross-package equivalence
|
|
368
|
+
* test exists or should be added; the relationship is "different invariants,
|
|
369
|
+
* different costs, different consumers." Audit-2 / audit-2026-05-17 §2
|
|
370
|
+
* documents the choice.
|
|
371
|
+
*/
|
|
372
|
+
export function canonicalJson(value: unknown): string {
|
|
249
373
|
return JSON.stringify(value, canonicalReplacer);
|
|
250
374
|
}
|
|
251
375
|
|
|
252
376
|
function canonicalReplacer(_key: string, val: unknown): unknown {
|
|
377
|
+
// audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
|
|
378
|
+
// `JSON.stringify` silently drops function and symbol values from
|
|
379
|
+
// object output. Two routes that differ ONLY in a function/Symbol
|
|
380
|
+
// value would canonicalize to the same string → silent scroll-cache
|
|
381
|
+
// key collision (positions clobber each other). Replacing the value
|
|
382
|
+
// with a sentinel string breaks the collision while keeping the
|
|
383
|
+
// canonical form deterministic. The sentinels are intentionally
|
|
384
|
+
// ASCII-only and lexically distinct from valid JSON-stringified
|
|
385
|
+
// values; consumers will see `"<fn>"` / `"<sym>"` if they ever
|
|
386
|
+
// round-trip the cache key, signalling the substitution clearly.
|
|
387
|
+
if (typeof val === "function") {
|
|
388
|
+
return "<fn>";
|
|
389
|
+
}
|
|
390
|
+
if (typeof val === "symbol") {
|
|
391
|
+
return "<sym>";
|
|
392
|
+
}
|
|
393
|
+
|
|
253
394
|
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
254
|
-
|
|
395
|
+
// Null-prototype accumulator: a plain `{}` would interpret
|
|
396
|
+
// `sorted["__proto__"] = x` as a prototype assignment (silently dropped
|
|
397
|
+
// from JSON.stringify output AND a prototype-pollution vector). Mirrors
|
|
398
|
+
// the same guard in `@real-router/sources/canonicalJson`. The two
|
|
399
|
+
// implementations are still intentionally divergent (see the doc-block
|
|
400
|
+
// on [[canonicalJson]] above), but prototype-safety is non-negotiable
|
|
401
|
+
// on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
|
|
402
|
+
const sorted = Object.create(null) as Record<string, unknown>;
|
|
255
403
|
// eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
|
|
256
404
|
const keys = Object.keys(val as Record<string, unknown>).sort(
|
|
257
405
|
(left: string, right: string) => left.localeCompare(right),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
1
2
|
import { createActiveRouteSource } from "@real-router/sources";
|
|
2
3
|
|
|
4
|
+
import { buildActiveRouteOptions } from "../internal/buildActiveRouteOptions";
|
|
3
5
|
import { sourceToSignal } from "../sourceToSignal";
|
|
4
6
|
import { injectRouter } from "./injectRouter";
|
|
5
7
|
|
|
@@ -11,19 +13,18 @@ export function injectIsActiveRoute(
|
|
|
11
13
|
params?: Params,
|
|
12
14
|
options?: { strict?: boolean; ignoreQueryParams?: boolean; hash?: string },
|
|
13
15
|
): Signal<boolean> {
|
|
16
|
+
assertInInjectionContext(injectIsActiveRoute);
|
|
17
|
+
|
|
14
18
|
const router = injectRouter();
|
|
15
|
-
const strict = options?.strict ?? false;
|
|
16
|
-
const ignoreQueryParams = options?.ignoreQueryParams ?? true;
|
|
17
|
-
const hash = options?.hash;
|
|
18
|
-
// exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — pass
|
|
19
|
-
// the field only when a value was provided. (#532)
|
|
20
19
|
const source = createActiveRouteSource(
|
|
21
20
|
router,
|
|
22
21
|
routeName,
|
|
23
22
|
params,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
buildActiveRouteOptions(
|
|
24
|
+
options?.strict ?? false,
|
|
25
|
+
options?.ignoreQueryParams ?? true,
|
|
26
|
+
options?.hash,
|
|
27
|
+
),
|
|
27
28
|
);
|
|
28
29
|
|
|
29
30
|
return sourceToSignal(source);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
2
|
+
|
|
1
3
|
import { injectOrThrow } from "./injectOrThrow";
|
|
2
4
|
import { NAVIGATOR } from "../providers";
|
|
3
5
|
|
|
4
6
|
import type { Navigator } from "@real-router/core";
|
|
5
7
|
|
|
6
8
|
export function injectNavigator(): Navigator {
|
|
9
|
+
assertInInjectionContext(injectNavigator);
|
|
10
|
+
|
|
7
11
|
return injectOrThrow(NAVIGATOR, "injectNavigator");
|
|
8
12
|
}
|
|
@@ -5,7 +5,11 @@ import type { InjectionToken } from "@angular/core";
|
|
|
5
5
|
export function injectOrThrow<T>(token: InjectionToken<T>, fnName: string): T {
|
|
6
6
|
const value = inject(token, { optional: true });
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Explicit null / undefined check — falsy guard would misfire on
|
|
9
|
+
// legitimately falsy values (`0`, `""`, `false`) if the token were ever
|
|
10
|
+
// typed for primitives. Today all our tokens hold object instances, but
|
|
11
|
+
// pinning the check keeps the function safe for future typing changes.
|
|
12
|
+
if (value === null || value === undefined) {
|
|
9
13
|
throw new Error(
|
|
10
14
|
`${fnName} must be used within a provideRealRouter context`,
|
|
11
15
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
2
|
+
|
|
1
3
|
import { injectOrThrow } from "./injectOrThrow";
|
|
2
4
|
import { ROUTE } from "../providers";
|
|
3
5
|
|
|
@@ -6,25 +8,32 @@ import type { Signal } from "@angular/core";
|
|
|
6
8
|
import type { Params, State } from "@real-router/core";
|
|
7
9
|
import type { RouteSnapshot } from "@real-router/sources";
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
type NonNullRouteSignals<P extends Params> = Omit<
|
|
10
12
|
RouteSignals<P>,
|
|
11
13
|
"routeState"
|
|
12
14
|
> & {
|
|
13
15
|
readonly routeState: Signal<
|
|
14
16
|
Omit<RouteSnapshot<P>, "route"> & { route: State<P> }
|
|
15
17
|
>;
|
|
16
|
-
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function injectRoute<
|
|
21
|
+
P extends Params = Params,
|
|
22
|
+
>(): NonNullRouteSignals<P> {
|
|
23
|
+
assertInInjectionContext(injectRoute);
|
|
24
|
+
|
|
17
25
|
const signals = injectOrThrow(ROUTE, "injectRoute") as RouteSignals<P>;
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
// Read the snapshot once: the signal is reactive, but the throw-guard
|
|
28
|
+
// and any future use of the snapshot within this call should observe the
|
|
29
|
+
// SAME value to avoid races.
|
|
30
|
+
const snapshot = signals.routeState();
|
|
31
|
+
|
|
32
|
+
if (!snapshot.route) {
|
|
20
33
|
throw new Error(
|
|
21
34
|
"injectRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?",
|
|
22
35
|
);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
return signals as
|
|
26
|
-
readonly routeState: Signal<
|
|
27
|
-
Omit<RouteSnapshot<P>, "route"> & { route: State<P> }
|
|
28
|
-
>;
|
|
29
|
-
};
|
|
38
|
+
return signals as NonNullRouteSignals<P>;
|
|
30
39
|
}
|
|
@@ -87,7 +87,6 @@ export function injectRouteEnter(
|
|
|
87
87
|
|
|
88
88
|
const { routeState } = injectRoute();
|
|
89
89
|
const skipSameRoute = options?.skipSameRoute ?? true;
|
|
90
|
-
let lastHandledRoute: State | null = null;
|
|
91
90
|
|
|
92
91
|
effect(() => {
|
|
93
92
|
const { route, previousRoute } = routeState();
|
|
@@ -99,24 +98,20 @@ export function injectRouteEnter(
|
|
|
99
98
|
// - **Skip-same-route**: query-only navigations have
|
|
100
99
|
// `transition.from === route.name`. Opt-out via
|
|
101
100
|
// `skipSameRoute: false`.
|
|
102
|
-
// - **Defensive dedupe + missing `previousRoute`**: same `route`
|
|
103
|
-
// ref between effect re-runs is unexpected on Angular (the
|
|
104
|
-
// signal only fires on real reference changes); `!previousRoute`
|
|
105
|
-
// is unreachable once `transition.from` is set (core populates
|
|
106
|
-
// them together). Both kept for parity with React; v8-ignored.
|
|
107
101
|
if (!route.transition.from) {
|
|
108
102
|
return;
|
|
109
103
|
}
|
|
110
104
|
if (skipSameRoute && route.transition.from === route.name) {
|
|
111
105
|
return;
|
|
112
106
|
}
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
// `previousRoute` is guaranteed populated whenever `route.transition.from`
|
|
108
|
+
// is set — core writes them together. The dead-code throw-guard that used
|
|
109
|
+
// to live here (review §8a LOW) is removed; the narrowing below is the
|
|
110
|
+
// type-safe equivalent and avoids the no-non-null-assertion lint.
|
|
111
|
+
if (!previousRoute) {
|
|
115
112
|
return;
|
|
116
113
|
}
|
|
117
|
-
/* v8 ignore stop */
|
|
118
114
|
|
|
119
|
-
lastHandledRoute = route;
|
|
120
115
|
handler({ route, previousRoute });
|
|
121
116
|
});
|
|
122
117
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
1
2
|
import { getNavigator } from "@real-router/core";
|
|
2
3
|
import { createRouteNodeSource } from "@real-router/sources";
|
|
3
4
|
|
|
@@ -7,6 +8,8 @@ import { injectRouter } from "./injectRouter";
|
|
|
7
8
|
import type { RouteSignals } from "../types";
|
|
8
9
|
|
|
9
10
|
export function injectRouteNode(nodeName: string): RouteSignals {
|
|
11
|
+
assertInInjectionContext(injectRouteNode);
|
|
12
|
+
|
|
10
13
|
const router = injectRouter();
|
|
11
14
|
const navigator = getNavigator(router);
|
|
12
15
|
const source = createRouteNodeSource(router, nodeName);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
1
2
|
import { getPluginApi } from "@real-router/core/api";
|
|
2
3
|
import { getRouteUtils } from "@real-router/route-utils";
|
|
3
4
|
|
|
@@ -6,6 +7,8 @@ import { injectRouter } from "./injectRouter";
|
|
|
6
7
|
import type { RouteUtils } from "@real-router/route-utils";
|
|
7
8
|
|
|
8
9
|
export function injectRouteUtils(): RouteUtils {
|
|
10
|
+
assertInInjectionContext(injectRouteUtils);
|
|
11
|
+
|
|
9
12
|
const router = injectRouter();
|
|
10
13
|
|
|
11
14
|
return getRouteUtils(getPluginApi(router).getTree());
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
2
|
+
|
|
1
3
|
import { injectOrThrow } from "./injectOrThrow";
|
|
2
4
|
import { ROUTER } from "../providers";
|
|
3
5
|
|
|
4
6
|
import type { Router } from "@real-router/core";
|
|
5
7
|
|
|
6
8
|
export function injectRouter(): Router {
|
|
9
|
+
assertInInjectionContext(injectRouter);
|
|
10
|
+
|
|
7
11
|
return injectOrThrow(ROUTER, "injectRouter");
|
|
8
12
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertInInjectionContext } from "@angular/core";
|
|
1
2
|
import { getTransitionSource } from "@real-router/sources";
|
|
2
3
|
|
|
3
4
|
import { sourceToSignal } from "../sourceToSignal";
|
|
@@ -7,6 +8,8 @@ import type { Signal } from "@angular/core";
|
|
|
7
8
|
import type { RouterTransitionSnapshot } from "@real-router/sources";
|
|
8
9
|
|
|
9
10
|
export function injectRouterTransition(): Signal<RouterTransitionSnapshot> {
|
|
11
|
+
assertInInjectionContext(injectRouterTransition);
|
|
12
|
+
|
|
10
13
|
const router = injectRouter();
|
|
11
14
|
const source = getTransitionSource(router);
|
|
12
15
|
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export { provideRealRouter, ROUTER, NAVIGATOR, ROUTE } from "./providers";
|
|
2
2
|
|
|
3
|
+
export type { RealRouterOptions } from "./providers";
|
|
4
|
+
|
|
5
|
+
export { provideRealRouterFactory } from "./providersFactory";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
RealRouterFactoryOptions,
|
|
9
|
+
RequestDepsFactory,
|
|
10
|
+
RequestPluginsFactory,
|
|
11
|
+
} from "./providersFactory";
|
|
12
|
+
|
|
3
13
|
export { sourceToSignal } from "./sourceToSignal";
|
|
4
14
|
|
|
15
|
+
// Note: SSR-feature exports (`ClientOnly`, `ServerOnly`, `injectDeferred`)
|
|
16
|
+
// have moved to the `/ssr` subpath — import them from
|
|
17
|
+
// `@real-router/angular/ssr` to opt into the SSR-feature surface.
|
|
5
18
|
export {
|
|
6
19
|
injectRouter,
|
|
7
20
|
injectNavigator,
|
|
@@ -27,8 +40,6 @@ export { RouteView } from "./components/RouteView";
|
|
|
27
40
|
|
|
28
41
|
export { RouterErrorBoundary } from "./components/RouterErrorBoundary";
|
|
29
42
|
|
|
30
|
-
export type { ErrorContext } from "./components/RouterErrorBoundary";
|
|
31
|
-
|
|
32
43
|
export { NavigationAnnouncer } from "./components/NavigationAnnouncer";
|
|
33
44
|
|
|
34
45
|
export { RouteMatch } from "./directives/RouteMatch";
|
|
@@ -41,7 +52,7 @@ export { RealLink } from "./directives/RealLink";
|
|
|
41
52
|
|
|
42
53
|
export { RealLinkActive } from "./directives/RealLinkActive";
|
|
43
54
|
|
|
44
|
-
export type { RouteSignals } from "./types";
|
|
55
|
+
export type { RouteSignals, ErrorContext } from "./types";
|
|
45
56
|
|
|
46
57
|
export type {
|
|
47
58
|
RouteSnapshot,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ActiveRouteSourceOptions } from "@real-router/sources";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the `options` literal for `createActiveRouteSource` while honoring
|
|
5
|
+
* `exactOptionalPropertyTypes` — the type forbids passing `{ hash: undefined }`
|
|
6
|
+
* literally (#532), so callers must conditionally include the `hash` key only
|
|
7
|
+
* when a value was provided.
|
|
8
|
+
*
|
|
9
|
+
* Used by `RealLink`, `RealLinkActive`, and `injectIsActiveRoute` — extracted
|
|
10
|
+
* from three identical ternaries (review-2026-05-16 §8a LOW).
|
|
11
|
+
*/
|
|
12
|
+
export function buildActiveRouteOptions(
|
|
13
|
+
strict: boolean,
|
|
14
|
+
ignoreQueryParams: boolean,
|
|
15
|
+
hash: string | undefined,
|
|
16
|
+
): ActiveRouteSourceOptions {
|
|
17
|
+
return hash === undefined
|
|
18
|
+
? { strict, ignoreQueryParams }
|
|
19
|
+
: { strict, ignoreQueryParams, hash };
|
|
20
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ApplicationRef, DestroyRef, inject } from "@angular/core";
|
|
2
|
+
|
|
3
|
+
import { createScrollRestoration, createViewTransitions } from "../dom-utils";
|
|
4
|
+
import { ROUTER } from "../providers";
|
|
5
|
+
|
|
6
|
+
import type { ScrollRestorationOptions } from "../dom-utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared installation helpers for `provideRealRouter` and
|
|
10
|
+
* `provideRealRouterFactory`. Must be called inside the body of a
|
|
11
|
+
* `provideEnvironmentInitializer(() => { ... })` callback so the active
|
|
12
|
+
* injection context resolves `ROUTER`, `ApplicationRef`, and `DestroyRef`.
|
|
13
|
+
*
|
|
14
|
+
* Closes review-2026-05-10 §8.1 MED — eliminates duplicate wiring between
|
|
15
|
+
* `providers.ts` and `providersFactory.ts` (high drift risk noted in the
|
|
16
|
+
* audit: the comment blocks were identical down to the punctuation).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function installScrollRestoration(
|
|
20
|
+
options: ScrollRestorationOptions,
|
|
21
|
+
): void {
|
|
22
|
+
const router = inject(ROUTER);
|
|
23
|
+
const sr = createScrollRestoration(router, options);
|
|
24
|
+
|
|
25
|
+
inject(DestroyRef).onDestroy(() => {
|
|
26
|
+
sr.destroy();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function installViewTransitions(): void {
|
|
31
|
+
const router = inject(ROUTER);
|
|
32
|
+
|
|
33
|
+
// Feature-detect `document.startViewTransition` once at install time. The
|
|
34
|
+
// `appRef.tick()` listener exists ONLY to feed Angular's zoneless CD into
|
|
35
|
+
// the VT utility's `setTimeout(0)`-driven snapshot capture (see comment
|
|
36
|
+
// below). When `startViewTransition` is unavailable (Firefox as of 2026-04,
|
|
37
|
+
// SSR, older browsers), `createViewTransitions` short-circuits to its
|
|
38
|
+
// frozen NOOP_INSTANCE — no leave subscriber registered, no
|
|
39
|
+
// `setTimeout(0)` invariant to satisfy. Installing the per-navigation
|
|
40
|
+
// tick listener anyway would force a synchronous CD pass on every
|
|
41
|
+
// navigation with zero benefit, doubling CD work in zoneless apps.
|
|
42
|
+
// Closes review-2026-05-10 §8.2 MED (view-transitions hot path).
|
|
43
|
+
const vtAvailable =
|
|
44
|
+
typeof document !== "undefined" &&
|
|
45
|
+
typeof document.startViewTransition === "function";
|
|
46
|
+
|
|
47
|
+
let offTick: (() => void) | undefined;
|
|
48
|
+
|
|
49
|
+
if (vtAvailable) {
|
|
50
|
+
// Force synchronous change detection on every transition success BEFORE
|
|
51
|
+
// the VT utility resolves its deferred. The utility uses `setTimeout(0)`
|
|
52
|
+
// to release the new-snapshot capture, which is load-bearing because
|
|
53
|
+
// Chromium blocks rAF callbacks while VT sits in the
|
|
54
|
+
// `update-callback-called` phase. Angular's zoneless CD is rAF-driven by
|
|
55
|
+
// default — without this synchronous tick the new DOM is not committed
|
|
56
|
+
// when the browser captures the new snapshot, so old and new snapshots
|
|
57
|
+
// end up identical and animations finish in ~0 ms with no visible work
|
|
58
|
+
// (the inner-route `products.list ↔ products.detail` morph in the
|
|
59
|
+
// example app was the canary).
|
|
60
|
+
//
|
|
61
|
+
// Subscribers fire in registration order; this one runs BEFORE
|
|
62
|
+
// `createViewTransitions` registers its own subscriber, guaranteeing CD
|
|
63
|
+
// completes first.
|
|
64
|
+
const appRef = inject(ApplicationRef);
|
|
65
|
+
|
|
66
|
+
offTick = router.subscribe(() => {
|
|
67
|
+
appRef.tick();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const vt = createViewTransitions(router);
|
|
72
|
+
|
|
73
|
+
inject(DestroyRef).onDestroy(() => {
|
|
74
|
+
offTick?.();
|
|
75
|
+
vt.destroy();
|
|
76
|
+
});
|
|
77
|
+
}
|