Repository navigation
SSR stream never closes when a rejected fragment abandons a pending serialized sibling #3165
Description
Describe the bug
On current next (07471da2, 2.0.0-rc.4), a streamed <Loading> fragment can reach its terminal error state while an async sibling inside the same fragment remains pending. The error swap, serialized error, $df marker, and static tail are all delivered, but the response never calls end().
The abandoned sibling still has a deferred registered with the hydration serializer. Fragment rejection removes the fragment from the renderer registry, but it does not terminally settle serialized deferreds belonging to the discarded subtree. serializer.flush() therefore waits forever and onDone never closes the response.
This holds the HTTP response and render resources open until the client or proxy disconnects.
Minimal reproduction
import { Errored, Loading, renderToStream } from "@solidjs/web";
import { createMemo } from "solid-js";
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const never = new Promise<string>(() => {});
function Child() {
const bad = createMemo(async () => {
await delay(10);
throw new Error("late-boom");
});
const stuck = createMemo(async () => never);
return (
<div>
{bad()}
{stuck()}
</div>
);
}
const completed = renderToStream(() => (
<html>
<body>
<Errored fallback={<p>caught</p>}>
<Loading fallback={<i>loading</i>}>
<Child />
</Loading>
</Errored>
<span>tail</span>
</body>
</html>
));
const ended = await Promise.race([
completed.then(() => true),
delay(2000).then(() => false)
]);
console.log(ended); // false; expected true<Errored> is not required. The minimized <Loading>-only shape with one rejecting hole and one never-settling hole also hangs.
Expected behavior
Once the fragment rejects terminally and its subtree is discarded, async serialization owned only by that abandoned subtree must no longer gate response completion. The error fragment should stream and the response should close.
Actual behavior
All terminal content is emitted, but the document request remains pending forever.
Controls
- Rejection without the stuck sibling closes normally.
- A slow sibling that eventually settles closes normally after settlement.
- A never-settling memo in a separate, still-live
<Loading>boundary correctly keeps the stream open. - The same failure reproduces with a nested pending boundary inside the rejected region.
These controls distinguish abandoned serialization from independent live work that should continue gating the response.
Implementation notes
- Server async memos create and serialize a deferred in
packages/solid/src/server/signals.ts(recordSlot/ctx.serialize). - Fragment rejection removes the fragment registry entry in
packages/web/src/server.ts, after whichflushEnd()reachesserializer.flush(). - Serializer completion intentionally waits for every serialized async value.
- Disposal prevents retry work but does not reject or resolve the already-serialized sibling deferred.
This is related to the always-settle work discussed in #3003, but it is a different rejected-fragment lifecycle gap: #3003 fixed retry convergence and superseded-node deferreds, while this reproduction terminally rejects a fragment that still owns a separate pending serialized sibling.
Activity
fix(web): settle abandoned serialization when a fragment rejects term…
test(web): pin creation-site ownership for #3165 abandonment scoping
ryansolid commentedon Sep 1, 2026
Fixed on
nextin 8963843. Pending promises written to the hydration serializer now join an abandonment ledger keyed by hydration id; a fragment settling with an error releases everything under its key prefix — descendant registry fragments settle so the registry can drain, and abandoned serialized deferreds resolve so seroval's completion fires. Abandonment follows creation ownership: a source created above the fragment (read inside it) serializes under its creator's id, stays gating, and ships its real value — the client adopts that id at the shell level. All four controls from the report are pinned as tests. Will ship in rc.5.ryansolid commentedon Sep 1, 2026
Fixed on
nextin 8963843: a terminally rejecting fragment now settles the serialization its discarded subtree owned (both the pending sibling deferred gating seroval's onDone and a nested pending boundary holding the fragment registry), so the response ends once the error fragment streams. Independent live boundaries keep gating the response — pinned along with your controls inssr-stream.spec.tsx. Ships in rc.5.