@real-router/angular 0.8.1 → 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.
Files changed (43) hide show
  1. package/README.md +183 -4
  2. package/dist/README.md +183 -4
  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 +759 -173
  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 +160 -12
  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,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
- putPos(keyOf(previousRoute), readPos());
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
- writePos(loadStore()[keyOf(route)] ?? 0);
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
- putPos(keyOf(current), readPos());
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
- function keyOf(state: State): string {
245
- return `${state.name}:${canonicalJson(state.params)}`;
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
- function canonicalJson(value: unknown): string {
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
- const sorted: Record<string, unknown> = {};
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
- 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
+ }
@@ -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
+ }