Skip to content

2.0: a truncated stream hangs hydration when hydrate() starts after parsing #4415

Closed

Description

@everton-dgn

Describe the bug

The truncation sweep from #2958 only arms when the client runtime boots while document.readyState === "loading". Otherwise watchTruncation (packages/solid/src/client/hydration.ts) returns early. If hydrate() first runs after parsing is over, there is no DOMContentLoaded left to wait for, and nothing marks the pending _fr declarations. When the stream was cut short, each streamed <Loading> keeps its fallback, hydration never ends, and the boundaries never fetch their data on the client.

The entry can run that late in at least two setups:

  • a deferred module entry (<script type="module" src> without async), which runs at readyState === "interactive", after parsing and before DOMContentLoaded;
  • an async entry that waits on a route chunk which arrives after DOMContentLoaded.

I checked the early return on 2.0.0-rc.18, rc.19 and next (81a12d8).

Your Example Website or App

No public link. I hit it in an app on 2.0.0-rc.19 that renders <HydrationScript /> (the early return is the same on rc.18). The harness repro below shows the same thing.

Steps to Reproduce the Bug or Issue

In the app: hold the route chunk until after DOMContentLoaded and cut the document stream after the shell. When the entry runs, the _fr refs still have no .s, hydration never ends and no data is fetched.

In this repo, with the late-boundary-after-done artifact from the parity harness (shell only, the rest never arrives):

globalThis._$HY = { events: [], completed: new WeakSet(), r: {}, s: { "": 1 }, fe() {} };
Object.defineProperty(document, "readyState", { value: "interactive", configurable: true });

container.innerHTML = shell.replace(scriptRe, "");
for (const m of shell.matchAll(scriptRe)) (0, eval)(m[1]);
hydrate(() => <scenario.App />, container);
await sleep(400);

_$HY.r["40_fr"].s; // undefined
_$HY.done; // undefined

The same shell with readyState at "loading" during hydrate() and a DOMContentLoaded afterwards (truncated-stream.spec.tsx) ends with the fragment rejected and _$HY.done === true.

Expected behavior

Same result as a runtime that booted mid-stream: the unsettled declaration becomes a rejected fragment, the boundary releases through the rejection path, the pending registry refs are swept, and hydration finishes.

Platform

  • solid-js / @solidjs/web: 2.0.0-rc.18 (also rc.19 and next)
  • Harness repro: jsdom

Additional context

The comment above watchTruncation says a runtime loaded after parse can't tell a completed page from a truncated one. With the in-render bootstrap (_$HY.s, #3971), every _fr declaration and every settle is a parser-inserted script, so once parsing is over a pending _fr can't settle anymore. A complete page has nothing pending, so a sweep there finds nothing to mark.

Once hydration ends, the boundary still keeps its fallback, because its memo adopts the dead promise. That part is #4413, which applies to the DOMContentLoaded path too.

I have a fix that runs the same sweep at boot in that case and keeps the order the comments describe (fragment pass first, registry pass a macrotask later). PR coming.

Activity

  1. ryansolid commented on Oct 30, 2026

    @ryansolid
    Member

    Fixed on next by #4416 (99d9821); it ships in the next RC.


    Generated by Claude Code

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