Skip to content

feat: add support for Streaming SSR#2419

Open
brandonroberts wants to merge 49 commits into
betafrom
feat/streaming-ssr
Open

feat: add support for Streaming SSR#2419
brandonroberts wants to merge 49 commits into
betafrom
feat/streaming-ssr

Conversation

@brandonroberts

Copy link
Copy Markdown
Member

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-in renderStream() 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

  • Primary scope: router
  • Secondary scopes: platform, vite-plugin-nitro, docs

Recommended merge strategy for maintainer [optional]

  • Squash merge
  • Rebase merge
  • Other

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 @defer capture is isolated via AsyncLocalStorage, so concurrent renders in one process never cross-talk.
  • Head/title reconcile — because the shell head is flushed before the app sets a dynamic title/meta, the authoritative <head> is streamed in the tail and applied idempotently (__analogReconcileHead) before hydration boots. Mirrors Nuxt's "inject late" strategy.
  • Bot bypass — crawler user-agents fall back to a fully buffered render with a resolved head (they may not run the finalize script), matching Nuxt's bot handling.
  • Per-route opt-out — a streaming: false route rule disables streaming for that route (mirrors ssr: false), surfaced via an x-analog-no-streaming header that routes the request to the buffered path.
  • Version gate — streaming is gated to Angular v21+, the lowest version whose compiled @angular/core FESM exposes the anchors the platform plugin patches (v20 inlines the profiler enum ordinal). Below v21 the plugin disables streaming and warns.
  • Graceful degradation — when the upstream streaming primitive is absent, renderStream() degrades to a single buffered chunk identical to render().
  • Docs — new "Streaming SSR" page under Server Side Rendering.
  • Example — new apps/streaming-app exercising eager, fast @defer, slow httpResource-backed @defer, and hydrate on interaction blocks, plus a /buffered route proving per-route opt-out.

Test plan

  • pnpm testrouter, platform, vite-plugin-nitro suites pass (117 router tests, incl. new specs for the streaming HTML/request helpers and the client reconcile runtime)
  • pnpm buildrouter and analog-app build clean
  • Manual verification — streaming-app validated 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; /buffered route confirmed non-chunked
  • nx format:check — prettier is enforced per-commit; full nx lint to be confirmed by CI

Does this PR introduce a breaking change?

  • No

The classic buffered render() path is unchanged and remains the default; streaming is opt-in behind experimental.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

brandonroberts and others added 30 commits July 5, 2026 20:39
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 '&amp;' to '&amp;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
… 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
brandonroberts and others added 12 commits July 6, 2026 14:47
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
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
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
@netlify

netlify Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploy Preview for analog-blog ready!

Name Link
🔨 Latest commit 16dcaa7
🔍 Latest deploy log https://app.netlify.com/projects/analog-blog/deploys/6a4c63f6249a9f00089b5bd9
😎 Deploy Preview https://deploy-preview-2419--analog-blog.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploy Preview for analog-docs ready!

Name Link
🔨 Latest commit 16dcaa7
🔍 Latest deploy log https://app.netlify.com/projects/analog-docs/deploys/6a4c63f6b8d16b00087eea6f
😎 Deploy Preview https://deploy-preview-2419--analog-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploy Preview for analog-app ready!

Name Link
🔨 Latest commit 16dcaa7
🔍 Latest deploy log https://app.netlify.com/projects/analog-app/deploys/6a4c63f612442300087c42f9
😎 Deploy Preview https://deploy-preview-2419--analog-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions Bot added scope:docs Documentation changes scope:platform Changes in @analogjs/platform scope:router Changes in @analogjs/router scope:vite-plugin-nitro Changes in @analogjs/vite-plugin-nitro labels Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

This PR touches multiple package scopes: platform, router, vite-plugin-nitro.

Please confirm the changes are closely related. Squash merge is highly preferred. If you recommend a non-squash merge, add a brief note explaining why the commit boundaries matter and why this PR should bypass focused changes per package.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 960cdb0b-ea2e-42f0-bcdf-820f8f08ac2a

📥 Commits

Reviewing files that changed from the base of the PR and between bb132b0 and 16dcaa7.

📒 Files selected for processing (5)
  • packages/platform/src/lib/platform-plugin.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.ts
  • packages/router/server/src/render-stream.ts
  • packages/vite-plugin-nitro/src/lib/utils/renderers.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/vite-plugin-nitro/src/lib/utils/renderers.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.ts
  • packages/platform/src/lib/platform-plugin.ts
  • packages/router/server/src/render-stream.ts

📝 Walkthrough

Walkthrough

This PR adds experimental progressive streaming SSR across router, platform, and Nitro plugin packages, plus a new streaming demo app and RFC/docs. It introduces renderStream, defer-streaming runtime patching, route-level streaming controls, client reconcile scripts, and buffered fallbacks. It also removes the legacy server-component bridge and unused Analog app page implementations.

Estimated code review effort: 5 (Critical) | ~120 minutes

Changes

Area Change
packages/router/server Adds streaming renderer/runtime/utilities; removes legacy server-component rendering and static props tokens
packages/platform Adds streaming options, Angular-version gating, and route opt-out header wiring
packages/vite-plugin-nitro Adds streaming SSR renderer selection and route config support
apps/streaming-app New demo app, blocks, routes, server/client entrypoints, config, and benchmark script
apps/docs-app / packages/router/RFC-streaming-ssr.md Adds docs, sidebar entry, and RFC
apps/analog-app Removes unused page/component modules

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
Loading

Related issues: None provided.

Related PRs: None provided.

Suggested labels: feature, experimental, ssr, breaking-change

Suggested reviewers: Maintainers of packages/router, packages/platform, and packages/vite-plugin-nitro

Poem: The old bridge fades from the stream, and renderStream takes the beam.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commit style and accurately summarizes the main change: Streaming SSR support.
Description check ✅ Passed The description is directly about experimental progressive streaming SSR and matches the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit bb132b0

Command Status Duration Result
nx run-many --target e2e --projects=analog-app-... ✅ Succeeded 51s View ↗
nx run-many --target test ✅ Succeeded 27s View ↗
nx run-many --target build --all --parallel=1 -... ✅ Succeeded 2m 22s View ↗
nx run-many --target lint --all --exclude=conte... ✅ Succeeded 2m View ↗
nx-cloud record -- pnpm prettier:check ✅ Succeeded 10s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-07 02:34:14 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/platform/src/lib/platform-plugin.spec.ts (1)

51-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

LGTM on the header test itself.

Consider also covering the Angular-version gating branch (forcing experimental.streaming off + console.warn when the detected major is below MIN_STREAMING_ANGULAR_MAJOR) added alongside this in platform-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 win

Consider 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?.ssr check), the not-target vs drifted warn-once behavior, and the buildEnd "never encountered" warning — has no direct test. That logic determines whether streaming silently degrades correctly, so a couple of lightweight tests invoking transform.handler(code, id, { ssr: true }) and buildEnd() with a stub this.warn would 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 lift

No unit test for renderStream's streaming/fallback logic.

stream-request.ts and reset-component-def-tviews.ts extracted pure helpers specifically so they're unit-testable, but the orchestration in renderStream itself (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 via seen, capturing stops after whenStable).

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

📥 Commits

Reviewing files that changed from the base of the PR and between bad4920 and 51d053d.

📒 Files selected for processing (47)
  • apps/analog-app/src/app/pages/client/(client).page.ts
  • apps/analog-app/src/app/pages/server/(server).page.ts
  • apps/analog-app/src/server/components/hello.ts
  • apps/docs-app/docs/features/server/streaming-ssr.md
  • apps/docs-app/sidebars.js
  • apps/streaming-app/index.html
  • apps/streaming-app/project.json
  • apps/streaming-app/src/app/app.component.ts
  • apps/streaming-app/src/app/app.config.server.ts
  • apps/streaming-app/src/app/app.config.ts
  • apps/streaming-app/src/app/blocks/fast-a.component.ts
  • apps/streaming-app/src/app/blocks/shell-status.component.ts
  • apps/streaming-app/src/app/blocks/slow-b.component.ts
  • apps/streaming-app/src/app/blocks/trigger-c.component.ts
  • apps/streaming-app/src/app/pages/buffered.page.ts
  • apps/streaming-app/src/app/pages/index.page.ts
  • apps/streaming-app/src/main.server.ts
  • apps/streaming-app/src/main.ts
  • apps/streaming-app/src/server/routes/api/slow-b.get.ts
  • apps/streaming-app/tsconfig.app.json
  • apps/streaming-app/tsconfig.editor.json
  • apps/streaming-app/tsconfig.json
  • apps/streaming-app/vite.config.ts
  • packages/platform/src/lib/options.ts
  • packages/platform/src/lib/platform-plugin.spec.ts
  • packages/platform/src/lib/platform-plugin.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts
  • packages/platform/src/lib/ssr/defer-streaming-plugin.ts
  • packages/router/RFC-streaming-ssr.md
  • packages/router/server/src/defer-reconcile-runtime.spec.ts
  • packages/router/server/src/defer-reconcile-runtime.ts
  • packages/router/server/src/index.ts
  • packages/router/server/src/render-stream.ts
  • packages/router/server/src/render.ts
  • packages/router/server/src/server-component-render.ts
  • packages/router/server/src/tokens.ts
  • packages/router/server/src/utils/reset-component-def-tviews.ts
  • packages/router/server/src/utils/stream-html.spec.ts
  • packages/router/server/src/utils/stream-html.ts
  • packages/router/server/src/utils/stream-request.spec.ts
  • packages/router/server/src/utils/stream-request.ts
  • packages/router/src/index.ts
  • packages/router/src/lib/server.component.ts
  • packages/vite-plugin-nitro/src/index.ts
  • packages/vite-plugin-nitro/src/lib/options.ts
  • packages/vite-plugin-nitro/src/lib/utils/renderers.ts
  • packages/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

Comment thread packages/platform/src/lib/platform-plugin.ts
Comment thread packages/platform/src/lib/ssr/defer-streaming-plugin.ts
Comment thread packages/router/server/src/render-stream.ts Outdated
Comment thread packages/router/server/src/render-stream.ts
Comment thread packages/vite-plugin-nitro/src/lib/utils/renderers.ts
brandonroberts and others added 2 commits July 6, 2026 21:14
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/streaming-app/tools/cwv-bench.mjs (1)

135-138: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard browser cleanup with try/finally.

If bench() throws (e.g. page.goto timeout 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

📥 Commits

Reviewing files that changed from the base of the PR and between 51d053d and bb132b0.

📒 Files selected for processing (2)
  • apps/streaming-app/tools/cwv-bench.mjs
  • packages/router/RFC-streaming-ssr.md
✅ Files skipped from review due to trivial changes (1)
  • packages/router/RFC-streaming-ssr.md

brandonroberts and others added 3 commits July 6, 2026 21:26
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope:docs Documentation changes scope:platform Changes in @analogjs/platform scope:router Changes in @analogjs/router scope:vite-plugin-nitro Changes in @analogjs/vite-plugin-nitro

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant