Skip to content

Anchor useId to the server boundary id during resumed hydration - #5157

Draft
JoviDeCroock wants to merge 1 commit into
mainfrom
useid-boundary-marker
Draft

Anchor useId to the server boundary id during resumed hydration#5157
JoviDeCroock wants to merge 1 commit into
mainfrom
useid-boundary-marker

Conversation

@JoviDeCroock

Copy link
Copy Markdown
Member

Summary

  • derive the useId namespace of a resumed subtree from the boundary id embedded in the server-emitted suspense marker (<!--$s:0-->) instead of a client-side counter
  • keep the stored start marker when a suspended-while-hydrating boundary re-renders before it resolves, so ancestor updates can no longer drift the boundary onto a nested boundary's marker
  • boundaries whose marker carries no id, and client-only boundaries, keep the existing root-counter behavior and cannot collide with a server namespace (PS<id>-N vs P0-N)

Problem

useId draws 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 by aria-* 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

renderToStringAsync currently 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 out PS<id>-N ids inside a suspended subtree, mirroring the mask this PR restores on the client.

Size: +27 B brotli on core.

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.
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: slower ❌ 0% - 1% (0.56ms - 6.14ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.02ms - +0.07ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -3% - +2% (-1.02ms - +0.64ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -4% - +2% (-0.61ms - +0.38ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -1% - +2% (-0.81ms - +0.85ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -1% - +10% (-0.01ms - +0.17ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -1% - +2% (-0.27ms - +0.55ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -4% - +4% (-1.14ms - +1.34ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: slower ❌ 1% - 1% (0.18ms - 0.19ms)
    preact-local vs preact-main
  • filter-list: slower ❌ 1% - 2% (0.02ms - 0.03ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -2% - +4% (-0.12ms - +0.22ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • replace1k: slower ❌ 1% - 1% (0.02ms - 0.02ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -6% - +2% (-0.07ms - +0.02ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: slower ❌ 1% - 1% (0.02ms - 0.03ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local844.64ms - 848.33ms-slower ❌
0% - 1%
0.56ms - 6.14ms
preact-main841.03ms - 845.23msfaster ✔
0% - 1%
0.56ms - 6.14ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.26ms - 19.26ms-slower ❌
1% - 1%
0.18ms - 0.19ms
preact-main19.07ms - 19.07msfaster ✔
1% - 1%
0.18ms - 0.19ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.52ms - 16.61ms-unsure 🔍
-0% - +0%
-0.02ms - +0.07ms
preact-main16.52ms - 16.56msunsure 🔍
-0% - +0%
-0.07ms - +0.02ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.57ms - 1.57ms-slower ❌
1% - 2%
0.02ms - 0.03ms
preact-main1.55ms - 1.55msfaster ✔
1% - 2%
0.02ms - 0.03ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local37.21ms - 38.50ms-unsure 🔍
-3% - +2%
-1.02ms - +0.64ms
preact-main37.52ms - 38.56msunsure 🔍
-2% - +3%
-0.64ms - +1.02ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.00ms - 5.27ms-unsure 🔍
-2% - +4%
-0.12ms - +0.22ms
preact-main4.98ms - 5.18msunsure 🔍
-4% - +2%
-0.22ms - +0.12ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.37ms - 16.95ms-unsure 🔍
-4% - +2%
-0.61ms - +0.38ms
preact-main16.38ms - 17.17msunsure 🔍
-2% - +4%
-0.38ms - +0.61ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.74ms - 3.74ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main3.74ms - 3.74msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5662
  • Commit: 316d0ce

duration

VersionAvg timevs preact-localvs preact-main
preact-local55.42ms - 56.53ms-unsure 🔍
-1% - +2%
-0.81ms - +0.85ms
preact-main55.33ms - 56.58msunsure 🔍
-2% - +1%
-0.85ms - +0.81ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.03ms - 3.03ms-slower ❌
1% - 1%
0.02ms - 0.02ms
preact-main3.01ms - 3.01msfaster ✔
1% - 1%
0.02ms - 0.02ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local27.42ms - 28.15ms-unsure 🔍
-1% - +3%
-0.31ms - +0.78ms
preact-main27.15ms - 27.95msunsure 🔍
-3% - +1%
-0.78ms - +0.31ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local32.27ms - 33.66ms-unsure 🔍
-3% - +3%
-1.06ms - +0.85ms
preact-main32.41ms - 33.72msunsure 🔍
-3% - +3%
-0.85ms - +1.06ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local29.96ms - 31.24ms-unsure 🔍
-3% - +3%
-0.81ms - +0.99ms
preact-main29.88ms - 31.14msunsure 🔍
-3% - +3%
-0.99ms - +0.81ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local25.41ms - 25.86ms-unsure 🔍
-0% - +2%
-0.03ms - +0.54ms
preact-main25.21ms - 25.55msunsure 🔍
-2% - +0%
-0.54ms - +0.03ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local22.23ms - 23.56ms-faster ✔
2% - 10%
0.54ms - 2.38ms
preact-main23.72ms - 25.00msslower ❌
2% - 11%
0.54ms - 2.38ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local20.86ms - 21.40ms-slower ❌
0% - 3%
0.02ms - 0.70ms
preact-main20.56ms - 20.98msfaster ✔
0% - 3%
0.02ms - 0.70ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 210
  • Built by: CI #5662
  • Commit: 316d0ce

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.83ms - 1.96ms-unsure 🔍
-1% - +10%
-0.01ms - +0.17ms
preact-main1.75ms - 1.88msunsure 🔍
-9% - +1%
-0.17ms - +0.01ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.04ms - 1.10ms-unsure 🔍
-6% - +2%
-0.07ms - +0.02ms
preact-main1.06ms - 1.13msunsure 🔍
-2% - +7%
-0.02ms - +0.07ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local24.16ms - 24.41ms-unsure 🔍
-1% - +2%
-0.27ms - +0.55ms
preact-main23.75ms - 24.53msunsure 🔍
-2% - +1%
-0.55ms - +0.27ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.27ms - 1.27ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.26ms - 1.26msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local31.75ms - 33.63ms-unsure 🔍
-4% - +4%
-1.14ms - +1.34ms
preact-main31.77ms - 33.41msunsure 🔍
-4% - +3%
-1.34ms - +1.14ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.98ms - 3.00ms-slower ❌
1% - 1%
0.02ms - 0.03ms
preact-main2.96ms - 2.97msfaster ✔
1% - 1%
0.02ms - 0.03ms
-

tachometer-reporter-action v2 for CI

@JoviDeCroock
JoviDeCroock marked this pull request as draft July 14, 2026 16:51
@github-actions

Copy link
Copy Markdown

Size Change: +87 B (+0.18%)

Total Size: 48.1 kB

📦 View Changed
Filename Size Change
dist/preact.js 4.88 kB +29 B (+0.6%)
dist/preact.mjs 4.9 kB +30 B (+0.62%)
dist/preact.umd.js 4.94 kB +28 B (+0.57%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.js 3.94 kB
compat/dist/compat.mjs 3.87 kB
compat/dist/compat.umd.js 4 kB
debug/dist/debug.js 3.91 kB
debug/dist/debug.mjs 3.91 kB
debug/dist/debug.umd.js 3.99 kB
devtools/dist/devtools.js 263 B
devtools/dist/devtools.mjs 273 B
devtools/dist/devtools.umd.js 348 B
hooks/dist/hooks.js 1.53 kB
hooks/dist/hooks.mjs 1.55 kB
hooks/dist/hooks.umd.js 1.59 kB
jsx-runtime/dist/jsxRuntime.js 892 B
jsx-runtime/dist/jsxRuntime.mjs 861 B
jsx-runtime/dist/jsxRuntime.umd.js 966 B
test-utils/dist/testUtils.js 473 B
test-utils/dist/testUtils.mjs 473 B
test-utils/dist/testUtils.umd.js 555 B

compressed-size-action

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 98.529%. remained the same — useid-boundary-marker into main

@JoviDeCroock

Copy link
Copy Markdown
Member Author

Unsure whether we want to do this over just allowing the user to specify the Suspense name...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants