Skip to content

fix(solid): hydration never asks again for an answer the server already sent - #4313

Merged
ryansolid merged 14 commits into
nextfrom
claude/project-thread-x1ore2-hydrate-reask
Oct 27, 2026
Merged

ryansolid merged 14 commits into
nextfrom
claude/project-thread-x1ore2-hydrate-reask

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Stacked on #4308. Review only the commits after 584e145 (d9a7a49, 2b2990a, 7ce4011, fae0320; b8014e1 is a merge of next). Once #4308 merges, the diff shrinks to those.

Before: a client that hydrates mid-stream asks again for async reads the server already answered.

After: every one of these shapes asks 0 times, loaded or streamed, with no flash. A real client write during hydration still computes and asks exactly once.

This regressed in f3da41e (2026-08-26, "hydration-latched nodes lose mid-stream dependency changes"). It treated every re-run of a node holding its server value as a dependency change. Two kinds of re-run aren't:

  • the errored-retry rule (a later tracked read retries an errored async node with nothing changed);
  • a served source's arrival (B's server answer was computed from that same A).

b68a907 (2026-09-29) then made such a re-run compute immediately, which is where the flash comes from.

How:

  • readSerializedOrCompute's latch re-serves its own serialized answer, errors included, and arms nothing when it re-runs inside one of hydration's own passes over the server's answers:
    • a hydrated <Errored>'s content run;
    • a rejected fragment's fresh render over the fallback the server drew;
    • the landing of a served answer.
  • All three set _claimOwner = ~clock: no claim owner, valid only for the flush round the pass opened in. This replaces NO_CLAIM. A write an effect makes during the landing's flush lands in a later round and computes.
  • servedPass flushes earlier client writes before a landing's pass starts, so they're never swallowed. Signals' existing clock is exported @internal (0 B).
  • Server: a rejected fragment with no outcome streams an empty template instead of a lone space, so the client can tell "the server drew an outcome" from "it drew nothing". Six artifacts were re-recorded (whitespace only).
  • Tests:
    • The 4287 and 4306 specs stream script by script with a clock tick between pieces and assert 0 asks.
    • The new served-source-landing scenario and spec cover B-reads-A plus two write races: a write before the landing's flush, and an effect write during it.
    • Every new streamed assertion fails without the fix.
    • f3da41e's and b68a907's own pins stay green (hydration-latch, write-before-resume, nav-before-resume, client-hydration).

Unchanged on purpose: a failure no <Errored> caught on the server is still rendered fresh by the client and asked again (#4242's recovery tests). That call is with the maintainer.

Size against next: +56 to +63 B minified on the hydrating and page scenarios. CSR, the signals floors, the server scenarios and the compiled floor (9,966 B brotli) are unchanged. The gate passes with no cap raised.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

claude and others added 9 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
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6c35714

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/web Patch
@solidjs/signals Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler 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.37 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.77 KB 0 B 0 B −107 B 9.75 KB ⚠️ over by 20 B, 127 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.56 KB +43 B (+0.2%) +45 B −241 B 18.49 KB ⚠️ over by 71 B, 261 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.93 KB +4 B (+0.0%) +46 B −32 B 30.86 KB ⚠️ over by 69 B, 52 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.37 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.09 KB 0 B 0 B −189 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.68 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.97 KB 0 B 0 B −106 B 9.93 KB ⚠️ over by 38 B, 126 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.11 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.14 KB −33 B (−0.1%) +48 B −739 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.44 KB +49 B (+0.1%) +44 B −240 B 33.37 KB ⚠️ over by 66 B, 260 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.81 KB −29 B (−0.1%) +46 B −45 B 37.75 KB ⚠️ over by 63 B, 65 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.94 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.50 KB +74 B (+0.2%) +46 B −232 B 35.43 KB ⚠️ over by 66 B, 252 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.68 KB +39 B (+0.1%) +46 B −55 B 41.56 KB ⚠️ over by 120 B, 75 B minified headroom eager (counted): web.js 22.27 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.99 KB +131 B (+0.3%) +51 B −233 B 40.81 KB ⚠️ over by 184 B, 253 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.34 KB +58 B (+0.1%) +46 B −52 B 47.16 KB ⚠️ over by 175 B, 72 B minified headroom eager (counted): client.js 27.43 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 20 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 71 B; minified 53,379 B vs 53,620 B recorded with the cap (−241 B) — 261 B of the 20 B minified allowance left; +45 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 69 B; minified 95,081 B vs 95,113 B recorded with the cap (−32 B) — 52 B of the 20 B minified allowance left; +46 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 38 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 66 B; minified 101,319 B vs 101,559 B recorded with the cap (−240 B) — 260 B of the 20 B minified allowance left; +44 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 63 B; minified 115,172 B vs 115,217 B recorded with the cap (−45 B) — 65 B of the 20 B minified allowance left; +46 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 66 B; minified 107,686 B vs 107,918 B recorded with the cap (−232 B) — 252 B of the 20 B minified allowance left; +46 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 120 B; minified 123,161 B vs 123,216 B recorded with the cap (−55 B) — 75 B of the 20 B minified allowance left; +46 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 184 B; minified 125,154 B vs 125,387 B recorded with the cap (−233 B) — 253 B of the 20 B minified allowance left; +51 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 175 B; minified 140,428 B vs 140,480 B recorded with the cap (−52 B) — 72 B of the 20 B minified allowance left; +46 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).

Copy link
Copy Markdown
Member Author

The size gate is red for this PR's own bytes (about +130 B minified on the hydrating paths). Four scenarios are now over their brotli cap and more than 20 B over their recorded size:

  • hydrating + every store family: +73 B
  • live server components: +129 B
  • compiled live server components: +111 B
  • live + router: +173 B

Part of the headroom on the live pages went to #4304, which landed on next after this branch was cut. I'm checking with the maintainer whether to raise those caps or golf the change further before pushing either.


Generated by Claude Code

@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40463844669

Coverage decreased (-0.006%) to 78.583%

Details

  • Coverage decreased (-0.006%) from the base build.
  • Patch coverage: 20 of 20 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: 1352
Covered Lines: 1122
Line Coverage: 82.99%
Relevant Branches: 1076
Covered Branches: 786
Branch Coverage: 73.05%
Branches in Coverage %: Yes
Coverage Strength: 28.07 hits per line

💛 - Coveralls

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
@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 15.15%

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 improved benchmarks
✅ 194 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ createStore setter: delete + set one root key (#3044 overlay) 2.1 ms 1.8 ms +16.5%
⚡ memo + sync render effect only (reference) 34 ms 29.9 ms +13.82%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-x1ore2-hydrate-reask (6c35714) with next (8f1d9d2)

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

…-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
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
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
@ryansolid
ryansolid merged commit e0e4224 into next Oct 27, 2026
9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 27, 2026
…eeds

markSnapshotScope's served branch only sees hydrated signal-likes, never the
internal boundary signals that carry CONFIG_NO_SNAPSHOT; a settled answer
already took its creation snapshot, so the landing hook needs no `!initP.s`.
-16 B minified on the hydrating and page scenarios; keeps the store app
within the size gate's allowance now that #4313 is on next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
ryansolid added a commit that referenced this pull request Oct 27, 2026
…client's value (#4314)

* fix(solid,signals): a boundary claimed after a shell write shows the client's value

A hydrated memo adopted while its server answer was still streaming had
no snapshot (the creation arm skips pending nodes). Since b68a907 a
shell node outside every pending boundary computes on a client write; a
streamed <Loading> that reads it and resumes afterwards then claimed its
server markup while reading the client's value, and since the claim pass
never mutates the DOM, the binding kept the server's text for good
(GH2). With the client's recompute still in flight at the resume, the
claim read the node pending and the never-revealed boundary committed
its fallback over the settled server content, detaching it and losing a
queued click (GH4).

- solid: a value node's server answer still streaming at adoption
  becomes its snapshot when it lands (readSerializedOrCompute), whether
  the node committed it or a client write had already taken the compute
  over. The claim reads what its markup shows and replays the client's
  value at the scope's release, as for a written signal.
- signals: markSnapshotScope(node, served) records that snapshot (no
  new export); a reader in a snapshot scope is served the snapshot ahead
  of the node's pending state (errored nodes still throw), so a revealed
  claim holds while the client's flight is open. takeSnapshot folds the
  three copies of the snapshot write.

Tests: hydration/claim-stale-text (signal / sync / async shell sources;
a chained pair of pending shell memos with the write before, between and
in the flush of their answers, settled or in flight at the claim — no
fallback after the fragment, no diagnostics); the generic consistency
pins GH2 and GH4 flip from test.fails to passing; signals snapshot unit
tests for the served snapshot.

Size: 0 B minified on every CSR scenario (compiled floor 9.97 KB
brotli); +61..69 B minified on the hydrating scenarios.

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

* perf(solid,signals): drop two checks the served-snapshot path never needs

markSnapshotScope's served branch only sees hydrated signal-likes, never the
internal boundary signals that carry CONFIG_NO_SNAPSHOT; a settled answer
already took its creation snapshot, so the landing hook needs no `!initP.s`.
-16 B minified on the hydrating and page scenarios; keeps the store app
within the size gate's allowance now that #4313 is on next.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid pushed a commit that referenced this pull request Oct 30, 2026
…arried

A first ask of a memo the server never streamed is not a refetch (#4313,
#4414): the emitted app reports each memo's hydration id before creating
it, and the hydration campaign counts an ask only when the shell or a
chunk carries that id's answer. Re-run on next: the one remaining
hydration finding (m2 after an errored root) goes away; 251 of 561
memos are served and still checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWKJDzwaZPgaGWE181b
ryansolid added a commit that referenced this pull request Oct 30, 2026
…arried (#4446)

* test(web): async app generator — generated Loading/Errored/Show trees over async memos, with safety and liveness oracles

An opt-in campaign (ASYNC_GEN=1, pnpm async-gen) that generates small apps
(signals, sync and async memos with manual flights, latest()/isPending()
reads, Loading / Errored / Show / leaves with grouped or split attribute
effects) and scripts (writes, actions, settling flights in any order,
flush / microtask / task). Oracles: nothing throws or logs, no unhandled
rejection, no hole shows undefined/NaN, and once every flight has settled
the screen equals a fresh synchronous render of the final inputs. Survey,
shrink and minimize modes; the minimizer reduces the app as well as the
script.

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

* test(web): async-gen equivalence pairs (grouping, identity memo, inert Errored, store twin, flush mode, owned memos)

ASYNC_GEN_PAIRS=E1,E2,E2e,E3,E6,E7 runs each scenario a second time
rewritten without changing its meaning and compares the screens at every
settle point (after the first render, each task step and the end);
differences are grouped by what each side shows. Scenarios also choose
whether memos are created at module level or inside the root component.

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

* test(web): async-gen regression gate against a recorded baseline

1,500 generated scenarios (seed 3289, ~15 s) run with the web suite; each
case's safety and liveness laws are compared to baseline.json. A case that
newly breaks a law fails the suite (a change broke a neighbouring shape);
a recorded failure that no longer reproduces is reported, and
ASYNC_GEN_UPDATE=1 records it. The baseline holds today's 20 failing cases
(#4362-#4367 shapes).

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

* test(web): async-gen hydration phase

Each generated scenario is emitted as a TSX module, rendered through
renderToStream (server half), then hydrated chunk by chunk (client half).
Oracles: no refetch of a read memo before the first write, no
console.error or hydration warning, hydrated screen equals a fresh CSR
render, then the scenario's steps and final liveness. Opt-in via
ASYNC_GEN_HY=1 (pnpm async-gen:hydrate); minimize-hydration.mts reduces a
failing case through both halves.

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

* test(web): async-gen differential mode; fix test-types

ASYNC_GEN_RECORD on a reference build writes every case's screens, final
screen and broken laws; ASYNC_GEN_DIFF on a candidate build replays the
same seed and fails on any difference. Both the client campaign and the
hydration campaign take it: the gate for the reader-seat carve. Same-build
record/diff is clean (client 500 cases, hydration 300).

minimize-hydration.mts loads minimize.ts through a computed specifier so
tsc (no allowImportingTsExtensions) accepts it; runCase's return type
names screens/final.

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

* test(web): async-gen pairs E8 (birth seat) and E9 (latest in a derive)

E8 re-creates the tree's readers in the flush that applies each write and
compares screens where nothing is in flight; E9 reads each latest(x) the
tree makes through a memo of it. Both from the carve audit's seat pairs.
First run (1,000 cases): #4377, #4378.

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

* test(web): async-gen refetch oracle counts only answers the payload carried

A first ask of a memo the server never streamed is not a refetch (#4313,
#4414): the emitted app reports each memo's hydration id before creating
it, and the hydration campaign counts an ask only when the shell or a
chunk carries that id's answer. Re-run on next: the one remaining
hydration finding (m2 after an errored root) goes away; 251 of 561
memos are served and still checked.

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

* test(web): quote the no-refetch rule in the hydration oracle

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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