Skip to content

fix(solid): a rejected fragment's fresh render claims what the server sent - #4319

Merged
ryansolid merged 26 commits into
nextfrom
claude/project-thread-x1ore2-uncontained-noreask
Oct 27, 2026
Merged

ryansolid merged 26 commits into
nextfrom
claude/project-thread-x1ore2-uncontained-noreask

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: when a server failure had no <Errored> to catch it, the fragment arrived rejected with no outcome. The client rendered the subtree fresh, and every async read in that render asked again. That included reads the server had already answered, and the read that failed. For #4242's JSX memo, it meant the client built the element the server had refused.

After: the client still renders the subtree fresh, so the graph under the boundary is created. Every read in that render uses the record the server sent instead of asking again:

  • A read the server resolved takes its value.
  • A read whose record is an error throws that error, as the server serialized it, to the nearest client boundary. With no client boundary it is an uncaught error, as it was on the server.
  • Only a read with no record computes. This includes reads the server abandoned when it gave up on the subtree.

This follows Ryan's ruling for hydration: "we rerendered so that the graph under boundary is properly created but under hydration it shouldn't be refetching.. it should be finding serialized values". A user's own client write during hydration that re-runs a reader of a served error still retries the fetch, as it does after hydration. That was also Ryan's call, since holding it costs +235 B hydrating or +20–35 B on CSR.

How:

  • Fresh render after a rejection (client/hydration.ts).
    • A rejection, whether the server's or a stream cut short, now resumes the boundary with 0 instead of false.
    • resumeBoundaryHydration runs every fresh render as servedPass(set, shouldHydrate === 0). It adopts by id only after a rejection, never for a superseded render or an asset failure.
    • servedPass resets its state in try/finally, because an uncaught error leaves the flush by throwing. The same finally runs checkHydrationComplete, so hydration still finishes after an adopted uncaught error.
    • While adopting, servedPass also owns the snapshot root. Without it, an ssrSource: "client" node such as a <Portal>'s content marked the page root as a snapshot scope that nothing released, and the Portal never mounted.
  • Abandoned records (web/src/server.ts abandonSubtree, server only).
    • Records the server abandoned still settle, so the serializer can finish.
    • A record not yet on the wire is dropped from the stub batch.
    • A record already registered gets a delete _$HY.r[id] task ahead of the _fr rejection.
    • Its read then has no record and computes, instead of adopting undefined.
  • Id alignment (web/src/client.ts getNextElement). The server spends a child id on every element's hydration key, but a non-claiming client render didn't. In the fresh render, every owner after an element got a shifted id, so it could miss its own record or take a sibling's. getNextElement now consumes the key whenever hydration is on.
  • Dynamic tags (web/src/index.ts staticElement). A template-less <Dynamic component="p"> called getNextElement() with nothing to create from, which threw a hydration mismatch in the fresh render. It now passes its create thunk, so the key is spent and the element is created, like a compiled template.
  • No signals changes and no new exports.

How did you test this change?

Size-Exception: "app: hydrating + every store primitive family" +34 B minified over the recorded size (+18 B from this PR, the rest from #4314), accepted by the maintainer 2026-10-27.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

claude and others added 13 commits October 26, 2026 18:15
…e, and adopt it (#4287)

Outside a server component, a <Loading> whose async read failed during a
streamed render settled its fragment blank, and the hydrating client
rendered the failed boundary from scratch: it asked again and showed the
loading fallback.

Server: the failing <Loading> now asks the enclosing <Errored> for its
outcome outside server components too, passing its own id. The Errored
answers only when that Loading is all of its content (its resolved markup
is the Loading's placeholder): its fallback is drawn into the fragment, or
inlined pre-flush, and the error is serialized at the Errored id. Deeper
in the content it answers nothing, as before. A rejected fragment's
template is marked `e`, and `$dfr` records the nodes it swaps in for it.

Client: inside a hydrated Errored's no-claim content run, a <Loading>
runs its content with its data adopted instead of waiting on its
fragment, so the Errored holds the client's own error and tracks the
content's inputs (#4273). A boundary that claimed its fallback and then
renders a rejected fragment fresh clears the nodes the swap put in first,
so nothing of the server's outcome is left beside the client's render.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…p is sent

When a streamed <Loading> fails after the shell and asks the enclosing
<Errored> for its outcome, the fallback may hold an async hole that has
not resolved yet. renderOutcome then returns no markup (the fragment
settles as a plain rejection), but handleError had already serialized
the error at the Errored id. A client hydrating after the failure read
that record, claimed a fallback that was never sent, and went blank
with a hydration key miss.

The outcome path now defers the record and writes it only once the
fallback resolved to finished markup. Without it the client renders the
failure itself, as on `next`. Server-only; no client bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…resolves (#4306)

An <Errored> whose fallback reads async data, around a <Loading> that
fails after the shell inside an outer <Loading> still pending, lost its
region: the outcome render met the fallback's pending hole, answered no
markup, and the parked fragment spliced a blank into the outer one while
its `_fr` resolved. The client claimed against the blank and showed
nothing there, with hydration warnings.

renderOutcome now answers a still-resolving fallback with a function
that waits out its holes and returns the finished markup, serializing
the error only then. The <Loading> calls it only while its fragment is
parked in an enclosing pending one (the registry's new `parked()` query
on the settle function), where nothing of the failure shows yet: the
outer fragment carries the fallback, or, if the outer settles first,
the inner fragment streams it when it lands. A failure in a region
already shown settles at once, as before. Server-only; no client bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…c holes (#4306)

Per RULES-DERIVED D-18 (a swap to a fallback waits for the fallback to be
ready), the wait for an Errored fallback's async holes no longer depends
on the fragment being parked. A <Loading> that fails after the shell under
an <Errored> whose fallback is still resolving keeps its placeholder until
the finished fallback is ready, then streams it with the error record, in
the parked case (carried by the outer fragment while that is pending) and
the unparked one alike. The registry's `parked()` query is dropped.

#4303's async-fallback scenario now expects the server's finished fallback
and its record: a client hydrating after the failure arrived claims it and
asks nothing again. Server-only; no client bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-4306

# Conflicts:
#	packages/solid/src/server/hydration.ts
#	packages/solid/src/server/signals.ts
#	packages/web/test/harness/__artifacts__/errored-loading-postflush-rejection-async-fallback-4287.json
#	packages/web/test/harness/scenarios.tsx
#	packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
…ad of asking again (#4287, #4306)

A client hydrating mid-stream asked again for an async memo whose server
read failed, and flashed back to the inner <Loading> fallback after the
failure showed: Errored > Loading > read, with the read failing after the
shell, unparked (the fragment rejects and the client renders the failure
fresh over the swapped-in Errored fallback) or parked in a pending outer
Loading (the outer resume runs the hydrated Errored's content, NO_CLAIM).

Both content runs read the shell memo a cycle after its error landed, and
a later tracked read retries an errored async node (PJ-R35). The retry
re-enters the hydration latch, which since f3da41e has taken every
re-entry for a dependency change ("re-entry is always invalidation-
driven"), and since b68a907 computes such a rerun at once for a shell
node outside the claim in progress: one real ask, the Loading fallback
for its duration. Before f3da41e the latch re-served the serialized
answer on every re-entry while hydrating, so the retry asked nothing.

Inside a hydrated failure's content run (NO_CLAIM) a latched rerun now
re-serves the server's answer, its error included, and arms nothing. A
rejected fragment's fresh render runs as such a content run when the swap
brought the outcome the server drew for the failure (the reveal record
lists its nodes); to make that list the outcome alone, a rejected
fragment with no outcome streams an empty template instead of a lone
space. Uncontained failures (no outcome) keep rendering fresh as before.

Tests: the #4287/#4306 hydration specs stream piece by piece with a clock
tick between pieces (the plain-fallback case only passed because no flush
ran between the error landing and the fresh read), assert 0 asks in both
modes and no flash back to a loading fallback, and cover the parked
plain-fallback scenario, which now has an ask counter and an update pass
(the Errored tracks the memo, #4273).

Size: 0 B on every CSR scenario (compiled floor 9,966 B); +65-71 B
minified on the hydrating scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…rs instead of asking again

Hydrated mid-stream, an async shell memo `b` reading another, `a`, both
read only inside a streamed <Loading>, asked again when `a`'s server
answer arrived: the landing re-ran `b`, and the hydration latch took the
rerun for a client write (f3da41e's "re-entry is always invalidation-
driven"; b68a907 computes it at once for a shell node). The server's
`b` was computed from that same `a`; nothing on the client diverged.

The latch now has one rule: a latched rerun inside hydration's own pass
over served answers (NO_CLAIM) re-serves its own answer and arms nothing.
That pass is a hydrated failure's content run (the Errored's, or a
rejected fragment's fresh render over the outcome the server drew, from
the previous commit) and, new here, a served answer's arrival:
readHydratedValue hands the runtime a thenable whose landing runs, with
the flush that carries it, under NO_CLAIM. The NO_CLAIM check moves to
the head of the latch rule, so in-claim and held reruns there re-serve
without arming a takeover too. Client writes still compute (the
write-before-resume, nav-before-resume and hydration-latch pins), and a
retry outside that pass still follows the client rule.

Tests: a parity scenario and spec for the b-reads-a shape (0 asks loaded
and streamed piece by piece with clock ticks, one real ask after a client
write to their input); both fail before this commit.

Size: 0 B on every CSR scenario (compiled floor 9,966 B); +19-27 B
minified on the hydrating scenarios over the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
A served answer's arrival is hydration's own pass (NO_CLAIM): a latched
node re-running there re-serves its server answer. A client write
carried by that same flush must still make the node compute.

- Client writes already made (pending, unflushed) flush first, outside
  the pass (`servedPass`). The async runtime's landing flushes
  synchronously, and that flush would otherwise carry them.
- The pass covers only the flush round its served write lands in
  (`_servedAt = clock`). An effect's write runs in a later round of the
  same flush, and a latched node re-run there computes. The latch tests
  `clock > _servedAt`.

`@solidjs/signals` exports its flush-round counter (`clock`), an
internal live binding, at 0 B.

Landings cannot be coalesced into one pass per microtask batch without
changing signals. `asyncWrite` already flushes synchronously on every
landing (`schedule(); flush()`) at the #4308 head, so a served pass
adds no flush per landing. Its trailing `flush()` is a no-op after a
value landing.

Tests: in served-source-landing, a write before the flush and an
effect's write in the landing's flush both give the written value with
1 ask. Both fail on 2b2990a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
This changes no behaviour. Client bytes over next go from about +130 B
to +58 B minified.

- A hydrated `<Errored>`'s content run and a served pass are one window
  kind: `_claimOwner = ~clock`, a window over no owner for the flush round
  it opened in. `~` keeps it truthy when clock is 0.
- That replaces both `NO_CLAIM` and `_servedAt`. The latch checks one
  thing, `_claimOwner !== ~clock`, and a later round of an arrival's flush
  (an effect's write) still computes. `isClaiming` reads a number as no
  owner, so nothing claims, as before.
- `servedPass` is curried (`servedPass(f)(x)`), so the arrival thenable
  is `initP.then(servedPass(a), servedPass(b))`.
- A resume clears the hydration flag once, before either branch. The
  fresh branch's own flush was redundant with the resume's trailing one.
  The outcome check reads `?.[0]` instead of `?.length`.

The pre-flush stays on both paths. Removing it makes the
write-before-landing test fail, and comparing only `_claimOwner` without
the round makes the effect-write test fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-hydrate-reask

# Conflicts:
#	packages/web/test/harness/scenarios.tsx
#	packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
#	packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx
… sent

Hydration never asks again for an answer the server sent. When a streamed
<Loading> fragment rejects with no outcome (no <Errored> on the server
could draw the failure: the one above it was already on the wire, or
there is none), the client still renders the fragment fresh, so the
graph under the boundary is created. Now every read in that render
claims its serialized record instead of asking again:
- a read the server resolved takes its value;
- a read whose record is an error throws that error (as the server
  serialized it: redacted unless markSafeError) to the nearest client
  boundary through the normal path, or is an uncaught error when there
  is none;
- only a read with no record at all computes (a truncated stream's refs
  are dropped from the registry).

How: a rejection (the server's, or the stream cut short) now resumes the
boundary with 0 rather than false, and resumeBoundaryHydration runs every
fresh render as a served pass (`servedPass(set, adopt)`), adopting
(`_hydratingValue`) only after a rejection. A superseded render and an
asset failure do not adopt. servedPass resets its state in a `finally`,
because an uncaught error leaves the flush by throwing. Recovery is a
real input change or a client boundary's reset(), as for any hydrated
failure (#4273).

Changed expectations:

async-memo-element-4242.spec.tsx, uncontained variants and their
`-reference` twins (the generic "server render error at this position"
path, so both change together; they still compare equal):
- runs: [] for the jsx shape. `outer` adopts the server's error and
  never runs. (The loaded-mode check used to expect the client to
  compute the element: "jsx-41Count: 1".) The issue shape keeps
  ["inner"]. That run is the fixture's own: the dry run's continuation
  escapes the mock Promise through a real timer and builds the inner
  slot, whose memo has no owner id to adopt by.
- halted: true, done: false (were false/true in loaded mode). The
  adopted error has no client boundary to reach (a <Loading> does not
  catch), so it is uncaught, as on any client render. The reactive
  system halts, and the halted resume never completes hydration.
- text "", afterClick undefined (were "jsx-41Count: 1"/"Count: 1", and
  "nested-41"). The element came from the errored memo, so it never
  renders and there is no counter to click.
- shown ["outer", ""] streamed, [""] loaded: the fallback, then nothing.

client-hydration.spec.ts, "rejected _fr resumes without hydrating
serialized children" -> "rejected _fr resumes fresh, adopting serialized
children": memo() 0 -> 7. The fresh DOM stays, but the serialized 7 is
the server's answer, so the client uses it rather than computing 0.

client-recovery-record.spec.ts (three tests): b.memo() 0 -> 7, for the
same reason. The recovery records are unchanged.

New: served-error-no-reask.spec.tsx with two parity-harness scenarios.
- nested-rejection-no-outcome: a shell <Errored> over a streamed outer
  <Loading> (a resolved read, a counter) and an inner <Loading> whose
  read fails on the server beside a read the server resolved. The inner
  fragment rejects with no outcome. The fresh render shows the server's
  error in the shell Errored, with 0 asks for either read. reset() asks
  once, and the recreated graph is live (the counter clicks).
- served-error-reread: a served error mid-hydration (a slow boundary
  still pending) stands with no ask. reset() is a real retry: one ask,
  and it recovers while hydration still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 599895e

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

This PR includes changesets to release 15 packages
Name Type
@solidjs/web Patch
solid-js Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/universal 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

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.42 KB 0 B 0 B −119 B 7.44 KB ✅
signals: + createStore 14.87 KB 0 B 0 B −1463 B 15.04 KB ✅
signals: + isPending/latest 10.38 KB 0 B 0 B −8 B 10.38 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −123 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B −107 B 9.75 KB ⚠️ over by 29 B, 127 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.61 KB +12 B (+0.1%) +14 B −180 B 18.49 KB ⚠️ over by 122 B, 200 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.94 KB −98 B (−0.3%) +18 B 0 B 30.96 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.39 KB 0 B 0 B −186 B 13.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.10 KB 0 B 0 B −189 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.69 KB 0 B 0 B −190 B 29.70 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B −106 B 9.93 KB ⚠️ over by 26 B, 126 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.14 KB 0 B 0 B −736 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.24 KB 0 B +28 B −663 B 32.25 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.45 KB −43 B (−0.1%) +11 B −177 B 33.37 KB ⚠️ over by 78 B, 197 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 8.92 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.92 KB −45 B (−0.1%) +17 B +20 B 37.75 KB ⚠️ over by 171 B, 0 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 8.93 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.53 KB +32 B (+0.1%) +25 B −160 B 35.43 KB ⚠️ over by 97 B, 180 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.77 KB −50 B (−0.1%) +24 B +14 B 41.56 KB ⚠️ over by 205 B, 6 B minified headroom eager (counted): web.js 22.32 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.95 KB −77 B (−0.2%) +10 B −179 B 40.81 KB ⚠️ over by 139 B, 199 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 8.97 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.32 KB −29 B (−0.1%) +15 B +11 B 47.16 KB ⚠️ over by 161 B, 9 B minified headroom eager (counted): client.js 27.42 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.32 KB, trace.js 8.95 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: render + one signal (the simple-app floor): over brotli cap by 29 B; minified 26,922 B vs 27,029 B recorded with the cap (−107 B) — 127 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 122 B; minified 53,440 B vs 53,620 B recorded with the cap (−180 B) — 200 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 26 B; minified 27,454 B vs 27,560 B recorded with the cap (−106 B) — 126 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 78 B; minified 101,382 B vs 101,559 B recorded with the cap (−177 B) — 197 B of the 20 B minified allowance left; +11 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 171 B; minified 115,237 B vs 115,217 B recorded with the cap (+20 B) — 0 B of the 20 B minified allowance left; +17 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 97 B; minified 107,758 B vs 107,918 B recorded with the cap (−160 B) — 180 B of the 20 B minified allowance left; +25 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 205 B; minified 123,230 B vs 123,216 B recorded with the cap (+14 B) — 6 B of the 20 B minified allowance left; +24 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 139 B; minified 125,208 B vs 125,387 B recorded with the cap (−179 B) — 199 B of the 20 B minified allowance left; +10 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 161 B; minified 140,491 B vs 140,480 B recorded with the cap (+11 B) — 9 B of the 20 B minified allowance left; +15 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

…gned ids

The previous commit made a rejected fragment's fresh render adopt what
the server sent by id. That exposed two wrong-data cases.

Abandoned records. When a fragment fails on the server with nested reads
still pending, abandonSubtree settles those records to release seroval,
and they reached the client as `undefined`. The fresh render adopted
that `undefined` as the server's answer. The server now also unregisters
them: a record not yet on the wire is dropped from the stub batch, and
one already registered gets a `delete _$HY.r[id]` document task, queued
ahead of the `_fr` rejection that triggers the fresh render. The read then
has no record and computes. Frame sinks are untouched, since they have no
reader for these records. Server-only: 0 B on the client for this part.

Id alignment. The server spends a child id on every element's hydration
key. A non-claiming client render did not (getNextElement only read the
key while claiming), so in a fresh render every owner after an element
got a shifted id. It could miss its own record or take a sibling's. Now
getNextElement consumes the key whenever hydration is on, claiming or
not. A non-adopting fresh render runs with the flag off, so it is
unchanged. Hydratable-compiled bundles only.

Test: abandoned-sibling-computes (loaded and streamed). A <Loading>
holds a read `a`, a read `d` after an element, and a nested <Loading>
with a read `c` (50ms on the server). Its content throws on the server
once `a` lands, a render error that no record carries. The fragment
rejects with `c` still pending. The client shows "adc!an0tail": `a` and
`d` are adopted with 0 asks, `c` computes once and gets its own value,
and the counter clicks. Without the server change `c` shows "" (it
adopts `undefined`). Without the key change `d` asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40534857438

Coverage increased (+0.1%) to 78.727%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 11 of 11 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1357
Covered Lines: 1127
Line Coverage: 83.05%
Relevant Branches: 1078
Covered Branches: 790
Branch Coverage: 73.28%
Branches in Coverage %: Yes
Coverage Strength: 28.47 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.7%

2 warnings

⚠️ 3 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 regressed benchmarks
✅ 194 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ createStore setter: delete + set one root key (#3044 overlay) 1.8 ms 2.1 ms -14.09%
❌ memo + sync render effect only (reference) 29.7 ms 34.3 ms -13.32%

Tip

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


Comparing claude/project-thread-x1ore2-uncontained-noreask (599895e) with next (8cff139)

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. ↩

A recover only runs while hydrating, so the guarded globalThis read is dead
weight: -12 B minified on every hydrating and page scenario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…rate-reask' into claude/project-thread-x1ore2-uncontained-noreask

# Conflicts:
#	packages/solid/src/client/hydration.ts
Keeps both sides of the harness scenarios and the 4306 spec imports. #4318's
new artifacts and its rejected-fragment expectations take this branch's empty
outcome template (`e></template>`, no lone space).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…rate-reask' into claude/project-thread-x1ore2-uncontained-noreask
…resh renders

With the fresh render adopting the server's records, the uncaught late
variant halts (no client boundary, as on the server) and hydration never
completes, as #4242's uncontained variants do. The parked twin parity now
compares warnings too: the streamed key miss it excluded no longer happens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-uncontained-noreask

# Conflicts:
#	packages/solid/src/client/hydration.ts
#	packages/web/test/harness/scenarios.tsx
@ryansolid
ryansolid marked this pull request as ready for review October 27, 2026 07:44
…gs and mounts Portals

Review of #4319 found two regressions, both in a <Loading> whose server
render threw. Both are caused by c376083: the adopting fresh render runs
with `sharedConfig.hydrating` on (servedPass sets `_hydratingValue =
adopt`, packages/solid/src/client/hydration.ts:605 at 57b6925) but
claims nothing. Before it, a fresh render ran with hydration off.

1. <Dynamic component="p"> threw "Hydration Mismatch. Unable to find DOM
   nodes for hydration key", and the page halted. staticElement
   (packages/web/src/index.ts:323-330) branches on the bare flag and calls
   getNextElement() with no template. Off the claim, getNextElement can
   only create from a template, so it threw. That has been the case since
   before the key change too. Fix: pass the create thunk. The key is spent
   (ids stay aligned) and the element is created, like a compiled template.
   This is the only template-less getNextElement caller. getHydrationKey
   has no other client callers.

2. <Portal> never mounted. Its content memo is `ssrSource: "client"`
   (packages/web/src/index.ts:155), so with hydration on it goes through
   withHydrationGate (hydration.ts:1174-1178), which writes
   setHydrated(true) right after creation. hydrateSignalLike calls
   markTopLevelSnapshotScope (hydration.ts:1191, 327-335). In the fresh
   render no window owned the snapshot root, so it marked the page root as
   a snapshot scope. The root's scope is released only by the `hydrating`
   setter (hydration.ts:1557-1564), and servedPass bypasses the setter. The
   scope stayed open, the gate write stayed held, and the gate memo never
   re-ran (confirmed: it ran once, with hydrated() false). Fix: an
   adopting servedPass sets `_snapshotRootOwner` for its duration, so the
   render opens no snapshot scope. That is a client render, as before
   c376083. The rest of the gated nodes (the Portal anchor memo,
   index.ts:225) are fixed by the same change.

Sweep of the other sites that branch on the bare flag during such a
render. None breaks:
- clientOnly (index.ts:458/468) shows its fallback and swaps at
  hydration end;
- lazy (component.ts:188/276), the dynamic factory warm-up
  (dynamic.ts:173) and eager list mapping (flow.ts:106) are harmless or
  keep ids aligned;
- reclaimRegion (client.ts:1484) only acts on a connected parent whose
  tracked nodes are detached, which is the cleared fallback region here.

Nits:
- resumeBoundaryHydration runs checkHydrationComplete in a finally. An
  adopted error with no client boundary throws out of the render, but the
  boundary has resumed, so hydration completes. Updated: #4242's
  uncontained variants and twins, and #4316's late case (16cec07), now
  expect done true. halted stays true.
- getNextElement: the reviewer's trim (node read only while claiming,
  one `!node` test).

Tests (served-error-no-reask.spec.tsx; new scenarios
rejected-fragment-dynamic and rejected-fragment-portal, a server-only
render throw after the shell): loaded and streamed, the text is
"adyn!atail" / "a!aportaltail", hydration is done, and there are no
console errors. Before the fixes: "tail" (halted) and "a!atail" (no
portal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
servedPass's `finally` now runs checkHydrationComplete itself, after
resetting the pass state. resumeBoundaryHydration drops its own
try/finally: a boundary's fresh render is that resume, and its uncaught
error still completes hydration. Everywhere else a served pass's check is
a no-op: a landing comes when hydration is done, or while boundaries are
still pending.

getNextElement reads isHydrating() inline instead of through a variable
(the dev mismatch warning calls it again, dev only).

Behaviour is unchanged. It saves 8 B minified on every hydrating bundle
(5 B in the compiled ones).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Size-Exception accepted by the maintainer (2026-10-27): +34 B minified
over the recorded size on "app: hydrating + every store primitive family".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Copy link
Copy Markdown
Member Author

CodSpeed Performance Analysis is red on 3abcc8b with two regressions: createStore setter: delete + set one root key (#3044 overlay) at −14% and memo + sync render effect only (reference) at −13%. Neither comes from this PR. Both benches live in packages/signals, and this PR changes nothing there: git diff 0090fe34a 3abcc8bef -- packages/signals is empty. CodSpeed compared against e0e4224 rather than the merge base 0090fe3 (#4314), and it flagged that base and head ran on different runner types. #4314's own report on the same signals code showed neither regression. I've re-run the benchmarks once.


Generated by Claude Code

claude and others added 2 commits October 27, 2026 09:43
…n and gates its stylesheets

The adopting fresh render (#4319) runs with sharedConfig.hydrating on but
claims nothing. Two readers took the bare flag for "claiming server DOM":

- NoHydration returned nothing, so its children vanished
  ("a!atail" instead of "anh!atail").
- gateHeadResource skipped the stylesheet reveal gate, so content the
  server never drew revealed before its sheet loaded.

Both now read isHydrating(), the claiming check. 0 B on every size
scenario.

The other bare readers stay: For's eager map, createUniqueId, the store,
memo and signal wrappers and the Loading wrapper spend or adopt ids the
server spent; clientOnly and Portal's tree marker defer through the
hydration-end gate; the lazy lookup reads the module cache; the head
flush and reclaimRegion never run inside the pass or are guarded on
connectivity. New scenarios pin NoHydration, clientOnly, For, a nested
Loading and a stylesheet inside a rejected fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid merged commit 2c9fdd9 into next Oct 27, 2026
8 of 9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 27, 2026
Re-records the four scenarios the store fold stamp (#4310) and the
delegated currentTarget/target restore (#4325) push past their minified
allowance, measured over next with #4319.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
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