Skip to content

RFC: Streaming SSR Hydration Coordination for Preact Core #5034

@JoviDeCroock

Description

@JoviDeCroock

Summary

This RFC proposes a set of targeted additions to Preact core that enable deterministic hydration of streamed SSR output. It builds directly on the SSR marker protocol implemented in the Hydration 2.0 protocol of v11.

Motivation

preact-render-to-string already supports streaming SSR with Suspense:

  • The shell is rendered synchronously and flushed immediately.
  • Suspended boundaries will be rendered as their fallback in the shell.
  • When the suspended subtree resolves, the resulting DOM is streamed to the client and a script replaces the fallback region in-place.

This works as a raw DOM patching protocol, but it is out-of-band with Preact hydration. Two independent systems end up mutating or depending on the same boundary DOM, and they do not coordinate:

Preact hydration traverses (and stores) fallback content resulting in, when the client-suspended component resolves, a mismatch.

  • Either the content is streamed in and we have a stored pointer to the fallback --> hydration missmatch.
  • Or the content isn't streamed yet and we destructively render over the fallback meaning the streamed in content won't be able to get inserted.

Why Hydration 2.0 Alone Is Not Enough

The SSR <!-- $s:id --> / <!-- /$s:id --> markers proposed in issue #4442 solve the static hydration pointer problem: they give the algorithm stable anchors so it can correctly count excess DOM children for suspended boundaries.

But markers alone do not address the streaming case, where:

  • The hydration algorithm has already run (or is still running) when the stream patcher fires.
  • The boundary's fallback DOM, which hydration stored pointers, to has been replaced by the resolved HTML.
  • The anchor comment nodes survive, but the internal vnode references (_dom, excessDomChildren) still point at the replaced fallback nodes.

When Preact later attempts to unsuspend that boundary, it reconciles against stale DOM that may no longer exist in the document.

Problem Statement

During streaming SSR hydration, three overlapping systems mutate or depend on the same DOM region:

[Server] shell + fallback markers + deferred resolved payload
         ↓
[Browser] DOM:  <!-- $s:0 --> <span>Loading…</span> <!-- /$s:0 -->
                 ↑                                         ↑
         [Preact] stores _dom ──────────────────── stores _lastDomChild

                         + shortly after, or concurrently:

         [Stream patcher] removes fallback, inserts resolved HTML

After replacement:

DOM:  <!-- $s:0 --> <div>Resolved content</div> <!-- /$s:0 -->
                    ↑
         Preact's _dom still points at the removed <span>

When the Suspense component resolves and Preact calls diff() to unsuspend:

  • It reconciles against the stale _dom pointer.
  • The reconciled result may be inserted at the wrong position, duplicated, or lost.
  • Hydration warnings fire; the tree may remount from scratch.

This is deterministically broken whenever the stream patcher wins the race against hydration.

Proposed Changes

These changes are split into three layers: SSR output, hydration-time registration, and resume-time rebinding. All three are needed together; they are described separately for clarity.

1. Stable SSR Boundary Anchors

preact-render-to-string will adopt the $s:id boundary marker format from Hydration 2.0, but with a small extension for streaming: the start and end anchors carry the same id attribute so the stream patcher can identify them after replacement.

SSR output (streaming mode):

<!-- $s:0 -->
<span>Loading…</span>
<!-- /$s:0 -->

<!-- elsewhere in the stream, deferred: -->
<preact-island data-target="0" hidden>
  <div>Resolved content</div>
</preact-island>

Both the start comment ($s:0) and end comment (/$s:0) carry the boundary id. The stream patcher replaces content between them but does not remove the anchor comments themselves. This is what allows Preact to re-scan the DOM for the boundary at resume time.

Relation to Hydration 2.0: The static renderToString/renderToStringAsync path already adopts <!-- $s --> (without id). Streaming mode extends this by adding an id, which is harmless for the static path to ignore.

2. Deferred excessDomChildren capture

When the hydration algorithm encounters a Suspense boundary that suspends, it currently stores DOM pointers and moves on. We should instead point at the comment-node and restore the pointers later when the suspension completes, then we ensure we will always hydrate against the most up-to-date DOM state.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions