@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
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ import type { RouterSource } from "@real-router/sources";
2
+
3
+ /**
4
+ * Subscribe a `RouterSource<T>` to a write-callback and return a cleanup
5
+ * function. The shape is the per-effect-run pattern that `RealLink`,
6
+ * `RealLinkActive`, and `RouteView` all share inside their constructor
7
+ * `effect(...)` (review-2026-05-16 §8a MEDIUM — identical 8-line block
8
+ * repeated in 3 directives):
9
+ *
10
+ * 1. Read initial snapshot and apply it via `onSnapshot(snap)`.
11
+ * 2. Subscribe — every subsequent emission calls `onSnapshot(snap)` again.
12
+ * 3. Return a cleanup that unsubscribes and destroys the source. For
13
+ * cached factories from `@real-router/sources` (`createActiveRouteSource`,
14
+ * `createRouteNodeSource`, `getTransitionSource`, `getErrorSource`,
15
+ * `createDismissableError`) `destroy()` is a no-op on the shared
16
+ * wrapper, so this helper is safe to invoke from rapid effect re-runs
17
+ * under signal-input changes.
18
+ *
19
+ * Callers pass the result to `onCleanup(...)` from Angular's `effect()`.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * effect((onCleanup) => {
24
+ * const source = createActiveRouteSource(router, routeName(), params());
25
+ * onCleanup(
26
+ * subscribeSourceToSignal(source, (snap) => {
27
+ * this.isActive.set(snap);
28
+ * this.updateDom();
29
+ * }),
30
+ * );
31
+ * });
32
+ * ```
33
+ */
34
+ export function subscribeSourceToSignal<T>(
35
+ source: RouterSource<T>,
36
+ onSnapshot: (snapshot: T) => void,
37
+ ): () => void {
38
+ onSnapshot(source.getSnapshot());
39
+
40
+ const unsub = source.subscribe(() => {
41
+ onSnapshot(source.getSnapshot());
42
+ });
43
+
44
+ return () => {
45
+ unsub();
46
+ source.destroy();
47
+ };
48
+ }
package/src/providers.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import {
2
- ApplicationRef,
3
- DestroyRef,
4
2
  InjectionToken,
5
- inject,
6
3
  makeEnvironmentProviders,
7
4
  provideEnvironmentInitializer,
8
5
  type EnvironmentProviders,
@@ -10,7 +7,10 @@ import {
10
7
  import { getNavigator, type Router, type Navigator } from "@real-router/core";
11
8
  import { createRouteSource } from "@real-router/sources";
12
9
 
13
- import { createScrollRestoration, createViewTransitions } from "./dom-utils";
10
+ import {
11
+ installScrollRestoration,
12
+ installViewTransitions,
13
+ } from "./internal/install";
14
14
  import { sourceToSignal } from "./sourceToSignal";
15
15
 
16
16
  import type { ScrollRestorationOptions } from "./dom-utils";
@@ -33,6 +33,11 @@ export function provideRealRouter(
33
33
  ): EnvironmentProviders {
34
34
  const navigator = getNavigator(router);
35
35
 
36
+ // `Parameters<typeof makeEnvironmentProviders>[0]` is the actual union
37
+ // `(Provider | EnvironmentProviders | EnvironmentProviders[])[]` —
38
+ // `provideEnvironmentInitializer()` returns `EnvironmentProviders`, so the
39
+ // narrower `Provider[]` would force a cast at every push (review §8a — the
40
+ // proposed Provider[] swap was retracted after discovering this).
36
41
  const providers: Parameters<typeof makeEnvironmentProviders>[0] = [
37
42
  { provide: ROUTER, useValue: router },
38
43
  { provide: NAVIGATOR, useValue: navigator },
@@ -50,45 +55,13 @@ export function provideRealRouter(
50
55
 
51
56
  providers.push(
52
57
  provideEnvironmentInitializer(() => {
53
- const sr = createScrollRestoration(router, scrollOpts);
54
-
55
- inject(DestroyRef).onDestroy(() => {
56
- sr.destroy();
57
- });
58
+ installScrollRestoration(scrollOpts);
58
59
  }),
59
60
  );
60
61
  }
61
62
 
62
63
  if (options?.viewTransitions === true) {
63
- providers.push(
64
- provideEnvironmentInitializer(() => {
65
- const appRef = inject(ApplicationRef);
66
-
67
- // Force synchronous change detection on every transition success
68
- // BEFORE the VT utility resolves its deferred. The utility uses
69
- // `setTimeout(0)` to release the new-snapshot capture, which is
70
- // load-bearing because Chromium blocks rAF callbacks while VT sits
71
- // in the `update-callback-called` phase. Angular's zoneless CD is
72
- // rAF-driven by default — without this synchronous tick the new
73
- // DOM is not committed when the browser captures the new snapshot,
74
- // so old and new snapshots end up identical and animations finish
75
- // in ~0 ms with no visible work (the inner-route `products.list ↔
76
- // products.detail` morph in the example example was the canary).
77
- // Subscribers fire in registration order; this one runs BEFORE
78
- // `createViewTransitions` registers its own subscriber,
79
- // guaranteeing CD completes first.
80
- const offTick = router.subscribe(() => {
81
- appRef.tick();
82
- });
83
-
84
- const vt = createViewTransitions(router);
85
-
86
- inject(DestroyRef).onDestroy(() => {
87
- offTick();
88
- vt.destroy();
89
- });
90
- }),
91
- );
64
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
92
65
  }
93
66
 
94
67
  return makeEnvironmentProviders(providers);
@@ -0,0 +1,298 @@
1
+ import {
2
+ DestroyRef,
3
+ REQUEST,
4
+ TransferState,
5
+ inject,
6
+ makeEnvironmentProviders,
7
+ makeStateKey,
8
+ provideAppInitializer,
9
+ provideEnvironmentInitializer,
10
+ type EnvironmentProviders,
11
+ } from "@angular/core";
12
+ import {
13
+ getNavigator,
14
+ type DefaultDependencies,
15
+ type PluginFactory,
16
+ type Router,
17
+ } from "@real-router/core";
18
+ import { cloneRouter } from "@real-router/core/api";
19
+ import { hydrateRouter, serializeRouterState } from "@real-router/core/utils";
20
+ import { createRouteSource } from "@real-router/sources";
21
+
22
+ import {
23
+ installScrollRestoration,
24
+ installViewTransitions,
25
+ } from "./internal/install";
26
+ import { NAVIGATOR, ROUTE, ROUTER } from "./providers";
27
+ import { sourceToSignal } from "./sourceToSignal";
28
+
29
+ import type { ScrollRestorationOptions } from "./dom-utils";
30
+ import type { RouteSignals } from "./types";
31
+
32
+ /**
33
+ * `TransferState` key carrying the SSR-resolved router state from server to
34
+ * client as an XSS-safe JSON string (produced by `serializeRouterState`).
35
+ * Populated server-side by the `provideAppInitializer` callback after
36
+ * `router.start()` resolves; consumed client-side after hydration. Mirrors the
37
+ * `<script>window.__SSR_STATE__ = …</script>` pattern used by every other
38
+ * adapter — Angular's idiomatic transport is `TransferState` (#599).
39
+ *
40
+ * Stored as `string`: `serializeRouterState(state)` already produces JSON;
41
+ * `hydrateRouter(router, json)` accepts a JSON string and parses it once
42
+ * internally. Storing the parsed object would force a double round-trip
43
+ * (TransferState wraps every value in JSON for transport).
44
+ *
45
+ * Internal implementation detail. Not re-exported.
46
+ */
47
+ const ROUTER_STATE_KEY = makeStateKey<string>("@real-router/angular:ssrState");
48
+
49
+ /**
50
+ * Factory function for deriving per-request dependencies from an SSR `Request`.
51
+ *
52
+ * - **Server:** receives the real `Request` exposed via Angular's `REQUEST` token.
53
+ * - **SSG:** receives a mocked `Request` injected via `platformProviders`.
54
+ * - **Client:** receives `null` — derive deps from `document.cookie` etc.
55
+ *
56
+ * The returned object becomes the second argument to
57
+ * `cloneRouter(baseRouter, deps)`. Returning `undefined` clones the router with
58
+ * no extra deps (cloneRouter accepts the optional 2nd argument).
59
+ */
60
+ export type RequestDepsFactory<
61
+ TDeps extends DefaultDependencies = DefaultDependencies,
62
+ > = (request: Request | null) => TDeps | undefined;
63
+
64
+ /**
65
+ * Function form for conditional plugins (different sets server vs client).
66
+ *
67
+ * Use this when the plugin set must differ — typically because some plugins
68
+ * (e.g. `browser-plugin`, `navigation-plugin`, `hash-plugin`) touch
69
+ * `window.history` / `window.location` and cannot run on the server.
70
+ */
71
+ export type RequestPluginsFactory<
72
+ TDeps extends DefaultDependencies = DefaultDependencies,
73
+ > = (request: Request | null) => readonly PluginFactory<TDeps>[];
74
+
75
+ export interface RealRouterFactoryOptions<
76
+ TDeps extends DefaultDependencies = DefaultDependencies,
77
+ > {
78
+ /**
79
+ * Base router instance — created once at app bootstrap (typically inside
80
+ * `app.config.ts` module scope). Each request clones this router via
81
+ * `cloneRouter(baseRouter, deps?.(request))`, producing an isolated
82
+ * router with its own state, plugins, and subscriptions.
83
+ *
84
+ * **Important:** the `baseRouter` MUST NOT be started ahead of time —
85
+ * `provideAppInitializer` is responsible for calling `router.start(url)`
86
+ * inside the per-request DI scope.
87
+ */
88
+ baseRouter: Router<TDeps>;
89
+
90
+ /**
91
+ * Plugins applied to every per-request router clone.
92
+ *
93
+ * **Static form** — same plugins on both sides:
94
+ * ```ts
95
+ * plugins: [ssrDataPluginFactory(loaders)]
96
+ * ```
97
+ *
98
+ * **Function form** — conditional client vs server (recommended when any
99
+ * browser-only plugin is involved):
100
+ * ```ts
101
+ * plugins: (request) => request
102
+ * ? [ssrDataPluginFactory(loaders)]
103
+ * : [browserPluginFactory(), ssrDataPluginFactory(loaders)],
104
+ * ```
105
+ *
106
+ * Function form is required if the plugin list contains
107
+ * `browser-plugin`, `navigation-plugin`, or `hash-plugin` — those plugins
108
+ * read `window.history` / `window.location` and crash on the server.
109
+ */
110
+ plugins?: readonly PluginFactory<TDeps>[] | RequestPluginsFactory<TDeps>;
111
+
112
+ /**
113
+ * Derive request-scoped deps (e.g. `currentUser` from cookies). The result
114
+ * is passed to `cloneRouter(baseRouter, deps)` and merged with any deps
115
+ * already registered on the base router.
116
+ *
117
+ * Receives `request: Request | null`:
118
+ * - non-null on server (real `Request` from `@angular/ssr` runtime)
119
+ * - non-null on SSG (mocked `Request` via `platformProviders`)
120
+ * - null on client (derive deps externally — e.g. parse `document.cookie`)
121
+ */
122
+ deps?: RequestDepsFactory<TDeps>;
123
+
124
+ /** Optional scroll restoration — same semantics as `provideRealRouter`. */
125
+ scrollRestoration?: ScrollRestorationOptions;
126
+
127
+ /** Optional view transitions — same semantics as `provideRealRouter`. */
128
+ viewTransitions?: boolean;
129
+ }
130
+
131
+ /**
132
+ * `provideRealRouterFactory` — environment providers for SSR / SSG scenarios.
133
+ *
134
+ * Unlike `provideRealRouter(router)` (single instance via `useValue`), this
135
+ * factory uses `useFactory` to produce a per-request router clone:
136
+ *
137
+ * 1. Reads Angular's `REQUEST` token (`{ optional: true }`).
138
+ * 2. Calls `cloneRouter(baseRouter, deps?.(request))` to create a request-scoped clone.
139
+ * 3. Applies plugins (`plugins` array or `plugins(request)` factory).
140
+ * 4. Registers `provideAppInitializer` that calls `await router.start(url)`.
141
+ * 5. Schedules `router.dispose()` via `DestroyRef.onDestroy` — the request
142
+ * Injector is destroyed after the response is sent, releasing all
143
+ * subscriptions and plugins.
144
+ *
145
+ * Use cases:
146
+ * - Angular SSR with `@angular/ssr` (`outputMode: "server"`).
147
+ * - SSG build-time render via `renderApplication` + `platformProviders` `REQUEST` mock.
148
+ * - Multi-tenant request-scoped routing.
149
+ *
150
+ * Existing single-instance scenarios (SPA, SSG client after hydration) continue
151
+ * to use `provideRealRouter(router)` — both APIs ship in parallel.
152
+ *
153
+ * @param options - Factory configuration — see `RealRouterFactoryOptions`.
154
+ * @returns `EnvironmentProviders` to spread into `ApplicationConfig.providers`.
155
+ */
156
+ export function provideRealRouterFactory<
157
+ TDeps extends DefaultDependencies = DefaultDependencies,
158
+ >(options: RealRouterFactoryOptions<TDeps>): EnvironmentProviders {
159
+ const { baseRouter, plugins, deps, scrollRestoration, viewTransitions } =
160
+ options;
161
+
162
+ const providers: Parameters<typeof makeEnvironmentProviders>[0] = [
163
+ {
164
+ provide: ROUTER,
165
+ useFactory: (): Router => {
166
+ const request = inject(REQUEST, { optional: true });
167
+ const requestDeps = deps?.(request);
168
+ const router = cloneRouter(baseRouter, requestDeps);
169
+
170
+ const pluginList =
171
+ typeof plugins === "function" ? plugins(request) : plugins;
172
+
173
+ if (pluginList && pluginList.length > 0) {
174
+ // Variadic — `usePlugin` accepts `(PluginFactory<D> | false | null | undefined)[]`.
175
+ router.usePlugin(...pluginList);
176
+ }
177
+
178
+ // Per-request cleanup. The application Injector is destroyed:
179
+ // - On server: after `writeResponseToNodeResponse` finishes the response
180
+ // (request scope ends).
181
+ // - On client: at `ApplicationRef.destroy` (rare in SPA, common in TestBed).
182
+ // - In SSG build: after each `renderApplication` resolves.
183
+ inject(DestroyRef).onDestroy(() => {
184
+ router.dispose();
185
+ });
186
+
187
+ return router as unknown as Router;
188
+ },
189
+ },
190
+ {
191
+ provide: NAVIGATOR,
192
+ useFactory: () => getNavigator(inject(ROUTER)),
193
+ },
194
+ {
195
+ provide: ROUTE,
196
+ useFactory: (): RouteSignals => {
197
+ const router = inject(ROUTER);
198
+
199
+ return {
200
+ routeState: sourceToSignal(createRouteSource(router)),
201
+ navigator: inject(NAVIGATOR),
202
+ };
203
+ },
204
+ },
205
+ // Async bootstrap — runs before the first component renders. Three
206
+ // branches based on TransferState population:
207
+ //
208
+ // 1. **Client after hydration** — server populated TransferState with
209
+ // the SSR-resolved router state. Consume it via `hydrateRouter`,
210
+ // which deposits the parsed state into the one-shot
211
+ // `RouterInternals.hydrationState` scratchpad before invoking
212
+ // `router.start(state.path)`. SSR loader plugins
213
+ // (`@real-router/ssr-data-plugin`, `@real-router/rsc-server-plugin`)
214
+ // read the scratchpad and skip the loader on first paint — parity
215
+ // with the other 5 adapters that consume `<script>__SSR_STATE__</script>` (#596, #599).
216
+ //
217
+ // 2. **Server / SSG** — TransferState empty; run the regular
218
+ // `router.start(path)`. After it resolves, write the serialized
219
+ // state back into TransferState so the matching client run lands
220
+ // in branch 1. Angular's `TransferState` infrastructure
221
+ // (provided by `provideClientHydration()`) carries this blob to
222
+ // the client as a `<script id="ng-state">` payload.
223
+ //
224
+ // 3. **Pure CSR** — TransferState empty (never populated by a server
225
+ // pass), and `inject(REQUEST, { optional: true })` returns null.
226
+ // Falls into the same `router.start(path)` branch as server-side
227
+ // but skips the TransferState write (no client to hand off to).
228
+ //
229
+ // Errors propagate (Option A from RFC §10): the bootstrap fails and the
230
+ // server returns 500. Custom error pages should be wired via
231
+ // `RouterErrorBoundary` on subsequent renders.
232
+ provideAppInitializer(async () => {
233
+ const router = inject(ROUTER);
234
+ const request = inject(REQUEST, { optional: true });
235
+ const transferState = inject(TransferState);
236
+
237
+ const ssrJson = transferState.get(ROUTER_STATE_KEY, null);
238
+
239
+ if (ssrJson !== null) {
240
+ // Branch 1: client after hydration — reuse server-resolved state.
241
+ await hydrateRouter(router, ssrJson);
242
+ // One-shot semantic, parity with `delete window.__SSR_STATE__`.
243
+ transferState.remove(ROUTER_STATE_KEY);
244
+
245
+ return;
246
+ }
247
+
248
+ // Branches 2 & 3: regular start.
249
+ // Browser-plugin's `start` interceptor (when registered) wraps this call
250
+ // with location-derived path. We always pass an explicit string — the
251
+ // interceptor uses the explicit value because `next(path ?? location)`
252
+ // short-circuits when `path` is non-nullish.
253
+ const path = deriveStartPath(request);
254
+ const state = await router.start(path);
255
+
256
+ if (request !== null) {
257
+ // Branch 2: running inside `@angular/ssr`'s request handler — write
258
+ // serialized state to TransferState so the matching client run can
259
+ // skip the loader on first paint.
260
+ transferState.set(ROUTER_STATE_KEY, serializeRouterState(state));
261
+ }
262
+ }),
263
+ ];
264
+
265
+ if (scrollRestoration) {
266
+ providers.push(
267
+ provideEnvironmentInitializer(() => {
268
+ installScrollRestoration(scrollRestoration);
269
+ }),
270
+ );
271
+ }
272
+
273
+ if (viewTransitions === true) {
274
+ providers.push(provideEnvironmentInitializer(installViewTransitions));
275
+ }
276
+
277
+ return makeEnvironmentProviders(providers);
278
+ }
279
+
280
+ /**
281
+ * Derive the path passed to `router.start(path)`:
282
+ * - Server / SSG: `request.url` → pathname + search.
283
+ * - Client: `window.location` if available.
284
+ * - Fallback: `"/"` (only reachable in synthetic non-browser non-SSR setups).
285
+ */
286
+ function deriveStartPath(request: Request | null): string {
287
+ if (request) {
288
+ const url = new URL(request.url);
289
+
290
+ return url.pathname + url.search;
291
+ }
292
+
293
+ if (typeof globalThis.window !== "undefined") {
294
+ return globalThis.location.pathname + globalThis.location.search;
295
+ }
296
+
297
+ return "/";
298
+ }
@@ -12,8 +12,16 @@ export function sourceToSignal<T>(source: RouterSource<T>): Signal<T> {
12
12
  });
13
13
 
14
14
  destroyRef.onDestroy(() => {
15
- unsubscribe();
16
- source.destroy();
15
+ // `try/finally` guarantees `source.destroy()` runs even if `unsubscribe`
16
+ // throws. Cached sources from `@real-router/sources` keep `destroy()` as
17
+ // a no-op (so they survive multi-consumer teardown), but non-cached
18
+ // sources rely on this call to release their router subscription —
19
+ // skipping it on an unsubscribe throw would leak the listener.
20
+ try {
21
+ unsubscribe();
22
+ } finally {
23
+ source.destroy();
24
+ }
17
25
  });
18
26
 
19
27
  return sig.asReadonly();
package/src/types.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { Signal } from "@angular/core";
2
- import type { Navigator, Params } from "@real-router/core";
2
+ import type { Navigator, Params, RouterError } from "@real-router/core";
3
3
  import type { RouteSnapshot } from "@real-router/sources";
4
4
 
5
5
  export interface RouteSignals<P extends Params = Params> {
6
6
  readonly routeState: Signal<RouteSnapshot<P>>;
7
7
  readonly navigator: Navigator;
8
8
  }
9
+
10
+ export interface ErrorContext {
11
+ $implicit: RouterError;
12
+ resetError: () => void;
13
+ }
@@ -0,0 +1,27 @@
1
+ import { NgTemplateOutlet } from "@angular/common";
2
+ import { afterNextRender, Component, input, signal } from "@angular/core";
3
+
4
+ import type { TemplateRef } from "@angular/core";
5
+
6
+ @Component({
7
+ selector: "client-only",
8
+ template: `
9
+ @if (mounted()) {
10
+ <ng-content />
11
+ } @else if (fallback()) {
12
+ <ng-container [ngTemplateOutlet]="fallback() ?? null" />
13
+ }
14
+ `,
15
+ imports: [NgTemplateOutlet],
16
+ })
17
+ export class ClientOnly {
18
+ readonly fallback = input<TemplateRef<unknown>>();
19
+
20
+ readonly mounted = signal(false);
21
+
22
+ constructor() {
23
+ afterNextRender(() => {
24
+ this.mounted.set(true);
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,106 @@
1
+ import { Component, inject, input } from "@angular/core";
2
+
3
+ import { HTTP_STATUS_SINK } from "../utils/createHttpStatusSink";
4
+
5
+ import type { OnInit } from "@angular/core";
6
+
7
+ /**
8
+ * Render-time HTTP status declaration. Mount inside a route component
9
+ * (typical use case: a glob `*` route's NotFound page) when the status is
10
+ * decided by the rendered tree rather than a loader.
11
+ *
12
+ * Writes `code` to the optionally injected `HTTP_STATUS_SINK` in `ngOnInit`
13
+ * (after the input binding has fired) and renders nothing. Without a provider
14
+ * registered (the standard client-side case) the component is a silent no-op
15
+ * — same component tree hydrates without touching the DOM or warning about
16
+ * mismatches.
17
+ *
18
+ * Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
19
+ * working as before; this component covers render-time decisions only.
20
+ *
21
+ * Last write wins when several `<http-status-code />` instances mount in the
22
+ * same render pass — sink reflects the last component whose `ngOnInit` ran.
23
+ *
24
+ * ```ts
25
+ * // entry-server.ts
26
+ * import { bootstrapApplication } from "@angular/platform-browser";
27
+ * import {
28
+ * createHttpStatusSink,
29
+ * provideHttpStatusSink,
30
+ * } from "@real-router/angular/ssr";
31
+ *
32
+ * const sink = createHttpStatusSink();
33
+ * await bootstrapApplication(AppRoot, {
34
+ * providers: [
35
+ * provideRealRouterFactory({ ... }),
36
+ * provideHttpStatusSink(sink),
37
+ * ],
38
+ * });
39
+ * response.status(sink.code ?? 200).send(html);
40
+ * ```
41
+ *
42
+ * ```html
43
+ * <!-- inside not-found.component.ts template -->
44
+ * <http-status-code [code]="404" />
45
+ * ```
46
+ *
47
+ * **Per-request wiring with `AngularNodeAppEngine`:** the sink must be
48
+ * passed via the second arg of `handle(req, requestContext)` — Angular
49
+ * surfaces it through the `REQUEST_CONTEXT` token. Attaching to `req`
50
+ * directly does NOT work: `AngularNodeAppEngine.handle` constructs a fresh
51
+ * Web `Request` from the Express `IncomingMessage` and discards every
52
+ * custom property. See the `ssr/` example's `app.config.ts` factory for
53
+ * the canonical pattern (`inject(REQUEST_CONTEXT, { optional: true })`
54
+ * → `(ctx as { httpStatusSink? } | null)?.httpStatusSink`).
55
+ *
56
+ * **`@angular/ssr` streaming + `@defer` blocks:** `@defer` blocks hydrate
57
+ * lazily on the client; their server-side rendering is fully synchronous,
58
+ * so `<http-status-code />` inside or outside a `@defer` writes to the
59
+ * sink before `AngularNodeAppEngine.handle` resolves. No streaming
60
+ * ordering concern in Angular's current SSR model.
61
+ *
62
+ * **JIT vs AOT:** the `code` input is declared as `input<number>()` (not
63
+ * `input.required<number>()`) because `input.required` trips `NG0950` in
64
+ * JIT/TestBed even after `componentRef.setInput(...)`. `ngOnInit` skips
65
+ * the write when `code()` is `undefined`. AOT (production build) binds
66
+ * the value normally and the skip never fires.
67
+ *
68
+ * **Valid `code` range:** Node's `res.end()` throws `Invalid status code`
69
+ * on `NaN`, `0`, negative values, or values `> 999` — this surfaces as a
70
+ * 5xx / dropped connection, not silent corruption. Pass a real HTTP status
71
+ * integer (commonly 4xx/5xx; 100-999 is what Node accepts).
72
+ */
73
+ @Component({
74
+ selector: "http-status-code",
75
+ template: "",
76
+ })
77
+ export class HttpStatusCode implements OnInit {
78
+ /**
79
+ * HTTP status to apply to the response. Common values: 404, 410, 451, 503.
80
+ *
81
+ * Declared as optional so the signal is safe to read in `ngOnInit` under
82
+ * both AOT (template binding fires before init hooks) and JIT/TestBed
83
+ * (`componentRef.setInput("code", N)` writes the value before the first
84
+ * change detection). `input.required` would trip `NG0950` in the JIT path
85
+ * because the required-flag is asserted independently of the runtime
86
+ * value. Consumers should always pass a value — `undefined` makes
87
+ * `ngOnInit` skip the sink write rather than throw.
88
+ */
89
+ readonly code = input<number>();
90
+
91
+ // Optional injection — when no `provideHttpStatusSink(...)` is registered
92
+ // (client side) the field is null and `ngOnInit` skips the write.
93
+ private readonly sink = inject(HTTP_STATUS_SINK, { optional: true });
94
+
95
+ ngOnInit(): void {
96
+ if (!this.sink) {
97
+ return;
98
+ }
99
+
100
+ const value = this.code();
101
+
102
+ if (value !== undefined) {
103
+ this.sink.code = value;
104
+ }
105
+ }
106
+ }