@analogjs/router 2.7.0-beta.1 → 2.7.0-beta.3
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/RFC-streaming-ssr.md +416 -0
- package/fesm2022/analogjs-router-server.mjs +390 -146
- package/fesm2022/analogjs-router-server.mjs.map +1 -1
- package/fesm2022/analogjs-router.mjs +5 -91
- package/fesm2022/analogjs-router.mjs.map +1 -1
- package/package.json +2 -2
- package/types/analogjs-router-server.d.ts +43 -11
- package/types/analogjs-router.d.ts +1 -32
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# RFC: Progressive Streaming SSR (`renderStream`)
|
|
2
|
+
|
|
3
|
+
**Status:** Prototype (feat/streaming-ssr branch)
|
|
4
|
+
**Author:** Brandon Roberts
|
|
5
|
+
**Date:** 2026-07-06
|
|
6
|
+
**Packages:** @analogjs/router (`@analogjs/router/server`), @analogjs/platform, @analogjs/vite-plugin-nitro
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
A streaming SSR renderer for Analog that flushes bytes to the client **during**
|
|
13
|
+
the render instead of after it. `renderStream(App, config)` is an alternative to
|
|
14
|
+
`render(App, config)` in `main.server.ts` that returns a
|
|
15
|
+
`ReadableStream<Uint8Array>`:
|
|
16
|
+
|
|
17
|
+
1. the document head + a small client runtime flush immediately, before the app
|
|
18
|
+
finishes rendering;
|
|
19
|
+
2. each `@defer (hydrate …)` block flushes the moment it resolves on the server
|
|
20
|
+
— out of document order — so a slow block never holds back an earlier one;
|
|
21
|
+
3. once the app is stable, the authoritative, fully hydration-annotated document
|
|
22
|
+
flushes as the tail, and the client runtime swaps it in before Angular's
|
|
23
|
+
incremental hydration runs.
|
|
24
|
+
|
|
25
|
+
The Angular capability it needs — a per-`@defer`-block resolution signal — is
|
|
26
|
+
delivered as a small **additive** string patch to `@angular/core`, applied only
|
|
27
|
+
to SSR builds via a Vite plugin (`deferStreamingPlugin`) and gated behind an
|
|
28
|
+
opt-in `experimental.streaming` flag. No hand-patching of `node_modules`.
|
|
29
|
+
|
|
30
|
+
## Motivation
|
|
31
|
+
|
|
32
|
+
The standard `render()` path calls `renderApplication` from
|
|
33
|
+
`@angular/platform-server`, which is **fully buffered**: it renders the entire
|
|
34
|
+
app to a Domino DOM, waits for `ApplicationRef.whenStable()` (which waits for
|
|
35
|
+
_every_ `@defer (hydrate …)` block to resolve), annotates the whole document for
|
|
36
|
+
hydration, and only then serializes and returns a single string.
|
|
37
|
+
|
|
38
|
+
Consequences:
|
|
39
|
+
|
|
40
|
+
1. **Time-to-first-byte is bounded by the slowest `@defer` block.** Nothing —
|
|
41
|
+
not even `<head>`/`<title>` or linked CSS/JS — reaches the browser until the
|
|
42
|
+
entire render finishes. A page with one slow hydrate-on-defer block delays
|
|
43
|
+
the whole response by that block's latency.
|
|
44
|
+
2. **No progressive paint.** With incremental hydration, `@defer (hydrate …)`
|
|
45
|
+
blocks _are_ rendered eagerly on the server, so their content already exists
|
|
46
|
+
in the DOM well before `whenStable` — but the buffered path holds all of it
|
|
47
|
+
until the last block resolves.
|
|
48
|
+
|
|
49
|
+
Streaming decouples TTFB from render completion and lets each block paint as it
|
|
50
|
+
resolves, which is the point of server rendering heavy/expensive subtrees behind
|
|
51
|
+
`@defer`.
|
|
52
|
+
|
|
53
|
+
## Design
|
|
54
|
+
|
|
55
|
+
Four pieces. How they interact over the lifetime of one request:
|
|
56
|
+
|
|
57
|
+
```mermaid
|
|
58
|
+
sequenceDiagram
|
|
59
|
+
autonumber
|
|
60
|
+
participant B as Browser
|
|
61
|
+
participant R as "renderStream (server)"
|
|
62
|
+
participant A as "@angular/core (SSR, patched)"
|
|
63
|
+
|
|
64
|
+
B->>R: GET /
|
|
65
|
+
Note over R: crawlers (bot user-agent) take the buffered render()<br/>path with a fully-resolved head instead of streaming
|
|
66
|
+
Note over R: platformServer + bootstrapApplication<br/>drives the platform directly
|
|
67
|
+
R-->>B: chunk 1 — document head + reconcile runtime<br/>+ empty stream region
|
|
68
|
+
Note over B: browser starts fetching CSS/JS at once<br/>— TTFB no longer waits on the slowest block
|
|
69
|
+
|
|
70
|
+
loop each @defer (hydrate) block, as it resolves — out of order
|
|
71
|
+
A->>R: __analogSsrDeferCapture(lContainer)
|
|
72
|
+
Note over R: serialize the block subtree one macrotask later,<br/>after change detection fills interpolations
|
|
73
|
+
R-->>B: data-analog-defer="sN" template + __analogPaint("sN")
|
|
74
|
+
Note over B: paints the block into the live region<br/>(progressive, resolution order)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Note over R: await whenStable() — all blocks resolved
|
|
78
|
+
Note over R: ɵrenderInternal → authoritative document<br/>(whole-document hydration annotation)
|
|
79
|
+
R-->>B: data-analog-head + data-analog-authoritative templates<br/>+ __analogReconcileHead() + __analogFinalize()
|
|
80
|
+
Note over B: apply the app-set title/meta to the live head,<br/>then swap body to the authoritative DOM
|
|
81
|
+
Note over B: Angular incremental hydration boots<br/>against the finalized DOM
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The head flushes before the app has rendered, each `@defer` block flushes the
|
|
85
|
+
moment it resolves (out of order), and the authoritative document — the only
|
|
86
|
+
thing hydration runs against — is the tail. The rest of this section covers each
|
|
87
|
+
piece in turn.
|
|
88
|
+
|
|
89
|
+
### 1. `renderStream` (`@analogjs/router/server`)
|
|
90
|
+
|
|
91
|
+
Drives the platform directly (`platformServer` + `bootstrapApplication` +
|
|
92
|
+
`ɵrenderInternal`) rather than calling `renderApplication`, so it can interleave
|
|
93
|
+
flushes with rendering. The `start()` of the returned `ReadableStream`:
|
|
94
|
+
|
|
95
|
+
- flushes `document` head + the reconcile runtime + an empty streaming region;
|
|
96
|
+
- installs `globalThis.__analogSsrDeferCapture`; on each resolved block it serializes
|
|
97
|
+
the block's live Domino subtree (a macrotask later, once change detection has
|
|
98
|
+
filled interpolations) and flushes it as a `<template>` + paint script;
|
|
99
|
+
- after `whenStable()`, calls `ɵrenderInternal(platformRef, appRef)` to produce
|
|
100
|
+
the authoritative, hydration-annotated document (byte-identical to a buffered
|
|
101
|
+
render) and flushes both its `<head>` (in a `<template data-analog-head>`) and
|
|
102
|
+
its body as the tail;
|
|
103
|
+
- falls back to a single buffered chunk for server-component requests, for
|
|
104
|
+
**crawlers** (bot user-agents, so they index a fully-resolved head — see
|
|
105
|
+
_Head & title_ below), or when the streaming primitive is absent, so output
|
|
106
|
+
matches the classic path.
|
|
107
|
+
|
|
108
|
+
### 2. Client reconcile runtime (`defer-reconcile-runtime.ts`)
|
|
109
|
+
|
|
110
|
+
A tiny inlined script exposing `window.__analogPaint(id)` (paints a streamed
|
|
111
|
+
block into the live region as it arrives — progressive, out of order),
|
|
112
|
+
`window.__analogReconcileHead()` (applies the authoritative `<head>`'s
|
|
113
|
+
title/meta/link to the live document — idempotent, so unchanged shell tags are
|
|
114
|
+
left alone), and `window.__analogFinalize()` (swaps the whole body to the
|
|
115
|
+
authoritative document before hydration boots, so the reconciled DOM matches a
|
|
116
|
+
buffered render byte-for-byte).
|
|
117
|
+
|
|
118
|
+
### 3. `deferStreamingPlugin` — the additive Angular seam (`@analogjs/platform`)
|
|
119
|
+
|
|
120
|
+
A Vite plugin (same shape and ssr-gate as the existing `i18nDefRegistryPlugin`)
|
|
121
|
+
that string-patches `@angular/core` during SSR builds. It is **purely additive**
|
|
122
|
+
— two edits keyed on single-occurrence anchors:
|
|
123
|
+
|
|
124
|
+
1. inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture` when a
|
|
125
|
+
block reaches `Complete` on the server, passing its live `lContainer`;
|
|
126
|
+
2. expose `collectNativeNodesInLContainer` on `globalThis.__analogSsrInternals` so
|
|
127
|
+
the renderer can serialize a block's subtree.
|
|
128
|
+
|
|
129
|
+
The transform is exported as a pure function (`injectDeferStreamingHook`) and
|
|
130
|
+
unit-tested against a bundle string. It is registered only when
|
|
131
|
+
`ssr && experimental.streaming`, so default builds are untouched.
|
|
132
|
+
|
|
133
|
+
**Angular version floor.** Streaming is gated on **Angular ≥ 21**. Incremental
|
|
134
|
+
hydration is a stable public API from v20 (`withIncrementalHydration`,
|
|
135
|
+
`@publicApi 20.0`), but v20's compiled FESM inlines the `DeferBlockStateEnd`
|
|
136
|
+
profiler event to its numeric ordinal, while v21+ keeps the symbolic
|
|
137
|
+
`ProfilerEvent.DeferBlockStateEnd` the anchor matches on — verified by checking
|
|
138
|
+
the published `@angular/core` FESM for v20/v21/v22. The gate lives in
|
|
139
|
+
`@analogjs/platform`: below the floor it warns and disables streaming (falling
|
|
140
|
+
back to buffered), and the value is reflected onto the options so the nitro
|
|
141
|
+
renderer selection and this plugin never disagree.
|
|
142
|
+
|
|
143
|
+
### 4. `ssrStreamRenderer` (`@analogjs/vite-plugin-nitro`)
|
|
144
|
+
|
|
145
|
+
An h3 event handler that returns the `ReadableStream` with chunked transfer
|
|
146
|
+
encoding, preserving the `x-analog-no-ssr` bypass. A `streaming: false` route
|
|
147
|
+
rule sets an `x-analog-no-streaming` response header (mirroring how `ssr: false`
|
|
148
|
+
becomes `x-analog-no-ssr`); `renderStream` then emits the buffered `render()`
|
|
149
|
+
output and the handler returns it as a normal, non-chunked document — so a route
|
|
150
|
+
can opt out of streaming while keeping SSR. The buffered `ssrRenderer` is
|
|
151
|
+
unchanged.
|
|
152
|
+
|
|
153
|
+
### Runtime & concurrency
|
|
154
|
+
|
|
155
|
+
The per-`@defer` resolution signal is a process-global entry point (the patched
|
|
156
|
+
`@angular/core` can only call one `globalThis` function), so `renderStream`
|
|
157
|
+
installs a **stable dispatcher once** and routes each resolved block to the
|
|
158
|
+
render that owns it using `AsyncLocalStorage` (`node:async_hooks`). Concurrent
|
|
159
|
+
renders in one process are therefore isolated — a block resolving in render A is
|
|
160
|
+
never enqueued into render B's stream.
|
|
161
|
+
|
|
162
|
+
Runtime portability is delegated to **Nitro**: Analog does not shim runtimes.
|
|
163
|
+
`node:async_hooks` is provided across Nitro's deployment presets (native Node,
|
|
164
|
+
and non-Node targets via `nodejs_compat` / unenv), so `renderStream` depends on
|
|
165
|
+
it directly rather than carrying its own edge fallbacks.
|
|
166
|
+
|
|
167
|
+
### Resilience to Angular drift
|
|
168
|
+
|
|
169
|
+
Because the patch anchors on internal symbol names, `deferStreamingPlugin`
|
|
170
|
+
classifies each `@angular/core` module: it warns if it finds the `@defer`
|
|
171
|
+
runtime but the anchors have drifted (Angular changed internals), and at
|
|
172
|
+
`buildEnd` if that module was never encountered — instead of silently producing
|
|
173
|
+
a build that falls back to buffered. `renderStream` likewise warns in dev when
|
|
174
|
+
the streaming primitive is absent at request time.
|
|
175
|
+
|
|
176
|
+
### Honest boundary
|
|
177
|
+
|
|
178
|
+
Angular's hydration annotation is **whole-document** — the root component's `ngh`
|
|
179
|
+
index references every `@defer` container — so the authoritative hydration
|
|
180
|
+
payload can only be finalized once all blocks have resolved. Therefore
|
|
181
|
+
**rendering streams progressively, but hydration begins when the tail arrives.**
|
|
182
|
+
The blocks are effectively sent twice (progressive preview during render +
|
|
183
|
+
authoritative copy in the tail); this is the byte cost measured below, and the
|
|
184
|
+
target of the "Future work" section.
|
|
185
|
+
|
|
186
|
+
### Head & title
|
|
187
|
+
|
|
188
|
+
Because the shell `<head>` is flushed **before** the app renders, a title or
|
|
189
|
+
meta set _during_ render (`Title`/`Meta` services, route meta) is not yet known
|
|
190
|
+
when the head goes out. Two mechanisms keep this correct, mirroring how Nuxt
|
|
191
|
+
handles the identical problem in its streaming renderer:
|
|
192
|
+
|
|
193
|
+
1. **Finalize-time reconcile (interactive clients).** The authoritative `<head>`
|
|
194
|
+
is streamed in the tail (`<template data-analog-head>`) and
|
|
195
|
+
`__analogReconcileHead()` applies its title/meta/link to the live document
|
|
196
|
+
before hydration boots. This is the same "inject the resolved head late"
|
|
197
|
+
strategy Nuxt uses — Nuxt flushes a head _shell_ then streams
|
|
198
|
+
`renderSSRHeadSuspenseChunk` `<script>` patches as Suspense boundaries
|
|
199
|
+
resolve; Analog's whole-document model needs only a single patch at the tail,
|
|
200
|
+
since the authoritative head is known in one shot at `whenStable`.
|
|
201
|
+
2. **Buffered path for crawlers.** A late `<script>` reconcile does not help a
|
|
202
|
+
bot that doesn't execute it, so crawlers (matched by user-agent) are routed
|
|
203
|
+
to the buffered `render()` path, whose `<head>` is fully resolved and
|
|
204
|
+
byte-identical to the classic render. Nuxt does the same — its `prefersStream`
|
|
205
|
+
check excludes bot user-agents from streaming.
|
|
206
|
+
|
|
207
|
+
Net: JS clients get the correct dynamic head (with a brief shell-title interval
|
|
208
|
+
before finalize), and crawlers get a fully-resolved head with no streaming
|
|
209
|
+
scaffolding. A static `<title>` in `index.html` is correct on every path.
|
|
210
|
+
|
|
211
|
+
## Benchmarks
|
|
212
|
+
|
|
213
|
+
In-process (no network), dev-mode Angular, synthetic per-block dependency
|
|
214
|
+
delays, median of 12 cold-cache runs. These measure render + serialization +
|
|
215
|
+
flush scheduling, **not** wire TTFB — on a real network the TTFB win is larger,
|
|
216
|
+
because the browser can begin fetching linked assets during the server's defer
|
|
217
|
+
wait.
|
|
218
|
+
|
|
219
|
+
**Latency shape (2 blocks, deps [150, 60] ms):**
|
|
220
|
+
|
|
221
|
+
| | Buffered | Streaming |
|
|
222
|
+
| ------------------- | -------- | ---------- |
|
|
223
|
+
| TTFB | 201 ms | **0.6 ms** |
|
|
224
|
+
| first block visible | — | 70 ms |
|
|
225
|
+
| complete | 201 ms | 201 ms |
|
|
226
|
+
|
|
227
|
+
TTFB is flat (~0.5 ms) regardless of block latency; completion time is identical
|
|
228
|
+
(streaming adds negligible CPU). The win is entirely latency-shape.
|
|
229
|
+
|
|
230
|
+
**Byte cost (heavier ~1.5 KB blocks):** the buffered document is 3716 bytes; the
|
|
231
|
+
streaming response is 7439 bytes (**+100%**), because each block ships in both
|
|
232
|
+
the progressive preview and the authoritative tail. The overhead is smaller in
|
|
233
|
+
relative terms with more/larger blocks (shell overhead amortizes) but is the
|
|
234
|
+
main downside of the additive-seam design.
|
|
235
|
+
|
|
236
|
+
### Core Web Vitals (over HTTP, throttled)
|
|
237
|
+
|
|
238
|
+
The in-process numbers above deliberately exclude the network. To measure the
|
|
239
|
+
client-perceived shape, `apps/streaming-app` was built for production, served
|
|
240
|
+
over HTTP, and driven in Chromium under Lighthouse-style throttling (Slow-4G,
|
|
241
|
+
4× CPU). The same route (`/`) was measured both ways — a browser user-agent
|
|
242
|
+
streams, a Googlebot user-agent takes the buffered fallback — so the page,
|
|
243
|
+
bundle, and ~600 ms `httpResource` dependency are identical and only the render
|
|
244
|
+
strategy differs. Median of 9 runs (reproduce with
|
|
245
|
+
[`tools/cwv-bench.mjs`](../../apps/streaming-app/tools/cwv-bench.mjs)):
|
|
246
|
+
|
|
247
|
+
| Metric | Streamed | Buffered | Note |
|
|
248
|
+
| ------------ | ---------- | -------- | ----------------------------------- |
|
|
249
|
+
| TTFB | **3 ms** | 608 ms | head flushes before the app renders |
|
|
250
|
+
| FCP | **200 ms** | 648 ms | first `@defer` block paints early |
|
|
251
|
+
| LCP | 660 ms | 648 ms | wash — see below |
|
|
252
|
+
| CLS | 0.006 | 0.000 | finalize body-swap; both "good" |
|
|
253
|
+
| Fully loaded | 1996 ms | 2434 ms | assets fetch during the defer wait |
|
|
254
|
+
|
|
255
|
+
**TTFB and FCP are the wins**, and they are large: buffered blocks the first
|
|
256
|
+
byte on the full render (which waits on the 600 ms data), while streaming
|
|
257
|
+
flushes the head immediately and paints the first resolved block at ~200 ms.
|
|
258
|
+
|
|
259
|
+
**LCP is a wash, and the reason is instructive.** The largest element here is the
|
|
260
|
+
static shell paragraph, and in this implementation the eager app shell (the
|
|
261
|
+
non-`@defer` chrome: `<h1>`, intro copy, eager components) renders in the
|
|
262
|
+
**authoritative tail**, flushed only once the app is stable (~600 ms) — only
|
|
263
|
+
`@defer` blocks stream early. So the LCP element is gated behind the same wait as
|
|
264
|
+
the buffered path. **Streaming improves LCP only when the LCP element streams
|
|
265
|
+
early** — i.e. lives in an early `@defer` block, or in a shell that is itself
|
|
266
|
+
streamed (see [Stream the eager shell early](#stream-the-eager-shell-early)).
|
|
267
|
+
|
|
268
|
+
**CLS is a small, real regression** (0.006 vs a perfect 0.000). `__analogFinalize`
|
|
269
|
+
replaces the whole body in one `replaceChildren`, so the progressively-painted
|
|
270
|
+
preview is torn down and rebuilt; that swap registers as a shift. It is well
|
|
271
|
+
within the "good" bar (< 0.1) for this app but scales with above-the-fold content.
|
|
272
|
+
|
|
273
|
+
**INP is equivalent by construction** — both paths ship the identical client
|
|
274
|
+
bundle with `withIncrementalHydration()`, so server render strategy does not
|
|
275
|
+
change interaction cost (measured interactions stayed at/under the ~16 ms
|
|
276
|
+
event-timing floor on both).
|
|
277
|
+
|
|
278
|
+
Caveats: localhost, one small route, one machine, the bot-UA fallback standing in
|
|
279
|
+
for buffered. Absolute values are optimistic versus the field; the **deltas** are
|
|
280
|
+
what transfer.
|
|
281
|
+
|
|
282
|
+
## Validation
|
|
283
|
+
|
|
284
|
+
**End-to-end** — a real Vite/nitro app (`apps/streaming-app`) driven in Chromium:
|
|
285
|
+
chunked HTTP with head-first / out-of-order block / tail ordering; an eager
|
|
286
|
+
component and `@defer` blocks on `hydrate on immediate`, `httpResource`-backed
|
|
287
|
+
data, and `hydrate on interaction` all hydrate and stay interactive; a
|
|
288
|
+
title/meta set during render is reconciled onto the live head after the stream;
|
|
289
|
+
a Googlebot user-agent gets the buffered render with the resolved head and no
|
|
290
|
+
streaming scaffolding; a `streaming: false` route rule serves a buffered,
|
|
291
|
+
non-chunked document. Zero console errors.
|
|
292
|
+
|
|
293
|
+
**Unit tests**
|
|
294
|
+
|
|
295
|
+
- `@analogjs/platform`: the Angular-core patch transform and drift inspection
|
|
296
|
+
(`injectDeferStreamingHook`, `inspectAngularCoreModule`), the version gate
|
|
297
|
+
(`streamingSupportedOnAngular`), and the `streaming: false` → header route-rule
|
|
298
|
+
transform.
|
|
299
|
+
- `@analogjs/router/server`: the document-slicing helpers (`headInner`,
|
|
300
|
+
`bodyInner`, `afterBodyOpen`), the buffered-fallback request checks
|
|
301
|
+
(`isLikelyBot`, `streamingDisabledByRoute`), and the client reconcile runtime
|
|
302
|
+
(`__analogPaint`, `__analogReconcileHead`, `__analogFinalize`) exercised
|
|
303
|
+
against a DOM.
|
|
304
|
+
|
|
305
|
+
**Concurrency** — per-render `@defer` capture is routed through
|
|
306
|
+
`AsyncLocalStorage` (see _Runtime & concurrency_), so interleaved renders in one
|
|
307
|
+
process cannot enqueue into each other's streams.
|
|
308
|
+
|
|
309
|
+
The prototype's single seam is the plugin-applied Angular patch; everything else
|
|
310
|
+
is standard Analog/Angular.
|
|
311
|
+
|
|
312
|
+
## Example usage
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
// main.server.ts
|
|
316
|
+
import { renderStream } from '@analogjs/router/server';
|
|
317
|
+
import { config } from './app/app.config.server';
|
|
318
|
+
import { App } from './app/app';
|
|
319
|
+
|
|
320
|
+
export default renderStream(App, config);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// vite.config.ts
|
|
325
|
+
export default defineConfig({
|
|
326
|
+
plugins: [analog({ experimental: { streaming: true } })],
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Opt a route out of streaming (buffered render for that route) with a route rule,
|
|
331
|
+
the same way `ssr: false` disables SSR:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// vite.config.ts
|
|
335
|
+
analog({
|
|
336
|
+
experimental: { streaming: true },
|
|
337
|
+
nitro: {
|
|
338
|
+
routeRules: {
|
|
339
|
+
'/report': { streaming: false }, // buffered render, no streaming
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Future work
|
|
346
|
+
|
|
347
|
+
### Single-send via a per-block incremental annotator
|
|
348
|
+
|
|
349
|
+
The +100% byte cost comes entirely from re-sending block content in the
|
|
350
|
+
authoritative tail. It can be eliminated by **annotating each block for
|
|
351
|
+
hydration at resolve time** so the block is streamed once, already carrying its
|
|
352
|
+
`ngh`/`jsaction`/`ngb`, and the tail is the shell with block content sliced out
|
|
353
|
+
(replaced by slot markers) plus the transfer state.
|
|
354
|
+
|
|
355
|
+
This has been prototyped and validated (single-send document hydrates identically
|
|
356
|
+
to buffered, 5/5). The mechanism is a larger patch to `@angular/core`:
|
|
357
|
+
|
|
358
|
+
- **A one-line edit inside `serializeLContainer`** so the defer-block id is
|
|
359
|
+
memoized by container when streaming is active, instead of `d${deferBlocks.size}`.
|
|
360
|
+
This is what keeps per-block annotation and the final whole-document pass
|
|
361
|
+
agreeing on ids — ids become **resolution-order** and consistent everywhere.
|
|
362
|
+
- **An appended driver** (`__analogSsrStreamBegin/End`, `__analogSsrAnnotateBlock`) that
|
|
363
|
+
reuses Angular's own `serializeLContainer` to annotate a single resolved
|
|
364
|
+
block's DOM.
|
|
365
|
+
|
|
366
|
+
**Byte results (same ~1.5 KB × 2 block scenario):**
|
|
367
|
+
|
|
368
|
+
| | bytes | vs buffered |
|
|
369
|
+
| --------------------------------------- | ----- | ----------- |
|
|
370
|
+
| buffered (baseline) | 3716 | — |
|
|
371
|
+
| single-send (incremental annotator) | 4603 | **+24%** |
|
|
372
|
+
| double-send (this RFC's implementation) | 7439 | +100% |
|
|
373
|
+
|
|
374
|
+
The residual +24% is streaming scaffolding (per-block `<template>` wrappers,
|
|
375
|
+
mount scripts, slot markers, runtime), not re-sent content, and it amortizes
|
|
376
|
+
toward `buffered + small constant` as blocks grow. There is a **crossover**: for
|
|
377
|
+
very small blocks the per-block wrapper (~90 B) exceeds the saved content, so
|
|
378
|
+
single-send is net-negative — it wins only for realistically-sized `@defer`
|
|
379
|
+
blocks.
|
|
380
|
+
|
|
381
|
+
**Why this is deferred:** unlike the additive seam (two append-only anchors,
|
|
382
|
+
one private symbol), the incremental annotator _edits the middle of_
|
|
383
|
+
`serializeLContainer` and references several more private core symbols
|
|
384
|
+
(`SerializedViewCollection`, `isIncrementalHydrationEnabled`,
|
|
385
|
+
`IS_EVENT_REPLAY_ENABLED`, …). It rides entirely on non-minified FESM symbol
|
|
386
|
+
names. That maintenance surface is exactly the line where a framework-carried
|
|
387
|
+
string patch stops being reasonable.
|
|
388
|
+
|
|
389
|
+
### Upstream primitive
|
|
390
|
+
|
|
391
|
+
Both the additive seam and the incremental annotator are prototypes for a
|
|
392
|
+
capability that belongs in Angular proper: a first-class streaming SSR entry
|
|
393
|
+
point (e.g. `renderApplicationStream`) that emits `@defer` blocks progressively
|
|
394
|
+
with per-block hydration annotation. The Analog prototype demonstrates the design
|
|
395
|
+
and quantifies the payoff; the durable form is an upstream API, not a patch a
|
|
396
|
+
framework maintains against private symbols.
|
|
397
|
+
|
|
398
|
+
### Stream the eager shell early
|
|
399
|
+
|
|
400
|
+
The [Core Web Vitals](#core-web-vitals-over-http-throttled) measurement shows LCP
|
|
401
|
+
is not improved, because the eager app shell (the non-`@defer` chrome) ships only
|
|
402
|
+
in the authoritative tail — today the first flush is just the head plus an empty
|
|
403
|
+
stream region, and only `@defer` blocks stream before the tail. Streaming the
|
|
404
|
+
eager shell in the first flush, into its final document position, would let the
|
|
405
|
+
LCP element paint at ~FCP time rather than at `whenStable`; and because the shell
|
|
406
|
+
would arrive already in place rather than being swapped in at finalize, it would
|
|
407
|
+
also shrink the small CLS cost the body-swap introduces. This is the
|
|
408
|
+
highest-leverage CWV follow-up and pairs naturally with progressive-paint
|
|
409
|
+
positioning below.
|
|
410
|
+
|
|
411
|
+
### Other
|
|
412
|
+
|
|
413
|
+
- Progressive-paint positioning (mount blocks into their document position as they
|
|
414
|
+
arrive, rather than a preview region) — orthogonal to bytes, and a prerequisite
|
|
415
|
+
for streaming the eager shell in place.
|
|
416
|
+
- A `create-analog` template and nitro config flag once the primitive stabilizes.
|