Skip to content

fix(solid): sweep truncated fragments when hydration starts after parse - #4416

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
everton-dgn:fix/hydration-late-start-truncation
Oct 30, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
everton-dgn:fix/hydration-late-start-truncation

Conversation

@everton-dgn

@everton-dgn everton-dgn commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4415

Summary

watchTruncation only armed its DOMContentLoaded listener when the runtime booted with readyState === "loading". A runtime that boots after parsing (a deferred module entry, or an async entry waiting on a route chunk) never swept, so a truncated stream left its _fr declarations pending and hydration never ended.

With this change, when the parser is already done and the document carries the in-render bootstrap (_$HY.s), the same sweep runs at boot. Under that bootstrap every declaration and settle is a parser-inserted script, so a _fr still pending at that point was cut off. On a complete page nothing is pending and the sweep does nothing.

Rebased on next after #4414. The sweep keeps #4414's order: dropTruncatedRefs(), then the fragment pass, then rejectTruncatedRefs(consumed) a macrotask later. In the late case both passes run before the claim walk, so the dead records are already out of the registry and boundaries register against an already rejected declaration, taking the existing s === 2 path (the one truncated-stream-late-boundary.spec.tsx covers). With both changes, #4413's case also works with a late boot: each read the server never answered is asked once, and what it sent before the cut is adopted.

Why the _$HY.s check: without it, 54 tests in the hydrate config fail. They hand-build _$HY under jsdom's default readyState === "complete" and feed chunks after hydrate(), which a late sweep reads as truncated. Documents with a host-placed bootstrap (generateHydrationScript()) or a hand-built _$HY keep the current behavior.

The listener body moved into a sweep closure, so most of the hydration.ts diff is indentation. git diff -w shows 18 lines added and 11 removed, mostly comments.

Also updated the matching sentence in server-components-principles.md §5.5, the comment in truncated-stream.spec.tsx, and the comment in truncated-fragment-fresh-render.spec.tsx that called DOMContentLoaded the only window where the sweep arms.

How did you test this change?

  • truncated-stream-late-start.spec.tsx replays the late-boundary-after-done shell with _$HY.s set and readyState at "interactive", and never delivers the rest. Right after hydrate() the _fr is rejected and the shell's pending data ref is already out of the registry; then hydration finishes.
  • truncated-fragment-fresh-render-late-start.spec.tsx replays fix(solid): a truncated fragment's fresh render computes what the server never sent #4414's fixture cut after the first chunk, with a late boot. a:slow, b:slow and c:slow are each asked once, b:fast and c:bad are not asked again, the text matches the server's, _$HY.done is true and nothing is logged.

Negative control, before the rebase (fix reverted, solid-js rebuilt): the first spec fails at expect(ref.s).toBe(2) with undefined, and with a 400 ms wait _$HY.done is still undefined.

After the rebase, with pnpm build:

  • packages/web: vitest run 1557 passed, --config vite.config.server.mjs 1906 passed, --config vite.config.hydrate.mjs 716 passed (140 files)
  • packages/solid: vitest run 917 passed
  • turbo run test-types typecheck for solid-js and @solidjs/web, prettier --check on the changed files

Size (scripts/size, after the rebase): +18 B minified in each hydrating scenario, +19 B over the recorded minified, inside the gate's 20 B allowance. The typeof document guard sits after the sweep closure on purpose: there the minifier folds it into the dispatch instead of an early return, 7 B less than the first version (+25 B), which failed the gate on three page scenarios.

@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 05d621c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
solid-js Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/web Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@everton-dgn

Copy link
Copy Markdown
Contributor Author

I checked this together with #4414 on a local merge of both branches.

The only conflict is the watchTruncation body, and the two changes compose directly: dropTruncatedRefs goes at the start of sweep, and rejectTruncatedRefs(consumed) stays in the setTimeout.

One assertion in the new late-start spec changes. With #4414 the shell's pending data refs are already gone right after hydrate(), since the drop runs at the start of the sweep, so line 65 becomes expect(k in hy.r).toBe(false).

With that, test/hydration/truncated* and served-error-no-reask.spec.tsx pass (27/27), and the full hydrate config passes (139 files, 715 passed, 10 expected fail, 3 skipped). I'll push the adjustment here once #4414 lands, or rebase #4414 if this one goes first.

@codspeed

codspeed Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

2 warnings

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 2 regressed benchmarks
✅ 196 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ projection derive: write one NESTED field (reference) 2.1 ms 2.4 ms -12.01%
❌ memo + sync render effect only (reference) 30.1 ms 31.9 ms -5.62%
⚡ createStore setter: delete + set one root key (#3044 overlay) 2.1 ms 1.8 ms +16.15%
⚡ arrow in template 12 ms 11.1 ms +8.31%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing everton-dgn:fix/hydration-late-start-truncation (05d621c) with next (5e68925)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Copy link
Copy Markdown
Member

Thanks! This reviewed clean: the late-boot sweep is sound, since readyState only leaves "loading" at parser EOF and every _fr settle is a parser-inserted script. A complete-stream control with a late boot asks nothing and finishes.

#4414 just landed on next (5d57e12), so this needs a rebase. What we found when combining them locally:

Combined, #4413's case also works with a late boot: each slow read is asked once, the text matches the server's, and done is true. It might be worth adding that as a spec.


Generated by Claude Code

watchTruncation only armed its DOMContentLoaded listener when the runtime
booted with document.readyState === "loading". An entry that runs later
(a deferred module, or one that waits on a route chunk that lands after
DOMContentLoaded) never swept, so a truncated stream left its _fr
declarations pending and hydration never finished.

Under the in-render bootstrap (_$HY.s) every declaration and settle is a
parser-inserted script, so once parsing is over a pending _fr can only be
truncated. Run the same sweep at boot in that case: drop the records that can no
longer settle, reject the cut fragments, and a macrotask later reject the
refs a keyed consumer already took. Host-placed bootstraps keep the
current behavior.
@everton-dgn
everton-dgn force-pushed the fix/hydration-late-start-truncation branch from 4a61088 to a18099f Compare October 29, 2026 21:56
@everton-dgn

Copy link
Copy Markdown
Contributor Author

Rebased on next. sweep now runs dropTruncatedRefs(), then the _fr pass, then setTimeout(() => rejectTruncatedRefs(consumed)), with the same arming as before.

  • The boot assertion in truncated-stream-late-start.spec.tsx is now false, and its comments say the data refs leave the registry at boot.
  • The watchTruncation comment now says only the rejection of consumed refs waits its macrotask. I also fixed the comment in truncated-fragment-fresh-render.spec.tsx and the changeset, which still described the old order.
  • Added truncated-fragment-fresh-render-late-start.spec.tsx for 2.0: reads inside a <Loading> cut by a truncated stream never run on the client #4413's case with a late boot: each slow read is asked once, b:fast and c:bad are not asked again, the text matches the server's and done is true.

All web and solid suites pass after pnpm build. The numbers are in the description.

With the guard after the `sweep` closure, the minifier folds it into the
dispatch (`typeof document<"u"&&(...)`) instead of an early
`if(...)return;`: 7 B less minified in every hydrating scenario. This PR's
growth drops from +25 B to +18 B minified, back inside the size gate's
20 B allowance on the three page scenarios that went over it.
@everton-dgn

Copy link
Copy Markdown
Contributor Author

The CodSpeed failure on 05d621c doesn't come from this change. The two regressed benchmarks (packages/signals/tests/store/projection-root-write.bench.ts and packages/signals/tests/mount-rows.bench.ts) only import packages/signals/src, and this PR only touches packages/solid/src/client/hydration.ts. The previous head (a18099f), with the same fix, passed the gate, and the report notes that base and head ran on different runner types.

That last commit only moves the typeof document guard below the sweep closure, which brings the PR from +25 B to +18 B minified and back inside the size gate's allowance (Size / check is green now). I can't acknowledge the CodSpeed result from a fork, so it needs someone with access to the CodSpeed project.

@ryansolid
ryansolid merged commit 99d9821 into solidjs:next Oct 30, 2026
8 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 30, 2026
#4416 and #4442 (#4445)

Caps at CI brotli + 10 B rounded up to 0.01 KB, recorded minified = CI
measured (next @ 32163a9, size job 123956613767). #4416's truncation
sweep (+19 B minified on the hydrating scenarios) put the hydrating
floor (+6 B), base + router (+55 B) and live + router (+58 B) over
their brotli caps with 1 B of minified allowance left; those caps rise
and every scenario's recorded minified moves to what next measures.
The ratchet lowers 13 caps. Hello world stays at 9,961 B under its
9.98 KB cap.


Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Co-authored-by: Claude <[email protected]>
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