Repository navigation
Description
Summary
While a streamed hydration is still in progress (sharedConfig.done === false, isHydrationInProgress() === true, a sibling <Loading> still on its fallback), a write to a source read under an already-hydrated <Loading> boundary re-runs that boundary's content computed. The content is re-created through createComponent as a client render — sharedConfig.hydrating is false by then, so the new nodes never see the hydration wrappers (readSerializedOrCompute is not entered) and compute live. The serialized server value stops holding the document mid-stream, and the component under the boundary is constructed twice.
rc.8 holds the DOM until hydration ends. rc.9 does not. Surfaced by the TanStack Solid Query release gate once it compiled with a matching compiler (#3534); the rc.9 skip let it ship.
Contract that breaks
Default (ssrSource: "server") hydration: the serialized value owns the node for the whole hydration window. A dependency change while a node is held on its server value re-enters readSerializedOrCompute, the latch re-serves the serialized value, and armLiveTakeover() re-runs exactly the diverged nodes when hydration ends — deferred, not lost, and never applied mid-stream (packages/solid/src/client/hydration.ts, latchedOnce).
That mechanism assumes the same node recomputes. It is bypassed entirely when the boundary above it recomputes instead and builds a fresh subtree.
Reproduction
TanStack solid-query-v6-pre, packages/solid-query/src/__tests__/hydration.test.tsx › streaming SSR hydration › cache writes during the open stream commit when hydration completes. Fails in isolation against next (ce17c298 and later), passes against 2.0.0-rc.8 with its own compiler.
cd packages/test-integration && pnpm run test:solid-query # full gate
# or, in the downloaded fixture's packages/solid-query:
SOLID_COMPILER_NATIVE=<repo>/packages/compiler/compiler.node npx vitest run src/__tests__/hydration.test.tsx -t "cache writes during the open stream"
Shape (src/__tests__/fixtures/hydration/StreamApp.tsx): two sibling boundaries under one provider —
<Loading fallback={<div>loading-header</div>}><HeaderQuery /></Loading> // settles in 5ms
<Loading fallback={<div>loading-feed</div>}><FeedQuery /></Loading> // settles in 250msHeaderQuery is useQuery(...) → a createProjection (deferStream) whose compute reads the cache, rendered as <span id="header">{query.data}</span>. The test applies the shell + header chunks only, calls hydrate(), then queryClient.setQueryData(['header'], 'updated-client') while the feed boundary is still outstanding.
Expected (rc.8, the test): #header stays header-server until the feed chunk arrives and hydration completes, then commits header-client via the takeover. Actual (rc.9): #header reads updated-client after one timer tick.
What the probe showed
Instrumented the fixture's installed solid-js dist and the adapter (all in the disposable download, nothing in the repo):
- Bundle's own
sharedConfigat the write:done=false,hydrating=false,isHydrationInProgress()=true, feed fallback present in the DOM. The window is open by every measure core exposes. - During the hydration pass
readSerializedOrComputewas entered for six owners; the projection (id=001004) was the one with a registry entry (has=true) and adopted its value. - After the write:
readSerializedOrComputeis never entered again. The adapter'scomputeDataruns twice withprev=undefined— a first compute of a new projection, not a re-run of the latched one. - A counter in
HeaderQueryshowscreate #2after the write. Stack of the second creation:
HeaderQuery
untrack → createRoot (transparent) → observedComponent → createComponent
Object.get children ← the boundary's `() => props.children`
Object._fn → recompute → GlobalQueue._update → runHeap → GlobalQueue.flush → flush
So the node that recomputed is the boundary's content computed (createBoundChildren's tree in createCollectionBoundary), and re-running it rebuilt the component.
Hypothesis (not yet confirmed)
The boundary tree acquired a dependency on the projection it should not have. Candidate: the read() rule that an untracked read of a pending node still links the reader so it re-runs at settle (if (!tracking && !spectating && el !== c) link(el, c), core.ts). Component bodies run under untrack with the tree as the ambient computation; during hydration the projection is pending (adopted thenable, loading window), so a body-time read of it — or of isPending/latest over it — links the tree to the projection. If that link survives the settle (rc.8 dropped it or never formed it), the projection's next change re-runs the tree. Needs a bisect over rc.8…rc.9 on packages/signals/src/core/core.ts / graph.ts and packages/solid/src/client/hydration.ts (the #3504 change 3154ed6 altered what a mid-window re-render is, but not whether the tree re-runs).
A core-only repro without TanStack should be possible: streamed page, two boundaries, the first boundary's content reads an adopted createProjection (or async memo) in the component body; while the second boundary is outstanding, write a signal the projection depends on; assert the DOM holds, the component constructs once, and the takeover lands the write at hydration end.
Consequences
- Server value no longer holds the document during the stream (visible flash to client data mid-hydration).
- Components under a hydrated boundary are constructed twice on any write — duplicated observers/effects,
onMounttwice, hydration ids no longer meaningful for the rebuilt subtree. - Any per-instance state in the first subtree is discarded silently.
Blocks the rc.10 gate together with four adapter-side test adaptations (see #3534).
Activity
ryansolid commentedon Sep 18, 2026