@real-router/angular 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -5
- package/dist/README.md +184 -5
- package/dist/fesm2022/real-router-angular-ssr.mjs +323 -0
- package/dist/fesm2022/real-router-angular-ssr.mjs.map +1 -0
- package/dist/fesm2022/real-router-angular.mjs +773 -180
- package/dist/fesm2022/real-router-angular.mjs.map +1 -1
- package/dist/types/real-router-angular-ssr.d.ts +227 -0
- package/dist/types/real-router-angular-ssr.d.ts.map +1 -0
- package/dist/types/real-router-angular.d.ts +119 -20
- package/dist/types/real-router-angular.d.ts.map +1 -1
- package/package.json +17 -10
- package/src/components/RouteView.ts +81 -56
- package/src/components/RouterErrorBoundary.ts +7 -5
- package/src/directives/RealLink.ts +57 -37
- package/src/directives/RealLinkActive.ts +34 -25
- package/src/dom-utils/link-utils.ts +119 -7
- package/src/dom-utils/route-announcer.ts +58 -2
- package/src/dom-utils/scroll-restore.ts +179 -23
- package/src/functions/injectIsActiveRoute.ts +9 -8
- package/src/functions/injectNavigator.ts +4 -0
- package/src/functions/injectOrThrow.ts +5 -1
- package/src/functions/injectRoute.ts +17 -8
- package/src/functions/injectRouteEnter.ts +5 -10
- package/src/functions/injectRouteNode.ts +3 -0
- package/src/functions/injectRouteUtils.ts +3 -0
- package/src/functions/injectRouter.ts +4 -0
- package/src/functions/injectRouterTransition.ts +3 -0
- package/src/index.ts +14 -3
- package/src/internal/buildActiveRouteOptions.ts +20 -0
- package/src/internal/install.ts +77 -0
- package/src/internal/subscribeSourceToSignal.ts +48 -0
- package/src/providers.ts +11 -38
- package/src/providersFactory.ts +298 -0
- package/src/sourceToSignal.ts +10 -2
- package/src/types.ts +6 -1
- package/ssr/components/ClientOnly.ts +27 -0
- package/ssr/components/HttpStatusCode.ts +106 -0
- package/ssr/components/ServerOnly.ts +27 -0
- package/ssr/functions/injectDeferred.ts +92 -0
- package/ssr/functions/provideHttpStatusSink.ts +43 -0
- package/ssr/ng-package.json +6 -0
- package/ssr/public_api.ts +35 -0
- package/ssr/utils/createHttpStatusSink.ts +61 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sourceToSignal.ts
CHANGED
|
@@ -12,8 +12,16 @@ export function sourceToSignal<T>(source: RouterSource<T>): Signal<T> {
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
destroyRef.onDestroy(() => {
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|