Anchor useId to the server boundary id during resumed hydration - #5157
Draft
JoviDeCroock wants to merge 1 commit into
Draft
Anchor useId to the server boundary id during resumed hydration#5157JoviDeCroock wants to merge 1 commit into
JoviDeCroock wants to merge 1 commit into
Conversation
The ids useId hands out are drawn from a shared counter in render order, so sibling Suspense boundaries that resolve in a different order on the client than on the server derive ids that no longer match the ids in the server HTML. Counting boundaries on the client (#5108) was reverted in #5135 because client-only boundaries rendering mid-hydration shift any encounter-order counter. Instead of re-deriving the namespace on the client, read it back from the boundary marker: when the opening marker carries the server boundary id (<!--$s:0-->), the resumed subtree draws its ids from a [S<id>, 0] mask, making them independent of resolution order. Boundaries without a marker id keep the current root-counter behavior, and client-only boundaries can never collide with a server namespace. This also keeps the stored start marker when a suspended-while-hydrating boundary is re-rendered by an ancestor update before it resolves: consuming the marker and re-storing one found in the rebuilt range made the boundary resume from a nested boundary's marker, corrupting both the claimed DOM range and the id namespace.
📊 Tachometer Benchmark ResultsSummaryduration
usedJSHeapSize
Resultscreate10kduration
usedJSHeapSize
filter-listduration
usedJSHeapSize
hydrate1kduration
usedJSHeapSize
many-updatesduration
usedJSHeapSize
replace1kduration
usedJSHeapSize
run-warmup-0
run-warmup-1
run-warmup-2
run-warmup-3
run-warmup-4
run-final
text-updateduration
usedJSHeapSize
tododuration
usedJSHeapSize
update10th1kduration
usedJSHeapSize
|
JoviDeCroock
marked this pull request as draft
July 14, 2026 16:51
|
Size Change: +87 B (+0.18%) Total Size: 48.1 kB 📦 View Changed
ℹ️ View Unchanged
|
Member
Author
|
Unsure whether we want to do this over just allowing the user to specify the Suspense name... |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
useIdnamespace of a resumed subtree from the boundary id embedded in the server-emitted suspense marker (<!--$s:0-->) instead of a client-side counterPS<id>-NvsP0-N)Problem
useIddraws ids from a shared counter in render order. With resumed hydration, the content of a suspended boundary renders when its promise resolves, so sibling boundaries that resolve in a different order on the client than on the server consume the counter in a different order and derive ids that don't match the ids already sitting in the server HTML (and referenced byaria-*attributes).We tried deriving a per-boundary namespace from an encounter-order counter in #5108, but had to revert it in #5135: a client-only boundary (
{isHydrated && <Suspense>…}) rendering in the middle of hydration consumes a namespace the server never allocated and shifts every boundary after it. Any scheme that allocates namespaces by when a boundary renders has this failure mode; the namespace has to come from what the boundary is.The streamed-hydration markers already carry exactly that: a stable, document-ordered boundary id assigned by the server. Reading it back from the DOM when the boundary resumes sidesteps re-deriving anything on the client — resolution order becomes irrelevant, and client-only boundaries simply have no marker.
While testing the client-only case this surfaced a second issue: when an ancestor update re-rendered a boundary that was still suspended, the resume path consumed the stored start marker and the re-suspension then stored the first marker found in the rebuilt (inner) range — a nested boundary's marker. The boundary would later resume from the wrong range with the wrong namespace. Re-suspensions now keep the marker they originally started from, and the stored marker is cleared once the boundary renders successfully.
Server side
renderToStringAsynccurrently emits bare<!--$s-->markers; the streamed variant emits<!--$s:N-->. The follow-up in preact-render-to-string is to emit the boundary id in both modes and to hand outPS<id>-Nids inside a suspended subtree, mirroring the mask this PR restores on the client.Size: +27 B brotli on core.