Skip to content

Retain and improve deferred hydration range markers - #5315

Open
JoviDeCroock wants to merge 8 commits into
mainfrom
JoviDeCroock/streamed-range-ownership
Open

JoviDeCroock wants to merge 8 commits into
mainfrom
JoviDeCroock/streamed-range-ownership

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Note

Tests were written by claude, reviewed by me and fix was implemented by me

Problem

During hydration, a component that suspends owns a range of DOM, not a single node. With renderToStringAsync, the server output looks like this:

<!--$s--> …server-rendered content… <!--/$s-->

The vnode has only one pointer, vnode._dom, plus vnode._component._excess, which points at the start marker. Each part of the diff that reads _dom expects something different. getDomSibling expects the first node of a range, diffChildren's cursor the last node of a range, unmount all nodes of a range and insert will walk _children.

Fix

The vnode now owns the whole range explicitly.

  • _dom is the $s comment marking the start of a suspending range.
  • A suspended diff() returns end.nextSibling as the cursor for diffChildren, so siblings continue after the closing marker.
  • The insert() tail stops at an $s starting comment marker.
  • unmount removes only what the component owns:
    • a pending owner (_excess == _dom) removes its start marker, contents and end marker;
    • a resolved owner removes only the two markers, because its content is child vnodes now and a keyed reorder may have moved it.
  • _excess is kept after a resume (it used to be cleared), so unmount can still find the markers.
  • An empty range resumes in place: oldDom = excessDomChildren[0] || end.

All fixed cases are in our tests. This is a follow-up to #5314 and has taken an excruciating amount of time to find all edge cases as only a few were found there and more kept popping up 😅

One thing that I did not fix that would take up a lot more bytes is a keyed reorder that moves a pending boundary, the range stays where it was. Most solutions were coming close to 100b and had some compromises. Need to think that through more.

A component that suspends while hydrating keeps the server DOM between its
`<!--$s-->` and `<!--/$s-->` markers, but its vnode could only point at one
node of that range. The first suspension pointed `_dom` at the closing
marker and a re-suspension (any parent rerender while pending) at the first
content node, so:

- unmounting a pending boundary left its opening marker and server content
  behind (after a re-suspension it also left the rest of the content and
  the closing marker)
- a sibling mounted after a re-suspended boundary, or before any pending
  one, was inserted inside the range, and since 9ba8f34 the resume
  removes it as unclaimed DOM while its vnode keeps the detached node
- several siblings mounted at once before a pending boundary had the same
  problem, because insert() stepped over the opening marker into the range

`_dom` is now the opening marker, the cursor continues after the closing
marker, unmount removes the whole range, and insert() no longer steps into
a range. The depth-balanced scan is shared with the resume path.

preact 4546 -> 4597 B br (+51), 4971 -> 5038 B gz
- rangeEnd() returns nothing unless it starts at a `$s` marker, so the
  separate isRangeStart() helper and its guards at the call sites go away
- unmount removes the range in a single guarded loop instead of if + do/while

preact 4597 -> 4583 B br (-14), 5038 -> 5028 B gz
A resumed range starts its cursor at the first kept node. An empty range
(`<!--$s--><!--/$s-->`, e.g. a streamed Suspense without a fallback) has
none, so the resolved content was appended at the end of the parent instead
of between its markers. Fall back to the end marker.

preact 4583 -> 4588 B br (+5)
Once a boundary resolved, nothing referenced its `<!--$s-->` markers, so
unmounting it left an empty pair behind. With streaming that isn't inert: a
`<preact-island>` chunk arriving after the unmount finds the empty range and
inserts the server HTML again, so unmounted content reappears.

Keep `_excess` after the resume (the resume path is gated on MODE_SUSPENDED,
so it is never re-entered) and remove the markers in unmount. A resolved
owner only removes the two markers: its content went with its child vnodes,
and after a keyed reorder the markers may surround a sibling's content.
Resuming stays mutation-free.

preact 4588 -> 4595 B br (+7)
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -0% - +0% (-3.26ms - +2.26ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.03ms - +0.04ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -2% - +1% (-1.43ms - +0.70ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +3% (-0.17ms - +0.44ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -4% - +2% (-3.23ms - +1.41ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -8% - +7% (-0.09ms - +0.08ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -2% - +2% (-0.37ms - +0.36ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -4% - +4% (-1.36ms - +1.49ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - -0% (-0.00ms - -0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -8% - +1% (-0.44ms - +0.05ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -7% - +0% (-0.08ms - +0.00ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +1% (-0.00ms - +0.02ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local657.44ms - 661.30ms-unsure 🔍
-0% - +0%
-3.26ms - +2.26ms
preact-main657.90ms - 661.84msunsure 🔍
-0% - +0%
-2.26ms - +3.26ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.24ms - 19.24ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main19.24ms - 19.24msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.51ms - 16.57ms-unsure 🔍
-0% - +0%
-0.03ms - +0.04ms
preact-main16.52ms - 16.56msunsure 🔍
-0% - +0%
-0.04ms - +0.03ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.56ms - 1.56ms-unsure 🔍
-0% - -0%
-0.00ms - -0.00ms
preact-main1.56ms - 1.56msunsure 🔍
+0% - +0%
+0.00ms - +0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local60.43ms - 61.96ms-unsure 🔍
-2% - +1%
-1.43ms - +0.70ms
preact-main60.83ms - 62.31msunsure 🔍
-1% - +2%
-0.70ms - +1.43ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.13ms - 5.35ms-unsure 🔍
-8% - +1%
-0.44ms - +0.05ms
preact-main5.22ms - 5.65msunsure 🔍
-1% - +8%
-0.05ms - +0.44ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.26ms - 16.68ms-unsure 🔍
-1% - +3%
-0.17ms - +0.44ms
preact-main16.11ms - 16.56msunsure 🔍
-3% - +1%
-0.44ms - +0.17ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.75ms - 3.75ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main3.74ms - 3.75msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #6104
  • Commit: 17196e5

duration

VersionAvg timevs preact-localvs preact-main
preact-local73.59ms - 76.65ms-unsure 🔍
-4% - +2%
-3.23ms - +1.41ms
preact-main74.28ms - 77.78msunsure 🔍
-2% - +4%
-1.41ms - +3.23ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.03ms - 3.03ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main3.03ms - 3.03msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local27.24ms - 28.06ms-slower ❌
0% - 4%
0.06ms - 1.17ms
preact-main26.65ms - 27.41msfaster ✔
0% - 4%
0.06ms - 1.17ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local31.20ms - 32.34ms-unsure 🔍
-2% - +4%
-0.54ms - +1.14ms
preact-main30.86ms - 32.08msunsure 🔍
-4% - +2%
-1.14ms - +0.54ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local30.10ms - 31.24ms-unsure 🔍
-3% - +2%
-0.91ms - +0.71ms
preact-main30.20ms - 31.35msunsure 🔍
-2% - +3%
-0.71ms - +0.91ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local31.39ms - 32.16ms-unsure 🔍
-2% - +2%
-0.62ms - +0.51ms
preact-main31.42ms - 32.24msunsure 🔍
-2% - +2%
-0.51ms - +0.62ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local24.01ms - 25.21ms-unsure 🔍
-3% - +3%
-0.86ms - +0.85ms
preact-main24.01ms - 25.22msunsure 🔍
-3% - +3%
-0.85ms - +0.86ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local28.66ms - 30.44ms-unsure 🔍
-5% - +4%
-1.42ms - +1.05ms
preact-main28.88ms - 30.59msunsure 🔍
-4% - +5%
-1.05ms - +1.42ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 250
  • Built by: CI #6104
  • Commit: 17196e5

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.03ms - 1.15ms-unsure 🔍
-8% - +7%
-0.09ms - +0.08ms
preact-main1.03ms - 1.15msunsure 🔍
-8% - +8%
-0.08ms - +0.09ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.04ms - 1.09ms-unsure 🔍
-7% - +0%
-0.08ms - +0.00ms
preact-main1.08ms - 1.14msunsure 🔍
-0% - +7%
-0.00ms - +0.08ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local20.53ms - 21.04ms-unsure 🔍
-2% - +2%
-0.37ms - +0.36ms
preact-main20.53ms - 21.05msunsure 🔍
-2% - +2%
-0.36ms - +0.37ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.28ms - 1.28ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.27ms - 1.28msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local33.42ms - 35.29ms-unsure 🔍
-4% - +4%
-1.36ms - +1.49ms
preact-main33.22ms - 35.37msunsure 🔍
-4% - +4%
-1.49ms - +1.36ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.00ms - 3.01ms-unsure 🔍
-0% - +1%
-0.00ms - +0.02ms
preact-main2.99ms - 3.01msunsure 🔍
-1% - +0%
-0.02ms - +0.00ms
-

tachometer-reporter-action v2 for CI

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Size Change: +60 B (+0.37%)

Total Size: 16.4 kB

📦 View Changed
Filename Size Change
dist/preact.mjs 5.02 kB +60 B (+1.21%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.mjs 3.95 kB
debug/dist/debug.mjs 4.2 kB
devtools/dist/devtools.mjs 268 B
hooks/dist/hooks.mjs 1.58 kB
jsx-runtime/dist/jsxRuntime.mjs 868 B
test-utils/dist/testUtils.mjs 472 B

compressed-size-action

@JoviDeCroock
JoviDeCroock force-pushed the JoviDeCroock/streamed-range-ownership branch from a64d1e1 to 564ad29 Compare October 19, 2026 05:56
@coveralls

coveralls commented Oct 19, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 99.857%. remained the same — JoviDeCroock/streamed-range-ownership into main

@JoviDeCroock JoviDeCroock changed the title Jovi de croock/streamed range ownership Reatain and improve deferred hydration range markers Oct 19, 2026
@JoviDeCroock JoviDeCroock changed the title Reatain and improve deferred hydration range markers Retain and improve deferred hydration range markers Oct 19, 2026

This branch has not been deployed

No deployments
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