Skip to content

Retain and improve deferred hydration range markers - #5315

Merged
JoviDeCroock merged 10 commits into
mainfrom
JoviDeCroock/streamed-range-ownership
Oct 20, 2026
Merged

JoviDeCroock merged 10 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 🔍 -1% - +3% (-6.67ms - +18.11ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -1% - +0% (-0.10ms - +0.02ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -3% - +3% (-1.06ms - +1.37ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +3% (-0.19ms - +0.48ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -4% - +2% (-3.35ms - +1.35ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -3% - +8% (-0.05ms - +0.16ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -3% - +2% (-1.05ms - +0.78ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -4% - +4% (-1.13ms - +1.32ms)
    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.01ms - -0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -5% - +4% (-0.25ms - +0.21ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -4% - +2% (-0.05ms - +0.02ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.01ms - +0.01ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local586.36ms - 605.40ms-unsure 🔍
-1% - +3%
-6.67ms - +18.11ms
preact-main582.24ms - 598.09msunsure 🔍
-3% - +1%
-18.11ms - +6.67ms
-

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.39ms - 16.47ms-unsure 🔍
-1% - +0%
-0.10ms - +0.02ms
preact-main16.42ms - 16.52msunsure 🔍
-0% - +1%
-0.02ms - +0.10ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local38.71ms - 40.40ms-unsure 🔍
-3% - +3%
-1.06ms - +1.37ms
preact-main38.52ms - 40.27msunsure 🔍
-3% - +3%
-1.37ms - +1.06ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.11ms - 5.39ms-unsure 🔍
-5% - +4%
-0.25ms - +0.21ms
preact-main5.09ms - 5.45msunsure 🔍
-4% - +5%
-0.21ms - +0.25ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.20ms - 16.65ms-unsure 🔍
-1% - +3%
-0.19ms - +0.48ms
preact-main16.04ms - 16.53msunsure 🔍
-3% - +1%
-0.48ms - +0.19ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local71.80ms - 75.18ms-unsure 🔍
-4% - +2%
-3.35ms - +1.35ms
preact-main72.86ms - 76.12msunsure 🔍
-2% - +5%
-1.35ms - +3.35ms
-

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-local25.66ms - 26.57ms-unsure 🔍
-2% - +3%
-0.48ms - +0.74ms
preact-main25.58ms - 26.40msunsure 🔍
-3% - +2%
-0.74ms - +0.48ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local29.73ms - 30.83ms-unsure 🔍
-3% - +3%
-0.83ms - +0.78ms
preact-main29.72ms - 30.90msunsure 🔍
-3% - +3%
-0.78ms - +0.83ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local29.42ms - 30.69ms-unsure 🔍
-3% - +3%
-1.04ms - +0.77ms
preact-main29.54ms - 30.83msunsure 🔍
-3% - +3%
-0.77ms - +1.04ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local30.59ms - 31.47ms-unsure 🔍
-2% - +2%
-0.72ms - +0.75ms
preact-main30.43ms - 31.61msunsure 🔍
-2% - +2%
-0.75ms - +0.72ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local22.94ms - 24.11ms-unsure 🔍
-4% - +3%
-1.01ms - +0.68ms
preact-main23.08ms - 24.30msunsure 🔍
-3% - +4%
-0.68ms - +1.01ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local27.49ms - 29.16ms-unsure 🔍
-7% - +1%
-2.00ms - +0.40ms
preact-main28.27ms - 29.99msunsure 🔍
-1% - +7%
-0.40ms - +2.00ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 220
  • Built by: CI #6108
  • Commit: e4c2527

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.89ms - 2.04ms-unsure 🔍
-3% - +8%
-0.05ms - +0.16ms
preact-main1.83ms - 1.99msunsure 🔍
-8% - +3%
-0.16ms - +0.05ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.02ms - 1.07ms-unsure 🔍
-4% - +2%
-0.05ms - +0.02ms
preact-main1.04ms - 1.08msunsure 🔍
-2% - +4%
-0.02ms - +0.05ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local32.07ms - 32.92ms-unsure 🔍
-3% - +2%
-1.05ms - +0.78ms
preact-main31.82ms - 33.44msunsure 🔍
-2% - +3%
-0.78ms - +1.05ms
-

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-local29.53ms - 31.32ms-unsure 🔍
-4% - +4%
-1.13ms - +1.32ms
preact-main29.50ms - 31.16msunsure 🔍
-4% - +4%
-1.32ms - +1.13ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.99ms - 3.00ms-unsure 🔍
-0% - +0%
-0.01ms - +0.01ms
preact-main2.99ms - 3.00msunsure 🔍
-0% - +0%
-0.01ms - +0.01ms
-

tachometer-reporter-action v2 for CI

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Size Change: +49 B (+0.3%)

Total Size: 16.4 kB

📦 View Changed
Filename Size Change
dist/preact.mjs 5.01 kB +49 B (+0.99%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.mjs 3.96 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.856% (-0.001%) from 99.857% — 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
rangeEnd() already walks the range, so let it collect the inner nodes
instead of re-walking nextSibling until the end marker. The array is only
allocated for components that own a range (`_excess` set).

-11 B gz / -10 B br.
@JoviDeCroock
JoviDeCroock merged commit b13a013 into main Oct 20, 2026
13 checks passed
@JoviDeCroock
JoviDeCroock deleted the JoviDeCroock/streamed-range-ownership branch October 20, 2026 07:44
@JoviDeCroock JoviDeCroock mentioned this pull request Oct 20, 2026
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.

3 participants