Skip to content

Hydration: a write during an open stream re-creates a hydrated <Loading> subtree as a client render, bypassing the serialized-value latch (rc.9 regression) #3542

Closed as not planned

Description

@ryansolid

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 250ms

HeaderQuery 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 sharedConfig at 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 readSerializedOrCompute was entered for six owners; the projection (id=001004) was the one with a registry entry (has=true) and adopted its value.
  • After the write: readSerializedOrCompute is never entered again. The adapter's computeData runs twice with prev=undefined — a first compute of a new projection, not a re-run of the latched one.
  • A counter in HeaderQuery shows create #2 after 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, onMount twice, 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

  1. ryansolid commented on Sep 18, 2026

    @ryansolid
    MemberAuthor

    Root-caused; this is not a core bug. Closing as by-design, with the adapter fix landing on solid-query-v6-pre.

    The hypothesis doesn't hold. I instrumented createBoundChildren's content computed in the fixture's installed signals dist: after its first run it has zero deps (deps: []), yet it is recomputed right after the root pass. No link to the projection, pending or otherwise — the !tracking && !spectating rule is not involved.

    What actually re-runs it is the hydration snapshot scope. In read():

    if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) {
      const sv = el._x?._snapshotValue;
      if (sv !== undefined) {
        const current = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value;
        if (current !== snapshot) c._flags |= REACTIVE_SNAPSHOT_STALE;
        return snapshot;
      }
    }

    A computation in scope that reads a signal written during the pass is served the pre-write snapshot and marked; releaseSnapshotScope dirties every marked node in the subtree. untrack only clears tracking, context stays — so a body-time read marks the instantiating computation. That is consistent with the async rule two lines above (an untracked read of a pending node still re-runs its reader), and it is intentional.

    The adapter tripped it: HeaderQuery setup creates version (snapshot 0), registry priming bumps it to 3 synchronously in the same setup, and two setup-time snapshots — metaProjection's seed and mountedAt — read version() through query() under untrack. Trace of the mark:

    SNAPSHOT_STALE node=signal(version) snapshot=0 current=3 ctx=00100 (boundary children computed) tracking=false
      at query ← untrack ← useBaseQueryLayer ← HeaderQuery ← createComponent ← get children ← createBoundChildren
    

    At release the boundary's children computed recomputes, props.children re-evaluates, a second HeaderQuery is created outside hydrating (no wrappers, live compute), the first is disposed. The projection's own marks (001004, 001005, tracked reads) are the legitimate takeover.

    Why rc.8 passed: scope membership is stamped at computed creation, and the scope used to be marked lazily by the first hydration-aware primitive — which runs inside HeaderQuery, i.e. inside the boundary's children computed. That computed predated the scope and was never in it. #3504 (3154ed6) marks the scope at the root before the first child, which is correct and is what put the boundary computed in scope. rc.8 was immune by accident.

    Minimal core check (solid-level, no TanStack): a hydrating root, coreMemo(() => untrack(Comp)) or createLoadingBoundary(() => untrack(Comp), …), where Comp does const [v, setV] = createSignal(0, { ownedWrite: true }); setV(1); untrack(v). Comp is constructed once before stopHydration() and twice after flush(). Same shape, same outcome — this is the model, not the adapter.

    Fix (adapter): split the cache lookup out of the versioned read — lookupQuery() (client sync + cache get/build, no version()) for the setup-time snapshots, query() = version(); lookupQuery() for the derived nodes. With that the boundary computed is never marked, #header keeps its element identity, the mid-stream write holds at header-server, and the takeover commits header-client at hydration end. hydration.test.tsx is 11/11 against rc.9.

    One thing I would still want from core, as a separate issue if you agree: the failure is silent. At mark time the engine can see that c is an ancestor of the signal's owner, i.e. that the replay will re-create the subtree rather than recompute a value. A dev diagnostic there — "snapshot replay will re-create <HeaderQuery>: setup read version, written during the hydration pass" — would have made this a five-minute fix. A line in the hydration docs that write-then-read in setup is served the pre-write value during the pass would help too.

    — Claude via Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions