Repository navigation
[2.0 rc.13] createSSRResponse keeps rendering after its body is cancelled and after a pre-flush redirect #3768
Description
Describe the bug
createSSRResponse(renderToStream(...), event) leaves the render running in two cases where nobody will read it.
- The body of the resolved
Responseis cancelled. Itscancel()only sets a localclosedflag (server.ts#L6897-L6899), andenqueuethen returns early on every write (#L6864-L6871), so the sink never throws andguardSinknever reachesabandon("sink")(#L2181-L2198). Fragments keep resolving, a serialized async iterator keeps being pulled, itsfinallyand the components'onCleanupwait for the render to end on its own (never, with an endless source), and every chunk goes to a sink that drops it. The plugin's Node handler cancels this body when the client goes away and for HEAD (http.ts#L97-L108), and its comment says that streaming a long (or endless) body into the void "just burns the render". - A pre-flush redirect. When the stub has a
Locationat shell flush (set onevent.responsebefore the render, or withhttpHeader("Location", ...)in a component), the sink resolves a bodyless redirect and sets the same flag (#L6880-L6886). The JSDoc says "the render is abandoned" (#L6822-L6823), but the render runs on as in case 1.
#3660 fixed the same thing for serverComponentResponse in rc.10. Its changelog entry says "previously cancel() only dropped writes and the render ran on until its sources happened to end". Cancelling the readable view of renderToStream also tears the render down (#L3180-L3186, plus #3628 for serialized iterators). The body of createSSRResponse is still at "previously".
signal doesn't cover these cases. Its JSDoc reserves it for a host whose transport "cannot report a dead consumer through the sink or the readable view" (#L1907-L1919), and createSSRResponse has no signal option of its own, so it has to go to renderToStream. On HEAD and on a redirect the client is still there and the response ends normally, so a host that aborts request.signal only when the response didn't finish, like the plugin's Node handler (http.ts#L54-L61), never aborts it. In a Node http server wired that way, the signal never fired on HEAD or on a 302, and the render kept pulling as in the table below. For a client that disconnects mid-body, passing the signal does stop the render.
With a finite tree the waste ends when the render does. With the iterator capped at 20 pulls, a cancelled body or a pre-flush redirect kept the render going until about 300 ms, which is how long a full read of the same page takes.
Your Example Website or App
Node script under Steps. No JSX and no build step.
Steps to Reproduce the Bug or Issue
npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13- Save the script below as
repro.mjs. node repro.mjs
repro.mjs
// repro.mjs: plain Node ESM, no JSX, no build step.
// npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13
// node repro.mjs prints the table (each case runs in its own process)
// node repro.mjs <case> prints one case as JSON
// Tree: <main><Loading><Ticker /></Loading><Loading><Late /></Loading></main>
// Ticker reads an endless serialized async iterator (one pull every 10 ms).
// Late reads a value that settles at 300 ms.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { Loading, createMemo, onCleanup } from "solid-js";
import {
createComponent,
createRequestEvent,
createSSRResponse,
escape,
renderToStream,
scope,
ssr,
ssrHydrationKey
} from "@solidjs/web";
const delay = ms => new Promise(r => setTimeout(r, ms));
const stats = { pulls: 0, finallies: 0, cleanups: 0, writes: [] };
async function* ticks() {
try {
for (let n = 0; ; n++) {
await delay(10);
stats.pulls++;
yield `tick ${n}`;
}
} finally {
stats.finallies++;
}
}
function Ticker() {
onCleanup(() => stats.cleanups++);
const tick = createMemo(() => ticks());
return ssr(["<p", ">", "</p>"], ssrHydrationKey(), scope(() => escape(tick())));
}
function Late() {
onCleanup(() => stats.cleanups++);
const text = createMemo(async () => {
await delay(300);
return "late-done";
});
return ssr(["<p", ">", "</p>"], ssrHydrationKey(), scope(() => escape(text())));
}
const App = () =>
ssr(
["<main", "><!--$-->", "<!--/--><!--$-->", "<!--/--></main>"],
ssrHydrationKey(),
escape(createComponent(Loading, { fallback: "loading", get children() { return createComponent(Ticker, {}); } })),
escape(createComponent(Loading, { fallback: "loading", get children() { return createComponent(Late, {}); } }))
);
// Sees every chunk the render hands createSSRResponse's sink, before the sink drops it.
const transformChunk = chunk => {
stats.writes.push({ at: performance.now(), late: chunk.includes("late-done") });
return chunk;
};
const until = async cond => {
const deadline = performance.now() + 2000;
while (!cond()) {
if (performance.now() > deadline) throw new Error("timed out waiting for the iterator to start");
await delay(5);
}
};
const response = location => {
const event = createRequestEvent(new Request("http://localhost/"));
if (location) event.response.headers.set("Location", location);
return createSSRResponse(renderToStream(App), event, { transformChunk });
};
// Takes the action, then reads the counters 1 s later.
async function measure(status, action) {
const pullsAtAction = stats.pulls;
const actionAt = performance.now();
await action();
await delay(1000);
const after = stats.writes.filter(w => w.at > actionAt);
return {
status,
pulls: `${pullsAtAction} -> ${stats.pulls}`,
finally: stats.finallies,
onCleanup: `${stats.cleanups}/2`,
sinkWritesAfter: after.length,
lateWrittenAfter: after.some(w => w.late) ? "yes" : "no"
};
}
const cases = {
"readable-cancel": {
label: "`readable` view, reader cancelled after the shell (control, #3628)",
async run() {
const reader = renderToStream(App).readable.getReader();
await reader.read();
await until(() => stats.pulls >= 2);
const row = await measure("-", () => reader.cancel());
return { ...row, sinkWritesAfter: "n/a", lateWrittenAfter: "n/a" };
}
},
"body-read-cancel": {
label: "createSSRResponse body cancelled after reading the shell (client gone)",
async run() {
const res = await response();
const reader = res.body.getReader();
await reader.read();
await until(() => stats.pulls >= 2);
return measure(res.status, () => reader.cancel());
}
},
"body-unread-cancel": {
label: "createSSRResponse body cancelled before reading (what the plugin does for HEAD)",
async run() {
const res = await response();
await until(() => stats.pulls >= 2);
return measure(res.status, () => res.body.cancel());
}
},
"redirect": {
label: "`Location` set before the render (pre-flush redirect)",
async run() {
const res = await response("/login");
return measure(`${res.status}, body ${res.body}`, () => {});
}
}
};
const [only] = process.argv.slice(2);
if (only) {
console.log(JSON.stringify(await cases[only].run()));
// Renders that were never torn down keep timers alive.
process.exit(0);
}
const columns = ["status", "pulls", "finally", "onCleanup", "sinkWritesAfter", "lateWrittenAfter"];
console.log("| case | status | iterator pulls (at action -> 1 s later) | iterator `finally` | `onCleanup` | render writes after action (seen by `transformChunk`) | late fragment written after action |");
console.log("| --- | --- | --- | --- | --- | --- | --- |");
for (const [name, { label }] of Object.entries(cases)) {
const out = execFileSync(process.execPath, [fileURLToPath(import.meta.url), name], { encoding: "utf8", timeout: 10000 });
const row = JSON.parse(out);
console.log(`| ${label} | ${columns.map(c => row[c]).join(" | ")} |`);
}Output with rc.13:
| case | status | iterator pulls (at action -> 1 s later) | iterator finally |
onCleanup |
render writes after action (seen by transformChunk) |
late fragment written after action |
|---|---|---|---|---|---|---|
readable view, reader cancelled after the shell (control, #3628) |
- | 2 -> 3 | 1 | 2/2 | n/a | n/a |
| createSSRResponse body cancelled after reading the shell (client gone) | 200 | 2 -> 87 | 0 | 0/2 | 87 | yes |
| createSSRResponse body cancelled before reading (what the plugin does for HEAD) | 200 | 2 -> 87 | 0 | 0/2 | 85 | yes |
Location set before the render (pre-flush redirect) |
302, body null | 0 -> 86 | 0 | 0/2 | 86 | yes |
The pull and write counts move by a few between runs (one pull every 10 ms for the whole second); the zeros and the control row don't. The redirect row starts at 0 pulls because that response resolves at the shell flush, before the first pull. next at e44b2e4, built locally, gives the same table.
Expected behavior
Cancelling the body of a createSSRResponse response tears the render down the way cancelling the readable view does: async sources are returned, onCleanup runs and nothing more is written. A pre-flush redirect does the same, as its JSDoc says.
Screenshots or Videos
N/A (server only).
Platform
- OS: macOS 27.0
- Runtime: Node.js v24.21.0 (no browser involved)
- Version:
solid-js2.0.0-rc.13,@solidjs/web2.0.0-rc.13. Same result onnextat e44b2e4, where the body'scancel()and the pre-flush redirect branch are the same code as in the rc.13 dist.
Additional context
Possible seam: pipe() already reads a module-private symbol off this sink for the pre-shell case (SHELL_ABANDONED, #L1872, #L3305-L3314). The reverse would let the body's cancel() and the redirect branch call into abandon(...), the way the body of serverComponentResponse does (frame-sink.ts#L2456-L2461). That call has to be a no-op after end(): a body can still be cancelled while queued chunks drain, and a completed render isn't dead (#L2267-L2271, #L2038). The existing pre-flush redirect tests (cookies.spec.js, ssr-async-rejection-3569.spec.tsx) check the status and Location, not that the render stops.
What I'd rather ask about is the label. In dev and observe builds, abandon("consumer") emits SSR_STREAM_ABANDONED at warn (#L2037-L2051), which the comment above it describes as the client having left (#L1991-L1995). abandon() with no reason settles the render as "error" and runs the failure completions (#L2057, #L2099-L2101). An auth redirect is neither, and HEAD has the same problem: with "consumer", every HEAD in a dev build would log a disconnect. Should these tear down silently, get a reason of their own, or reuse "consumer"? Or did "the render is abandoned" only mean that the output is discarded?
Related:
- Public request-level failure report for the server error hook (kind: "request") #3723 asked for separate issues for its related items, one of them a throw after
next()whose page "keeps running into a buffer nobody reads". This doesn't close that one: a droppedResponsewhose body is never cancelled keeps rendering at the same rate as the rows above, on rc.13 andnext. Withcancel()fixed, the containment could stop it by cancelling the body. - The known gap in fix(web): createSSRResponse settles when the render ends before the shell #3725 (an abort through
signalafter the shell leaves the body open) is the other direction across the same sink. I left it out of this issue.
I can send a PR with tests next to stream-cancel-iterators-3626.spec.tsx once the label is settled. If you'd rather write it yourself, that works too.
Activity
fix(web): createSSRResponse tears the render down on body cancel and …
ryansolid commentedon Oct 4, 2026