You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a streamed render fails before its shell flushes (the render reaches failRender, onError sees handling: "failed"), the Promise<Response> returned by createSSRResponse(renderToStream(...), event) never settles. It neither resolves nor rejects, so the request hangs until the client or the host times out.
The same renderToStream result completes through every other consumer, as #3569 made it do: pipe() ends the sink after zero writes, await resolves with "", and pipeTo()/readable close. createSSRResponse is built on pipe(), but its end() ignores an end that arrives before the first write.
Two ordinary trees reach this:
an async read that rejects with no <Loading> or <Errored> above it;
Hosts that answer every page through createSSRResponse hang the request in both cases. @solidjs/vite-plugin (3.0.0-next.46) is one of them: in its default stream mode, the generated server handler passes the renderToStream result, unawaited, to createSSRResponse(result, event, ...).
Your Example Website or App
Self-contained script below. It has no JSX, so it runs with plain node against the published packages.
Steps to Reproduce the Bug or Issue
// repro.mjs: plain Node ESM, no JSX, no build step.// Needs solid-js@2.0.0-rc.11 and @solidjs/web@2.0.0-rc.11 (Node picks dist/server.js).// Usage: node repro.mjs <root|loading> <response|pipe|await>import{Loading,NotReadyError,createMemo}from"solid-js";import{createRequestEvent,createSSRResponse,escape,renderToStream,scope,ssr,ssrHydrationKey}from"@solidjs/web";const[tree="root",consumer="response"]=process.argv.slice(2);constdelay=(ms,value)=>newPromise(r=>setTimeout(()=>r(value),ms));// root: <main><p>{data()}</p></main>, no <Loading> or <Errored> above the read.constRootRejection=()=>{constdata=createMemo(async()=>{awaitdelay(10);thrownewError("fetch failed");});constp=ssr(["<p",">","</p>"],ssrHydrationKey(),scope(()=>escape(data())));returnssr(["<main",">","</main>"],ssrHydrationKey(),escape(p));};// loading: <div>{held()}<Loading fallback="loading"><NeverConverges /></Loading></div>// The #3569 (b) shape: the boundary trips its convergence budget while a root// read still holds the shell, and it has no parent handler.constNeverConverges=()=>{thrownewNotReadyError(Promise.resolve());};constLoadingFailure=()=>{constheld=createMemo(()=>delay(200,"shell"));returnssr(["<div",">","","</div>"],ssrHydrationKey(),scope(()=>escape(held())),escape(Loading({fallback: "loading",getchildren(){returnNeverConverges();}})));};consttimer=setTimeout(()=>{console.log("still pending after 3000 ms");process.exit(1);},3000);constresult=renderToStream(tree==="loading" ? LoadingFailure : RootRejection,{onError(error,context){console.log(`onError: handling=${context.handling}${error.message.slice(0,60)}`);}});if(consumer==="response"){constevent=createRequestEvent(newRequest("http://localhost/"));constresponse=awaitcreateSSRResponse(result,event);console.log(`resolved: status=${response.status} body=${JSON.stringify(awaitresponse.text())}`);}elseif(consumer==="pipe"){letwrites=0;awaitnewPromise(end=>result.pipe({write(){writes++;}, end }));console.log(`sink ended after ${writes} write(s)`);}else{console.log(`awaited: ${JSON.stringify(awaitresult)}`);}clearTimeout(timer);
Command
Output
node repro.mjs root response
onError: handling=failed fetch failed, then still pending after 3000 ms
node repro.mjs root pipe
onError: handling=failed fetch failed, sink ended after 0 write(s)
onError: handling=failed <Loading> boundary discovery did not converge after 10001 pa..., then still pending after 3000 ms
node repro.mjs loading pipe
same onError, sink ended after 0 write(s)
node repro.mjs loading await
same onError, awaited: ""
A wider matrix, one Node process per cell, each cell run both with no hook and with configureServerErrors({ onError }) (identical results either way):
Tree
createSSRResponse
pipe()
await
pipeTo()
read resolves (control)
200, full body
1 write, ended
full HTML
closed
read rejects, no boundary
pending
0 writes, ended
""
closed, 0 chunks
<Loading> that never converges, shell held by a root read
pending
0 writes, ended
""
closed, 0 chunks
read rejects under <Errored>
200, fallback
1 write, ended
fallback HTML
closed
read rejects under <Loading>
200, loading fallback, then the rejected fragment
2 writes, ended
HTML
closed
read rejects under <Loading>, deferStream: true
200
1 write, ended
HTML
closed
read rejects under <Errored><Loading>
200
2 writes, ended
HTML
closed
<Errored><Loading> that never converges, shell held
200, shell plus the serialized error
1 write, ended
HTML
closed
Only the two trees that reach failRender before the first write hang, and only through createSSRResponse.
Expected behavior
createSSRResponse settles once the render has failed, so the host can answer the request. Resolving with a 5xx Response (or rejecting) would both work. A 200 with an empty body would be worse than the hang, because caches and CDNs would keep it as a valid page.
Analysis
Permalinks are to next at bf87f27 (the rc.12 version bump). createSSRResponse is byte-identical between the rc.11 release commit ee49b3e and bf87f27, and no pending changeset touches it.
The root-hole retry throws inside pipe()'s flush, which contains it through failRootRender (server.ts#L3279-L3286, #L2100-L2115) and then failRender (#L2087-L2094). The <Loading> shape gets there through finalizeError, which has no parent handler (hydration.ts#L360-L413, call at L396).
abandon() hands writable to the failure completions. Before the shell, writable is still undefined, so they run with sink === undefined (#L2010-L2076, see L2072-L2075).
createSSRResponse only calls resolve from its first write (or from the pre-flush Location branch there). Its end() starts with if (closed || !controller) return;, and controller only exists after that first write (#L6666-L6736, end() at L6715-L6734). An end() with nothing written returns early and the promise is never settled.
This stack trace, printed from a raw pipe() sink's end() with the rc.11 dist, confirms the order for the root case:
Error: end() called
at Object.end (stack-probe.mjs:11:23)
at .../@solidjs/web/dist/server.js:2258:33 // pipe(): sink ? sink.end() : w.end()
at abandon (.../@solidjs/web/dist/server.js:1556:19)
at failRender (.../@solidjs/web/dist/server.js:1565:5)
at failRootRender (.../@solidjs/web/dist/server.js:1568:5)
at .../@solidjs/web/dist/server.js:2268:15 // pipe() flush: catch around doShell()
at attempt (.../@solidjs/web/dist/server.js:2145:7)
Test coverage: the #3569 (b) tests cover await, raw pipe() and readable. The createSSRResponse tests in http-components.spec.tsx only cover an error caught by <Errored>, so this path has no test.
Suggested fix
Settle the promise when end() arrives before any write, following the pre-flush Location branch in the same function:
end() {
- if (closed || !controller) return;+ if (closed) return;+ if (!flushed) {+ // The render failed before the shell: pipe() ended the sink with nothing written.+ closed = true;+ if (stub) commitResponseStub(stub, { event });+ const head = deriveHead(stub, responseInit);+ const status = stub && stub.headers.get("Location") ? getExpectedRedirectStatus(stub) : 500;+ resolve(new Response(null, { status, headers: head.headers }));+ return;+ }
const location = stub && stub.headers.get("Location");
I'd resolve with a 500 rather than reject: it keeps createSSRResponse from ever rejecting (no new failure path for callers that don't catch it), and onError has already reported the cause. Rejecting would let host middleware render its own error page, but callers would then have to handle a rejection.
Two details in the diff:
The 500 overrides stub.status on purpose: a status set before the failure should not survive a render that produced no page. In practice httpStatus()/httpHeader() declarations made inside the render are already gone by then, because abandon() disposes the render before the failure completions run and the uncommitted declarations revert on cleanup.
A Location still on the stub (set outside the render, for example by middleware) keeps the redirect, in the same order as the pre-flush branch in write(). Without that check the host would get a 500 carrying a Location header.
I applied the diff above to a copy of the rc.11 dist. Both hanging trees then resolve with 500 and an empty body, and every other cell in the matrix is unchanged. With event.response.headers.set("Location", "/login") before the render, the same failure resolves with 302 and location: /login; with httpStatus(404) or httpHeader("Location", ...) declared inside the render, it resolves with 500. A regression test fits next to the #3569 (b) tests: the failingPreShell() fixture consumed through createSSRResponse.
A related case with a different cause: aborting options.signal before the shell also leaves createSSRResponse pending. abandon("signal") skips the failure completions on purpose, since the client has left, so this may be intended. Still, middleware that awaits next() never unwinds in that case.
Screenshots or Videos
N/A (server only).
Platform
OS: macOS 27.0
Runtime: Node.js v24.21.0 (no browser involved)
Version: solid-js 2.0.0-rc.11, @solidjs/web 2.0.0-rc.11. createSSRResponse is unchanged on next (bf87f27, 2.0.0-rc.12 not yet published).
Describe the bug
When a streamed render fails before its shell flushes (the render reaches
failRender,onErrorseeshandling: "failed"), thePromise<Response>returned bycreateSSRResponse(renderToStream(...), event)never settles. It neither resolves nor rejects, so the request hangs until the client or the host times out.The same
renderToStreamresult completes through every other consumer, as #3569 made it do:pipe()ends the sink after zero writes,awaitresolves with"", andpipeTo()/readableclose.createSSRResponseis built onpipe(), but itsend()ignores an end that arrives before the firstwrite.Two ordinary trees reach this:
<Loading>or<Errored>above it;<Loading>that trips its convergence budget while a root read still holds the shell (the shape used by the [2.0 rc.9] await renderToStream(): an async value that rejects as a direct child of <Loading> throws "reading 'emit'" and the promise never settles #3569 (b) tests).Hosts that answer every page through
createSSRResponsehang the request in both cases.@solidjs/vite-plugin(3.0.0-next.46) is one of them: in its default stream mode, the generated server handler passes therenderToStreamresult, unawaited, tocreateSSRResponse(result, event, ...).Your Example Website or App
Self-contained script below. It has no JSX, so it runs with plain
nodeagainst the published packages.Steps to Reproduce the Bug or Issue
node repro.mjs root responseonError: handling=failed fetch failed, thenstill pending after 3000 msnode repro.mjs root pipeonError: handling=failed fetch failed,sink ended after 0 write(s)node repro.mjs root awaitonError: handling=failed fetch failed,awaited: ""node repro.mjs loading responseonError: handling=failed <Loading> boundary discovery did not converge after 10001 pa..., thenstill pending after 3000 msnode repro.mjs loading pipeonError,sink ended after 0 write(s)node repro.mjs loading awaitonError,awaited: ""A wider matrix, one Node process per cell, each cell run both with no hook and with
configureServerErrors({ onError })(identical results either way):createSSRResponsepipe()awaitpipeTo()""<Loading>that never converges, shell held by a root read""<Errored><Loading><Loading>,deferStream: true<Errored><Loading><Errored><Loading>that never converges, shell heldOnly the two trees that reach
failRenderbefore the first write hang, and only throughcreateSSRResponse.Expected behavior
createSSRResponsesettles once the render has failed, so the host can answer the request. Resolving with a 5xxResponse(or rejecting) would both work. A 200 with an empty body would be worse than the hang, because caches and CDNs would keep it as a valid page.Analysis
Permalinks are to
nextatbf87f27(the rc.12 version bump).createSSRResponseis byte-identical between the rc.11 release commitee49b3eandbf87f27, and no pending changeset touches it.pipe()'s flush, which contains it throughfailRootRender(server.ts#L3279-L3286, #L2100-L2115) and thenfailRender(#L2087-L2094). The<Loading>shape gets there throughfinalizeError, which has no parent handler (hydration.ts#L360-L413, call at L396).abandon()handswritableto the failure completions. Before the shell,writableis still undefined, so they run withsink === undefined(#L2010-L2076, see L2072-L2075).pipe()'s completion then ends the raw sink:sink ? sink.end() : w.end()(#L3264-L3274). This is the [2.0 rc.9] await renderToStream(): an async value that rejects as a direct child of <Loading> throws "reading 'emit'" and the promise never settles #3569 behavior, andssr-async-rejection-3569.spec.tsxasserts it (#L394-L403).createSSRResponseonly callsresolvefrom its firstwrite(or from the pre-flushLocationbranch there). Itsend()starts withif (closed || !controller) return;, andcontrolleronly exists after that firstwrite(#L6666-L6736,end()at L6715-L6734). Anend()with nothing written returns early and the promise is never settled.This stack trace, printed from a raw
pipe()sink'send()with the rc.11 dist, confirms the order for the root case:Test coverage: the #3569 (b) tests cover
await, rawpipe()andreadable. ThecreateSSRResponsetests inhttp-components.spec.tsxonly cover an error caught by<Errored>, so this path has no test.Suggested fix
Settle the promise when
end()arrives before anywrite, following the pre-flushLocationbranch in the same function:end() { - if (closed || !controller) return; + if (closed) return; + if (!flushed) { + // The render failed before the shell: pipe() ended the sink with nothing written. + closed = true; + if (stub) commitResponseStub(stub, { event }); + const head = deriveHead(stub, responseInit); + const status = stub && stub.headers.get("Location") ? getExpectedRedirectStatus(stub) : 500; + resolve(new Response(null, { status, headers: head.headers })); + return; + } const location = stub && stub.headers.get("Location");I'd resolve with a 500 rather than reject: it keeps
createSSRResponsefrom ever rejecting (no new failure path for callers that don't catch it), andonErrorhas already reported the cause. Rejecting would let host middleware render its own error page, but callers would then have to handle a rejection.Two details in the diff:
stub.statuson purpose: a status set before the failure should not survive a render that produced no page. In practicehttpStatus()/httpHeader()declarations made inside the render are already gone by then, becauseabandon()disposes the render before the failure completions run and the uncommitted declarations revert on cleanup.Locationstill on the stub (set outside the render, for example by middleware) keeps the redirect, in the same order as the pre-flush branch inwrite(). Without that check the host would get a 500 carrying aLocationheader.I applied the diff above to a copy of the rc.11 dist. Both hanging trees then resolve with
500and an empty body, and every other cell in the matrix is unchanged. Withevent.response.headers.set("Location", "/login")before the render, the same failure resolves with302andlocation: /login; withhttpStatus(404)orhttpHeader("Location", ...)declared inside the render, it resolves with500. A regression test fits next to the #3569 (b) tests: thefailingPreShell()fixture consumed throughcreateSSRResponse.A related case with a different cause: aborting
options.signalbefore the shell also leavescreateSSRResponsepending.abandon("signal")skips the failure completions on purpose, since the client has left, so this may be intended. Still, middleware that awaitsnext()never unwinds in that case.Screenshots or Videos
N/A (server only).
Platform
solid-js2.0.0-rc.11,@solidjs/web2.0.0-rc.11.createSSRResponseis unchanged onnext(bf87f27, 2.0.0-rc.12 not yet published).Refs #3569.