@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
@@ -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
- document.body.prepend(element);
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
- return getCustomText(route);
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
- return raw ? (JSON.parse(raw) as Record<string, number>) : {};
83
+ store = raw ? (JSON.parse(raw) as Record<string, number>) : {};
75
84
  } catch {
76
- return {};
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 store = loadStore();
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
- store[key] = pos;
85
- sessionStorage.setItem(storageKey, JSON.stringify(store));
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,32 +222,46 @@ 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
- putPos(keyOf(previousRoute), readPos());
225
+ const prevKey = safeKeyOf(previousRoute);
226
+
227
+ if (prevKey !== null) {
228
+ putPos(prevKey, readPos());
229
+ }
182
230
  }
183
231
 
184
- // Single rAF so DOM is committed before we read anchors / write scroll.
185
- // Guard against destroy() racing with the callback.
186
232
  requestAnimationFrame(() => {
187
233
  if (destroyed) {
188
234
  return;
189
235
  }
190
236
 
191
- if (mode === "top" || !nav) {
237
+ if (mode === "top") {
192
238
  scrollToHashOrTop(route);
193
239
 
194
240
  return;
195
241
  }
196
242
 
197
- if (nav.navigationType === "replace") {
243
+ if (route.transition.replace || nav?.navigationType === "replace") {
198
244
  return;
199
245
  }
200
246
 
201
- if (
202
- nav.direction === "back" ||
203
- nav.navigationType === "traverse" ||
204
- nav.navigationType === "reload"
205
- ) {
206
- writePos(loadStore()[keyOf(route)] ?? 0);
247
+ // Both arms are required: `transition.reload` only fires for programmatic
248
+ // `router.navigate({reload:true})`. F5 under navigation-plugin primes
249
+ // `nav.navigationType === "reload"` via #531 getActivationType but leaves
250
+ // opts.reload undefined, so dropping the plugin arm would regress F5
251
+ // scroll-restore. Same belt-and-suspenders pattern is used for replace
252
+ // above. Browser-plugin's F5 is not covered (no priming, out of scope).
253
+ if (route.transition.reload || nav?.navigationType === "reload") {
254
+ const key = safeKeyOf(route);
255
+
256
+ writePos(key === null ? 0 : (loadStore()[key] ?? 0));
257
+
258
+ return;
259
+ }
260
+
261
+ if (nav?.direction === "back" || nav?.navigationType === "traverse") {
262
+ const key = safeKeyOf(route);
263
+
264
+ writePos(key === null ? 0 : (loadStore()[key] ?? 0));
207
265
 
208
266
  return;
209
267
  }
@@ -216,7 +274,11 @@ export function createScrollRestoration(
216
274
  const current = router.getState();
217
275
 
218
276
  if (current) {
219
- putPos(keyOf(current), readPos());
277
+ const key = safeKeyOf(current);
278
+
279
+ if (key !== null) {
280
+ putPos(key, readPos());
281
+ }
220
282
  }
221
283
  };
222
284
 
@@ -241,20 +303,114 @@ export function createScrollRestoration(
241
303
  };
242
304
  }
243
305
 
244
- function keyOf(state: State): string {
245
- return `${state.name}:${canonicalJson(state.params)}`;
306
+ /**
307
+ * Internal cache-key builder for scroll-position storage.
308
+ *
309
+ * **Exported for testing only — not part of the public API** (intentionally
310
+ * excluded from `index.ts` barrel). Adapter property tests import it via
311
+ * the direct path to lock the `(name, canonicalJson(params))` key shape
312
+ * as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
313
+ * key format would silently lose scroll positions across an upgrade —
314
+ * the test set is the contract.
315
+ *
316
+ * ## Identity-based memoization (audit-2026-05-17 §8b #2)
317
+ *
318
+ * `State` objects emitted by core are frozen per-navigation: their
319
+ * `name` / `params` are immutable for the lifetime of the snapshot, and
320
+ * any change produces a new `State` reference. A `WeakMap<State, string>`
321
+ * therefore safely caches the canonicalised key by identity — repeat
322
+ * `keyOf(state)` calls on the same snapshot (typical on
323
+ * back/forward/traverse where the same prior `State` is re-emitted)
324
+ * skip the recursive `canonicalJson` pass entirely.
325
+ *
326
+ * The cache key is the `State` reference, so entries auto-release when
327
+ * the snapshot is GC'd — no eviction needed.
328
+ */
329
+ const KEY_CACHE = new WeakMap<State, string>();
330
+
331
+ export function keyOf(state: State): string {
332
+ const cached = KEY_CACHE.get(state);
333
+
334
+ if (cached !== undefined) {
335
+ return cached;
336
+ }
337
+
338
+ const key = `${state.name}:${canonicalJson(state.params)}`;
339
+
340
+ KEY_CACHE.set(state, key);
341
+
342
+ return key;
246
343
  }
247
344
 
248
- function canonicalJson(value: unknown): string {
345
+ /**
346
+ * Stable JSON serializer with sorted object keys.
347
+ *
348
+ * **Exported for testing only — not part of the public API** (intentionally
349
+ * excluded from `index.ts` barrel). Adapter property tests import it via
350
+ * the direct path to lock the key-order-insensitive property
351
+ * (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
352
+ *
353
+ * ## Divergence from `@real-router/sources/canonicalJson` — by design
354
+ *
355
+ * Two independent implementations live in the monorepo:
356
+ *
357
+ * - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
358
+ * cache key builder. Uses `localeCompare` and a plain-object accumulator;
359
+ * tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
360
+ * replacer happens to sort them; relies on `JSON.stringify`'s native cycle
361
+ * detector. Designed to be cheap on the navigation hot path. The
362
+ * surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
363
+ * cyclic) and skips the offending capture/restore.
364
+ *
365
+ * - **`@real-router/sources/canonicalJson`** — sources cache key builder.
366
+ * Uses byte-order compare (`< / >`) for locale-independence, a
367
+ * `Object.create(null)` accumulator to prevent prototype pollution, and a
368
+ * bespoke path-based cycle detector (the native one cannot see the cloned
369
+ * graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
370
+ * back to a non-cached source.
371
+ *
372
+ * **They are intentionally NOT interchangeable.** Aligning them would either
373
+ * regress scroll-restore performance (byte-order + recursive clone is heavier
374
+ * per call) or weaken the sources cache (locale dependence breaks
375
+ * deterministic cache keys across machines). No cross-package equivalence
376
+ * test exists or should be added; the relationship is "different invariants,
377
+ * different costs, different consumers." Audit-2 / audit-2026-05-17 §2
378
+ * documents the choice.
379
+ */
380
+ export function canonicalJson(value: unknown): string {
249
381
  return JSON.stringify(value, canonicalReplacer);
250
382
  }
251
383
 
252
384
  function canonicalReplacer(_key: string, val: unknown): unknown {
385
+ // audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
386
+ // `JSON.stringify` silently drops function and symbol values from
387
+ // object output. Two routes that differ ONLY in a function/Symbol
388
+ // value would canonicalize to the same string → silent scroll-cache
389
+ // key collision (positions clobber each other). Replacing the value
390
+ // with a sentinel string breaks the collision while keeping the
391
+ // canonical form deterministic. The sentinels are intentionally
392
+ // ASCII-only and lexically distinct from valid JSON-stringified
393
+ // values; consumers will see `"<fn>"` / `"<sym>"` if they ever
394
+ // round-trip the cache key, signalling the substitution clearly.
395
+ if (typeof val === "function") {
396
+ return "<fn>";
397
+ }
398
+ if (typeof val === "symbol") {
399
+ return "<sym>";
400
+ }
401
+
253
402
  if (val !== null && typeof val === "object" && !Array.isArray(val)) {
254
- const sorted: Record<string, unknown> = {};
403
+ // Null-prototype accumulator: a plain `{}` would interpret
404
+ // `sorted["__proto__"] = x` as a prototype assignment (silently dropped
405
+ // from JSON.stringify output AND a prototype-pollution vector). Mirrors
406
+ // the same guard in `@real-router/sources/canonicalJson`. The two
407
+ // implementations are still intentionally divergent (see the doc-block
408
+ // on [[canonicalJson]] above), but prototype-safety is non-negotiable
409
+ // on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
410
+ const sorted = Object.create(null) as Record<string, unknown>;
255
411
  // eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
256
- const keys = Object.keys(val as Record<string, unknown>).sort(
257
- (left: string, right: string) => left.localeCompare(right),
412
+ const keys = Object.keys(val).sort((left: string, right: string) =>
413
+ left.localeCompare(right),
258
414
  );
259
415
 
260
416
  for (const key of keys) {
@@ -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
- hash === undefined
25
- ? { strict, ignoreQueryParams }
26
- : { strict, ignoreQueryParams, hash },
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
- if (!value) {
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
- export function injectRoute<P extends Params = Params>(): Omit<
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
- if (!signals.routeState().route) {
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 Omit<RouteSignals<P>, "routeState"> & {
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
- /* v8 ignore start */
114
- if (lastHandledRoute === route || !previousRoute) {
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
+ }