feat: add support for Streaming SSR#2419
Conversation
Introduces a lightweight alternative to the standard render() that uses a custom Renderer2 writing to a token tree instead of a full DOM emulation library (Happy DOM/Domino). Targets improved SSR performance and edge runtime portability. - DOM shim: minimal Document/Element/Text/Comment with HTML parser - StringRendererFactory2: token-tree-based Renderer2 implementation - ServerSharedStylesHost: optional styles collector without DOM access - renderToString(): entry point wiring shim + renderer + renderApplication() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add document.location with origin/href/protocol for ServerPlatformLocation - Add element.children getter (element-only childNodes) for DominoAdapter.getBaseHref - Add component-level benchmarks (vitest bench) - Add E2E benchmark comparing render() vs renderToString() on analog-app Result: renderToString() is ~21% faster (mean), ~23% faster (median) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pulls the function out of render.ts so renderToString() can call it too without duplicating the comment/logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RendererFactory2 is registered by BrowserModule (transitively via ServerModule) at the application/environment injector level. Providing the StringRendererFactory2 override in platformProviders left it in a parent scope that was shadowed, so DomRendererFactory2 was injected and the StringRenderer never ran — injectIntoDocument then wiped the host element by setting innerHTML to an empty string. Move RendererFactory2 and BEFORE_APP_SERIALIZED into a per-request ApplicationConfig so they sit at the same injector level as Angular's defaults. Also call resetComponentDefTViews() before each render to match render() and avoid frozen $localize templates across requests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nderer Three correctness fixes in the string-based SSR pipeline: - Text content was emitted unescaped, so user-supplied interpolation could inject live HTML. Split escapeHTML into escapeText (for content) and escapeAttr (for attribute values), and thread the parent tag through serialization so script/style content stays raw. - innerHTML reused the TextToken path, conflating raw HTML with text. Add a separate Raw token / ShimRaw node for pre-escaped HTML and route innerHTML through them. - serializeToken merged classes/styles back into token.attributes, which doubled them on every additional serialize call. Build the final attribute set in a local Map without mutating the token. Also stop round-tripping the rendered HTML through appRoot.innerHTML in injectIntoDocument — the shim parser does not decode entities, so a re-parse + re-serialize doubled '&' to '&amp;'. Attach a ShimRaw node to the host instead so the rendered string passes through untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers parser round-trip, void and raw-text element handling, attribute and text escaping (XSS guard), comments, basic StringRenderer ops, innerHTML pass-through, class/style merging, and serializer stability across repeated calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds string-renderer-v2.ts as a parallel implementation that replaces per-element Map/Set allocations with lazy strings/arrays: - attrs: flat string[] of [k, v, k, v]; undefined when empty - classNames: space-delimited string; '' when empty - styles: flat string[]; undefined when empty - no prev/next sibling links — nextSibling computed from children - serialize via push+join with single-pass escape and a no-op fast path Parity test confirms V2 matches V1 byte-for-byte for the same op sequence. Benchmarks (build + serialize on a 50-component tree, ~250 elements): build-only: V2 33.9k ops/s vs V1 21.9k ops/s 1.55x full render: V2 29.3k ops/s vs V1 21.8k ops/s 1.35x serialize-only: V2 29.2M ops/s vs V1 35.7M ops/s 0.82x Build phase wins clearly — that's the lazy-allocation thesis paying off, and it's what scales with element count under real Angular SSR (one renderer call per DOM op). Serialize-only loses ~20% because the V8 rope-string optimization makes V1's '+=' approach hard to beat at this output size; that path could be tightened further but has a small share of the full-cycle cost. Kept alongside V1 (no replacement) so we can iterate on V2 and switch the production wiring only after a wider correctness pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-on changes to the V2 prototype:
1. Replace push-into-array + join with '+=' string concat. V8's rope
string optimization handles this well at SSR-typical output sizes
(~KB), and removes the per-element array allocation. Also inline the
raw-text-element check (script/style only) and cache the lowercased
tagName at create time.
2. Route setAttribute('class', x) directly to classNames and
setAttribute('style', x) directly to styleText, so the serializer no
longer scans the flat attrs array looking for class/style keys to
merge. Same for the corresponding setProperty paths.
Combined effect on the 50-component benchmark:
build-only: V1 22.6k vs V2 36.7k 1.62x (was 1.55x)
full render: V1 22.7k vs V2 31.5k 1.39x (was 1.35x)
serialize-only: V1 35.4M vs V2 36.0M 1.02x (was 0.82x)
Serialize flipped from losing to ~parity, and build-only widened — so
the renderer is now strictly faster than V1 across all phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds renderToStringFast() that keeps a single Angular platform alive
across requests instead of creating and destroying one per render. The
platform owns DominoAdapter init, the platform injector, PlatformState,
and PlatformLocation; recreating those every request is pure overhead.
DOCUMENT and RendererFactory2 are overridden at the application
injector per-request, so the component tree always sees the per-request
shim. Platform-level singletons (PlatformState, PlatformLocation) hold
the platform-creation values, but we sidestep them: serialization goes
straight through serializeDocument(shimDocument), bypassing
PlatformState.renderToString().
End-to-end on the analog-app at '/':
render() 5.28ms (baseline, default Domino path)
renderToString() 4.22ms (string renderer + per-request platform)
renderToStringFast() 2.81ms (string renderer + cached platform)
renderToStringFast is 46.9% faster than render() — almost half.
Caveats (intentional, prototype-level):
- PlatformLocation is constructed once per platform from
INITIAL_CONFIG.url, so apps reading platformLocation.href directly
will see stale values. Angular Router is unaffected because it
receives the URL via the bootstrap context.
- Hydration annotation is currently skipped. Re-enable by calling
ɵannotateForHydration before serializeDocument when needed.
destroySharedPlatform() is exported for tests and graceful shutdown.
Wired up alongside render() and renderToString() in apps/analog-app's
bench-ssr.mjs via main.server.fast.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…check script Verified the cached-platform prototype against render() and renderToString() on the analog-app. The fast path is missing four things that renderInternal does in the standard path: - hydration markers (ngh attrs, <!--nghm-->, integrity marker) - ng-server-context attribute on the root component - event-dispatch script removal (leaks from index.html) - TransferState ng-state script (missing entirely) The renderer-level gaps (1-3) all stem from skipping prepareForHydration and appendServerContextInfo when bypassing renderInternal. Item 4 needs investigation — either transferStore.isEmpty is true under the cached platform, or the BEFORE_APP_SERIALIZED hook from provideServerRendering() isn't being collected when EnvironmentProviders are spread into the per-request ApplicationConfig. Updated the docstring with the full gap list and how to wire each one back in. Added apps/analog-app/parity-check.mjs as a tool for diffing HTML output across the three renderers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…enderToStringFast
Replicates the four pieces of platform-server's renderInternal that the
prototype was bypassing:
1. prepareForHydration: when IS_HYDRATION_DOM_REUSE_ENABLED, append the
SSR content-integrity marker comment, run ɵannotateForHydration on
the application, then either insert the event-replay script or
strip the inlined event-dispatch script. When hydration is off,
just strip the dispatch script.
2. appendServerContextInfo: read ɵSERVER_CONTEXT from the env injector
and set 'ng-server-context' on each bootstrapped component's host
via that component's renderer.
3. TransferState ng-state script now carries content. The shim's
ShimNode held textContent as a plain field; setting it on a script
element didn't create a child text node, so serialization emitted
an empty <script>. Move textContent to a real accessor:
- ShimElement: get walks descendants, set replaces children with
a single text node.
- ShimText / ShimComment: get/set proxies nodeValue.
4. Annotated ordering: prepareForHydration → appendServerContextInfo →
BEFORE_APP_SERIALIZED hooks (TransferState's serializer runs here,
reading the __nghData__ that step 1 wrote into TransferState).
End-to-end on the analog-app at '/':
render() 5.25ms (baseline, 1919B)
renderToString() 4.60ms (12.3% faster, 1021B)
renderToStringFast() 3.02ms (42.4% faster, 1021B)
renderToString() and renderToStringFast() are now byte-identical (one
cosmetic attribute-order difference). Cost of parity: +0.21ms in the
fast path (gives back ~4 percentage points of the original 46.9% win).
The V1 path also slowed by +0.38ms — same root cause, same gain in
correctness: its TransferState script previously serialized empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The V2 string renderer previously serialized empty attributes using the HTML5 bare-name shorthand (e.g. `<div hidden>`). Domino — and therefore the existing `render()` output — always quotes them as `name=""`. Both forms are valid HTML, but matching Domino keeps byte-equivalent output across renderers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domino lowercases attribute names on write per the HTML spec, so attributes like `routerLink` or `viewBox` (without namespace) come out of `render()` as `routerlink`/`viewbox`. The V2 string renderer kept the original case, producing visibly different bytes for the same DOM operation. Lowercase non-namespaced attribute names at `setAttribute` / `removeAttribute` time. Namespaced attrs (xml:lang, xlink:href) keep their original case since their local-name resolution depends on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds emulated view encapsulation and component-style injection to the
V2 string renderer, closing the largest parity gap with `render()`:
- `StringRendererFactory2V2` now caches a renderer per `type.id` and
dispatches by `type.encapsulation`, mirroring DomRendererFactory2.
Server-side ShadowDom falls back to Emulated.
- `EmulatedStringRendererV2` adds `_ngcontent-${compId}` on every
element it creates and `_nghost-${compId}` to the host (via the
factory's `applyToHost` callback).
- Both Emulated and None renderers route component CSS through the
default `@angular/platform-browser` `SharedStylesHost`. The default
implementation already speaks plain DOM, so it works directly with
the lightweight shim — no custom server styles host is needed.
- `renderToStringFast` provides the factory via `useFactory` so it
can pull `SharedStylesHost` and `APP_ID` from the application
injector.
After this change, the analog-app `/` route comes out byte-equivalent
to `render()` for component encapsulation: `<style ng-app-id>` block,
`_nghost-…`, and `_ngcontent-…` attributes are all present and match.
Remaining gap is event replay (`jsaction="…"` markers + the
`ng-event-dispatch-contract` script), which depends on tracking event
listener registrations through the renderer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ToString Keep the cached-platform implementation (formerly renderToStringFast) as the one renderToString export. Drop the per-request renderApplication path and the V1 token renderer; the V2 renderer becomes string-renderer.ts with the V2 suffixes removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
…hes measure real work The synthetic tree was built under an orphaned element, so the serialize-only and full-cycle benches were serializing an empty root token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
…dration annotation Angular's annotateForHydration checks node.isConnected to decide whether a node is part of the serialized output, and stamps jsaction event-replay attributes via direct nodeType/getAttribute/setAttribute/hasAttribute calls. Plain-object tokens had none of these, so every renderer-created node was treated as disconnected: component views under the root were dropped from __nghData__ and event replay was silently disabled. Convert tokens to classes so the DOM-facing surface lives on the prototype with zero per-element allocation cost. renderToString output now matches render() except for whitespace and attribute-order cosmetics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
…rors Destroy the cached SSR platform and exit explicitly — its event-loop handles kept both scripts alive after results printed. Restore the suppressed console in a finally block so render failures are no longer swallowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBu5vifftgU92BmCMyQs5
… renderer
Adds `renderStream`, which returns a ReadableStream and flushes bytes DURING
the render rather than after it. It drives the platform directly
(`platformServer` + `bootstrapApplication` + `ɵrenderInternal`) so it can
interleave flushes with rendering:
1. the document head + a small client reconcile runtime flush immediately,
before the app finishes rendering;
2. each `@defer (hydrate …)` block flushes the moment it resolves on the
server — out of document order — so a slow block never holds back an
earlier one;
3. once the app is stable, the authoritative, fully hydration-annotated
document flushes as the tail (byte-identical to a buffered render), which
the runtime swaps in before Angular's incremental hydration runs.
Angular's hydration annotation is whole-document, so the authoritative payload
is necessarily the tail: rendering streams progressively, hydration begins once
the tail arrives. Depends on an Analog-provided per-block resolution hook
exposed on globalThis (`__analogSsrDeferCapture` / `__analogSsrInternals`); when
absent, `renderStream` degrades to a single buffered chunk so behaviour matches
the classic `render()` path, which remains the default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Adds an h3 event handler that returns the ReadableStream produced by `renderStream` with chunked transfer encoding, so the head and each resolved `@defer` block stream to the client. Preserves the `x-analog-no-ssr` bypass. The existing buffered `ssrRenderer` is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
…treaming SSR Adds a Vite plugin that injects the streaming-SSR per-`@defer` block resolution hook into `@angular/core` during SSR builds, so `renderStream` can flush blocks as they resolve without hand-patching node_modules. Mirrors the ssr-gated transform shape of `i18nDefRegistryPlugin`. Exposes the hook and subtree collector under Analog-prefixed globals (`__analogSsrDeferCapture` / `__analogSsrInternals`) to avoid collision with Angular internals. The transform is exported separately as a pure function so it is unit-tested against a bundle string. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
…eaming plugin Registers `deferStreamingPlugin()` when `ssr && experimental.streaming`, gating the streaming-SSR Angular patch behind an opt-in flag under a nested `experimental` namespace. Off by default, so existing SSR builds are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Documents the additive-seam streaming design (renderStream + deferStreamingPlugin + ssrStreamRenderer) as implemented on this branch, with the TTFB/byte benchmarks. Records the per-block incremental annotator (single-send) as future work — including its measured byte savings and why its larger patch surface argues for a first-class upstream `renderApplicationStream` primitive rather than a framework-carried patch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
…ency safety The streaming capture hook was stored in a single `globalThis` slot, so two renderStream calls in one process would clobber each other — a block resolving in render A could be enqueued into render B's stream. Install a stable dispatcher once and route each resolved `@defer` block to the render whose async context it fired in, via AsyncLocalStorage. Concurrent renders are now isolated (verified: two interleaved renders each receive only their own blocks). Also surfaces a dev warning when `renderStream` is used but the streaming primitive is absent, instead of silently falling back to buffered rendering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
The streaming patch anchors on `@angular/core` internal symbol names, so an Angular version that changes them would make the transform a silent no-op and degrade streaming to buffered with no signal. Add `inspectAngularCoreModule` to distinguish "not the target module" from "this IS the @defer runtime but the anchors drifted", and have the plugin warn on drift, and at buildEnd if the @defer runtime module was never encountered. Adds tests for the classifier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
… RFC Documents that renderStream isolates concurrent renders via AsyncLocalStorage and that runtime portability (node:async_hooks on non-Node targets) is delegated to Nitro's deployment presets rather than shimmed in Analog. Adds the drift / never-applied build warnings and the concurrency test to the design and validation sections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
A @defer block can reach `Complete` more than once during a render, and resolutions can also fire during the authoritative-tail pass. Both cases streamed duplicate `<template data-analog-defer>` blocks (4 templates for 2 blocks in a real zone.js + file-router build). Track streamed containers in a `seen` Set and stop capturing before serializing the tail so each block streams exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
…treaming Add a nested `experimental.streaming` option to the nitro plugin. When enabled, wire the `#ANALOG_SSR_RENDERER` virtual module to `ssrStreamRenderer` instead of the buffered `ssrRenderer`, leaving the default (buffered) path unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Add a Mermaid sequence diagram to the RFC's Design section showing the request → head-first flush → out-of-order block previews → authoritative tail → hydration flow. Document head/title handling in the Honest boundary section: the finalize-time head reconcile for interactive clients and the buffered fallback for crawlers, citing Nuxt's streaming renderer as prior art. Note the browser-validated streaming-app in Validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
The streaming patch anchors on compiled @angular/core symbols. Incremental hydration is a stable public API from v20, but v20's FESM inlines the DeferBlockStateEnd profiler event to its numeric ordinal, whereas v21+ keeps the symbolic ProfilerEvent.DeferBlockStateEnd form the anchor matches — verified against the published v20/v21/v22 core bundles. Keying on the literal ordinal would be fragile, so v21 is the floor. Detect the installed @angular/core major from the project root and, when experimental.streaming is requested below the floor, warn and disable it. The gated value is written back onto the platform options so the nitro renderer selection and the deferStreamingPlugin registration stay consistent — the streaming renderer is never selected without the patch being applied. An undetectable version does not block (the build-time anchor-drift detection remains the backstop). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Remove `renderToString` and its custom token-tree renderer (`StringRendererFactory2`, `StringRenderer`, the DOM shim, benchmarks, and parity harness). It was a separate performance prototype (render straight to a string, no Domino DOM) and is unrelated to progressive streaming SSR: `renderStream` renders into a real Domino DOM via `platformServer` + `ɵrenderInternal` and needs live nodes for per-@defer serialization and whole-document hydration annotation, which the DOM-less string renderer cannot provide. The default `render()` path is unchanged. Removes the `renderToString`/`destroySharedPlatform` exports, the `server/src/ssr/*` renderer + shim + specs + benchmark, the RFC, and the analog-app string entry, benchmark, and parity scripts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Add a per-route opt-out for progressive streaming SSR, mirroring how `ssr: false` disables SSR. A `streaming: false` route rule is translated by the platform plugin into an `x-analog-no-streaming` response header; `renderStream` reads it and emits the buffered `render()` output for that request instead of streaming, and the ssrStreamRenderer handler returns it as a normal, non-chunked document (content-length, no streaming scaffolding) while SSR + incremental hydration still work. Augments the nitropack route-rule types with `streaming?: boolean` in both @analogjs/platform and @analogjs/vite-plugin-nitro. Only meaningful when `experimental.streaming` is enabled; harmlessly ignored otherwise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
A `/buffered` route with a `streaming: false` route rule, to exercise the per-route opt-out end to end: the response is a buffered document (no chunked encoding, no streaming scaffolding) whose deferred block still hydrates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Add a page under Server Side Rendering (after the Overview) covering enabling `experimental.streaming` + `renderStream`, the Angular 21+ / incremental hydration requirement, streaming `@defer` blocks, dynamic title/meta handling, and opting a route out with a `streaming: false` route rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Remove the experimental, never-released server-only components feature: the client `ServerOnly` bridge component, the server-side renderer (`renderServerComponent`, `serverComponentRequest`), and the static props/outputs tokens (`provideStaticProps`, `injectStaticProps`, `injectStaticOutputs`). The `render()` and `renderStream()` paths no longer branch on `X-Analog-Component` / `/_analog/components` requests. Also removes the analog-app example (the `hello` server component and the `server`/`client` demo pages). The shared `cache-key` helper is kept — it is also used by the request-context interceptor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Open with a concise "Analog supports…" sentence matching the Server Side Rendering guide, and use a `markup` code fence for the template example. `angular-html` isn't a registered Prism language in Docusaurus, and `html` makes Prettier reformat the Angular `@defer` control-flow; `markup` is Prism's base HTML language (so it highlights) and Prettier leaves it untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
…ntime Extract the pure streaming helpers out of render-stream into utils so they can be tested without driving the platform: the document slicers (`headInner`, `bodyInner`, `afterBodyOpen`) into `utils/stream-html`, and the buffered-fallback request checks (`isLikelyBot`, `streamingDisabledByRoute`) into `utils/stream-request`. Add specs for both, plus a jsdom spec that evaluates the emitted client runtime and asserts `__analogPaint`, `__analogReconcileHead` (title + idempotent meta upsert), and `__analogFinalize` behave against a real DOM. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
Replace the prototype-era test counts with what the committed tree actually covers: the Chromium end-to-end streaming-app run, the platform and router unit tests, and the AsyncLocalStorage-based concurrency isolation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7utNvoMVtkyLsR9uf7nxM
✅ Deploy Preview for analog-blog ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for analog-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for analog-app ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
This PR touches multiple package scopes: Please confirm the changes are closely related. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughThis PR adds experimental progressive streaming SSR across router, platform, and Nitro plugin packages, plus a new streaming demo app and RFC/docs. It introduces Estimated code review effort: 5 (Critical) | ~120 minutes Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant renderStream
participant AngularApp
participant Stream
Client->>renderStream: request(url, document, serverContext)
renderStream->>renderStream: isLikelyBot / streamingDisabledByRoute check
alt buffered fallback
renderStream->>AngularApp: renderApplication (buffered)
renderStream->>Stream: enqueue full HTML, close
else streaming enabled
renderStream->>Stream: enqueue head + reconcile runtime
AngularApp->>renderStream: __analogSsrDeferCapture on defer resolve
renderStream->>Stream: enqueue defer template + paint script
renderStream->>AngularApp: whenStable()
renderStream->>AngularApp: renderInternal (authoritative tail)
renderStream->>Stream: enqueue tail templates + finalize/reconcile scripts
renderStream->>Stream: close
end
Related issues: None provided. Related PRs: None provided. Suggested labels: feature, experimental, ssr, breaking-change Suggested reviewers: Maintainers of Poem: The old bridge fades from the stream, and 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
View your CI Pipeline Execution ↗ for commit bb132b0
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗ ☁️ Nx Cloud last updated this comment at |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
packages/platform/src/lib/platform-plugin.spec.ts (1)
51-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLGTM on the header test itself.
Consider also covering the Angular-version gating branch (forcing
experimental.streamingoff +console.warnwhen the detected major is belowMIN_STREAMING_ANGULAR_MAJOR) added alongside this inplatform-plugin.ts— currently untested here. As per path instructions,**/*.{test,spec}.{ts,tsx}files should "Include tests which validate behavior for any new functionality added."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/platform/src/lib/platform-plugin.spec.ts` around lines 51 - 70, The current spec only covers the routeRules header mapping, but not the new Angular-version gating in platformPlugin. Add a test in platform-plugin.spec.ts that exercises the branch where the detected Angular major is below MIN_STREAMING_ANGULAR_MAJOR, verifies experimental.streaming is forced off, and asserts console.warn is called. Use the existing platformPlugin setup and the same symbols from platform-plugin.ts so the new functionality is covered alongside the header behavior.Source: Path instructions
packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts (1)
1-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding coverage for
deferStreamingPlugin()itself.The pure helpers (
injectDeferStreamingHook,inspectAngularCoreModule,streamingSupportedOnAngular) are well covered, but the plugin object's own branching — skipping non-SSR builds (options?.ssrcheck), thenot-targetvsdriftedwarn-once behavior, and thebuildEnd"never encountered" warning — has no direct test. That logic determines whether streaming silently degrades correctly, so a couple of lightweight tests invokingtransform.handler(code, id, { ssr: true })andbuildEnd()with a stubthis.warnwould close the gap. As per path instructions,**/*.{test,spec}.{ts,tsx}should "Include tests which validate behavior for any new functionality added."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts` around lines 1 - 121, Add tests for deferStreamingPlugin() itself, not just the pure helpers, to cover the plugin branching behavior. Use the deferStreamingPlugin transform.handler with a stubbed ssr build context to verify non-SSR inputs are skipped, and exercise the not-target vs drifted paths to confirm warn-once behavior. Also call buildEnd() with a mocked this.warn to assert the “never encountered” warning is emitted when appropriate, using the existing deferStreamingPlugin, transform.handler, and buildEnd entry points.Source: Path instructions
packages/router/server/src/render-stream.ts (1)
164-306: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftNo unit test for
renderStream's streaming/fallback logic.
stream-request.tsandreset-component-def-tviews.tsextracted pure helpers specifically so they're unit-testable, but the orchestration inrenderStreamitself (capture dispatch, macrotask flush ordering, fallback branching, platform teardown) has no accompanying spec in this PR. Given this is genuinely tricky async/concurrency logic (AsyncLocalStorage scoping, out-of-order flush, capture-once dedup), it'd benefit from at least a few targeted tests (e.g., fallback triggers for bot/route-disabled/missing-primitive, dedup viaseen, capturing stops afterwhenStable).As per CONTRIBUTING.md, "If you've added new functionality, please include tests which validate its behavior."
Want me to draft a Vitest suite covering the fallback-selection branches and the capture-dedup logic?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/router/server/src/render-stream.ts` around lines 164 - 306, Add unit tests for renderStream’s orchestration logic, since the new streaming/fallback branching and async capture flow are untested. Cover the renderStream function’s fallback path selection for bot, route-disabled, and missing-streaming-primitive cases, plus the streaming path behavior around capture dispatch, seen-based deduplication of repeated lContainer completions, and stopping capture after appRef.whenStable() before the authoritative tail is serialized. Use the renderStream, installCaptureDispatcher, onBlockResolved, and asyncDestroyPlatform flow as the main targets so the tests stay stable if implementation details move.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/platform/src/lib/platform-plugin.ts`:
- Around line 26-43: `getAngularCoreMajor()` currently resolves
`@angular/core/package.json` from `process.cwd()`, which can use the wrong
Angular install when `workspaceRoot` is set. Update the Angular version
detection in `platform-plugin.ts` to accept the effective workspace root from
`platformOptions.workspaceRoot ?? process.cwd()` and use that path when creating
the `createRequire` base, so `experimental.streaming` is gated against the
correct Angular major in monorepo and wrapper setups.
In `@packages/platform/src/lib/ssr/defer-streaming-plugin.ts`:
- Around line 43-66: The injected DeferBlockState completion hook in
injectDeferStreamingHook only catches errors inside the
globalThis.__analogSsrDeferCapture call, so the surrounding guard can still
throw before the catch. Move the entire injected if block for the
DeferBlockState.Complete path inside the try/catch, keeping the existing
END_ANCHOR replacement and inspectAngularCoreModule anchor checks intact, so
shifted Angular internals fail as a no-op instead of a ReferenceError.
In `@packages/router/server/src/render-stream.ts`:
- Around line 173-212: The buffered fallback in renderStream skips the component
TView/locale reset, so bots and streaming-disabled routes can reuse stale
locale-specific consts output. Move resetComponentDefTViews() so it runs
unconditionally before both the buffered renderApplication path and the
streaming path, matching the behavior used in render.ts and ensuring each render
starts from a clean state.
- Around line 257-302: The render path in render-stream.ts can drop
bootstrap/render failures because the try/finally around bootstrap, whenStable,
and renderInternal closes the stream without first surfacing the error. Add a
catch around that block in the render streaming flow so failures are reported to
the ReadableStream controller before controller.close() runs, and preserve the
original error path instead of swallowing it. Use the existing render-stream
flow symbols like bootstrap, appRef.whenStable, renderInternal, and
controller.close to wire this in. Since the head is already flushed, also log
useful context such as the request URL and current block/capture state to help
diagnose partial-response failures.
In `@packages/vite-plugin-nitro/src/lib/utils/renderers.ts`:
- Around line 23-67: The ssrStreamRenderer handler is forcing chunked transfer
framing on every streamed response, which should not be done universally. Update
the eventHandler in renderers.ts so the transfer-encoding header is not set
unconditionally after the noStreaming branch; instead let Nitro/h3 manage
framing, or guard it so only the Node HTTP/1.1 path uses it. Keep the existing
noSSR and noStreaming logic intact and only adjust the response header handling
around getResponseHeader/setResponseHeader in ssrStreamRenderer.
---
Nitpick comments:
In `@packages/platform/src/lib/platform-plugin.spec.ts`:
- Around line 51-70: The current spec only covers the routeRules header mapping,
but not the new Angular-version gating in platformPlugin. Add a test in
platform-plugin.spec.ts that exercises the branch where the detected Angular
major is below MIN_STREAMING_ANGULAR_MAJOR, verifies experimental.streaming is
forced off, and asserts console.warn is called. Use the existing platformPlugin
setup and the same symbols from platform-plugin.ts so the new functionality is
covered alongside the header behavior.
In `@packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts`:
- Around line 1-121: Add tests for deferStreamingPlugin() itself, not just the
pure helpers, to cover the plugin branching behavior. Use the
deferStreamingPlugin transform.handler with a stubbed ssr build context to
verify non-SSR inputs are skipped, and exercise the not-target vs drifted paths
to confirm warn-once behavior. Also call buildEnd() with a mocked this.warn to
assert the “never encountered” warning is emitted when appropriate, using the
existing deferStreamingPlugin, transform.handler, and buildEnd entry points.
In `@packages/router/server/src/render-stream.ts`:
- Around line 164-306: Add unit tests for renderStream’s orchestration logic,
since the new streaming/fallback branching and async capture flow are untested.
Cover the renderStream function’s fallback path selection for bot,
route-disabled, and missing-streaming-primitive cases, plus the streaming path
behavior around capture dispatch, seen-based deduplication of repeated
lContainer completions, and stopping capture after appRef.whenStable() before
the authoritative tail is serialized. Use the renderStream,
installCaptureDispatcher, onBlockResolved, and asyncDestroyPlatform flow as the
main targets so the tests stay stable if implementation details move.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 08abbc0d-7442-46d6-ae31-97123c29bd5d
📒 Files selected for processing (47)
apps/analog-app/src/app/pages/client/(client).page.tsapps/analog-app/src/app/pages/server/(server).page.tsapps/analog-app/src/server/components/hello.tsapps/docs-app/docs/features/server/streaming-ssr.mdapps/docs-app/sidebars.jsapps/streaming-app/index.htmlapps/streaming-app/project.jsonapps/streaming-app/src/app/app.component.tsapps/streaming-app/src/app/app.config.server.tsapps/streaming-app/src/app/app.config.tsapps/streaming-app/src/app/blocks/fast-a.component.tsapps/streaming-app/src/app/blocks/shell-status.component.tsapps/streaming-app/src/app/blocks/slow-b.component.tsapps/streaming-app/src/app/blocks/trigger-c.component.tsapps/streaming-app/src/app/pages/buffered.page.tsapps/streaming-app/src/app/pages/index.page.tsapps/streaming-app/src/main.server.tsapps/streaming-app/src/main.tsapps/streaming-app/src/server/routes/api/slow-b.get.tsapps/streaming-app/tsconfig.app.jsonapps/streaming-app/tsconfig.editor.jsonapps/streaming-app/tsconfig.jsonapps/streaming-app/vite.config.tspackages/platform/src/lib/options.tspackages/platform/src/lib/platform-plugin.spec.tspackages/platform/src/lib/platform-plugin.tspackages/platform/src/lib/ssr/defer-streaming-plugin.spec.tspackages/platform/src/lib/ssr/defer-streaming-plugin.tspackages/router/RFC-streaming-ssr.mdpackages/router/server/src/defer-reconcile-runtime.spec.tspackages/router/server/src/defer-reconcile-runtime.tspackages/router/server/src/index.tspackages/router/server/src/render-stream.tspackages/router/server/src/render.tspackages/router/server/src/server-component-render.tspackages/router/server/src/tokens.tspackages/router/server/src/utils/reset-component-def-tviews.tspackages/router/server/src/utils/stream-html.spec.tspackages/router/server/src/utils/stream-html.tspackages/router/server/src/utils/stream-request.spec.tspackages/router/server/src/utils/stream-request.tspackages/router/src/index.tspackages/router/src/lib/server.component.tspackages/vite-plugin-nitro/src/index.tspackages/vite-plugin-nitro/src/lib/options.tspackages/vite-plugin-nitro/src/lib/utils/renderers.tspackages/vite-plugin-nitro/src/lib/vite-plugin-nitro.ts
💤 Files with no reviewable changes (7)
- apps/analog-app/src/server/components/hello.ts
- apps/analog-app/src/app/pages/client/(client).page.ts
- packages/router/src/index.ts
- apps/analog-app/src/app/pages/server/(server).page.ts
- packages/router/server/src/tokens.ts
- packages/router/server/src/server-component-render.ts
- packages/router/src/lib/server.component.ts
…g SSR Adds apps/streaming-app/tools/cwv-bench.mjs — a Playwright harness that measures the same route rendered streamed (browser UA) vs buffered (bot-UA fallback) under Slow-4G + 4x CPU throttling, reporting median TTFB/FCP/LCP/CLS/INP. Uses Playwright rather than Lighthouse because Lighthouse's own user-agent matches SSR_BOT_RE and would only ever measure the buffered path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ow-up Adds an over-HTTP, throttled CWV section to the streaming SSR RFC: TTFB and FCP win large (head + first defer block stream early), LCP is a wash and CLS is a small regression because the eager app shell renders only in the authoritative tail and the finalize body-swap rebuilds the DOM. Captures the highest-leverage follow-up (stream the eager shell early, in position) and marks the real over-HTTP benchmark as done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7d57786 to
bb132b0
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/streaming-app/tools/cwv-bench.mjs (1)
135-138: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winGuard browser cleanup with try/finally.
If
bench()throws (e.g.page.gototimeout at Line 77, or a Playwright/CDP error),browser.close()at Line 138 never runs and the headless Chromium process is orphaned. Since this is a manual dev tool, impact is low, but a leaked browser process on a dev machine/CI runner is an easy fix.🔧 Proposed fix
-const browser = await chromium.launch({ headless: true }); -const streamed = await bench(browser, 'streamed', UA_BROWSER); -const buffered = await bench(browser, 'buffered', UA_BOT); -await browser.close(); +const browser = await chromium.launch({ headless: true }); +let streamed, buffered; +try { + streamed = await bench(browser, 'streamed', UA_BROWSER); + buffered = await bench(browser, 'buffered', UA_BOT); +} finally { + await browser.close(); +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/streaming-app/tools/cwv-bench.mjs` around lines 135 - 138, Wrap the browser lifecycle in cwv-bench.mjs with try/finally so cleanup always runs even if bench() fails; in the block around chromium.launch(), bench(), and browser.close(), keep the benchmark calls in the try section and move browser.close() into finally. Use the existing browser variable in cwv-bench.mjs so any Playwright/CDP or page.goto error still triggers cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/streaming-app/tools/cwv-bench.mjs`:
- Around line 135-138: Wrap the browser lifecycle in cwv-bench.mjs with
try/finally so cleanup always runs even if bench() fails; in the block around
chromium.launch(), bench(), and browser.close(), keep the benchmark calls in the
try section and move browser.close() into finally. Use the existing browser
variable in cwv-bench.mjs so any Playwright/CDP or page.goto error still
triggers cleanup.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ea1df360-c01b-4e50-bfac-dde5560861c3
📒 Files selected for processing (2)
apps/streaming-app/tools/cwv-bench.mjspackages/router/RFC-streaming-ssr.md
✅ Files skipped from review due to trivial changes (1)
- packages/router/RFC-streaming-ssr.md
…y path Two correctness fixes from review of the streaming renderer: - The try/finally around bootstrap/whenStable/renderInternal had no catch, so a mid-render throw hit finally's controller.close() before the error propagated — on an already-closed stream controller.error() is a spec no-op, so the failure was swallowed and the client got a truncated, non-hydratable 200 with no server log. Add a catch that errors the stream and logs with block context; skip close() once errored. - resetComponentDefTViews() only ran on the streaming path. The buffered fallback (bots, streaming:false routes, missing primitive) called renderApplication without it, unlike render.ts which resets unconditionally, so the first render's locale/consts could freeze for the process — and that path is the one crawlers hit. Hoist the reset to cover both paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Widen the injected @angular/core hook's try/catch to wrap the whole guard, not just the capture call. The condition references DeferBlockState and newState outside the try, so a drifted runtime that still matches the anchors but renamed those locals would throw a ReferenceError inside applyDeferBlockState — breaking all rendering. Wrapping it keeps streaming on a silent no-op path. Add a test asserting the guard is wrapped. - getAngularCoreMajor() resolved @angular/core from process.cwd(), ignoring platformOptions.workspaceRoot that the rest of the plugin honors, so a monorepo/wrapper invocation could gate experimental.streaming against the wrong Angular install. Thread workspaceRoot ?? cwd() through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sponses The stream renderer set Transfer-Encoding: chunked unconditionally. h3 already frames the response per preset (chunked for the Node preset), and an explicit Transfer-Encoding header is forbidden on HTTP/2 and edge runtimes, so forcing it could break those deployments. Drop the header and let the runtime choose. Verified the Node preset still streams chunked with a low TTFB and progressive block flush, and streaming:false still buffers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Checklist
Adds experimental progressive streaming SSR to Analog. Today
render()buffers the whole document and flushes it once the app is stable. This introduces an opt-inrenderStream()path that flushes bytes during the render: the document head + a client reconcile runtime first, then each@defer (hydrate …)block the moment it resolves on the server (out of document order), then the authoritative hydration-annotated document as the tail that Angular's incremental hydration runs against.Feature-flagged (
experimental.streaming), off by default; the classic buffered path is unchanged and remains the default.Full design + validation details:
packages/router/RFC-streaming-ssr.md.Closes #
Affected scope
routerplatform,vite-plugin-nitro,docsRecommended merge strategy for maintainer [optional]
What is the new behavior?
renderStream()(@analogjs/router/server) — progressive streaming renderer. Drives the platform directly (platformServer+bootstrapApplication+ɵrenderInternal) so it can interleave flushes with rendering. Per-render@defercapture is isolated viaAsyncLocalStorage, so concurrent renders in one process never cross-talk.<head>is streamed in the tail and applied idempotently (__analogReconcileHead) before hydration boots. Mirrors Nuxt's "inject late" strategy.streaming: falseroute rule disables streaming for that route (mirrorsssr: false), surfaced via anx-analog-no-streamingheader that routes the request to the buffered path.@angular/coreFESM exposes the anchors the platform plugin patches (v20 inlines the profiler enum ordinal). Below v21 the plugin disables streaming and warns.renderStream()degrades to a single buffered chunk identical torender().apps/streaming-appexercising eager, fast@defer, slowhttpResource-backed@defer, andhydrate on interactionblocks, plus a/bufferedroute proving per-route opt-out.Test plan
pnpm test—router,platform,vite-plugin-nitrosuites pass (117 router tests, incl. new specs for the streaming HTML/request helpers and the client reconcile runtime)pnpm build—routerandanalog-appbuild cleanstreaming-appvalidated in Chromium: eager + fast + slow-httpResource+ interaction blocks all hydrate with zero console errors; out-of-order flush confirmed (slow block does not hold back an early one); dynamic title reconciled on the streaming path; Googlebot UA receives the buffered doc with a resolved head;/bufferedroute confirmed non-chunkednx format:check— prettier is enforced per-commit; fullnx lintto be confirmed by CIDoes this PR introduce a breaking change?
The classic buffered
render()path is unchanged and remains the default; streaming is opt-in behindexperimental.streaming.Other information
Deferred to follow-ups (see the RFC's "Future work"): an incremental hydration annotator, upstreaming the per-block resolution primitive into Angular, progressive-paint positioning, and over-HTTP benchmarks.
🤖 Generated with Claude Code