Describe the bug
On the server, a bare ssrSource: "client" source throws a NotReadyError whose source is the shared CLIENT_HOLE, tagged $clientHole. <Loading> recognises the tag (hasFinalHole()) and hands the position to the client ($$f) instead of awaiting a settle that never comes. That works for direct reads and for derived sync memos.
It does not work when the read happens inside a derived async computation. Three shapes, all inside <Loading>, never complete the response — renderToStream(App).readable never closes:
const client = createMemo(() => fetchOnClient(), { ssrSource: "client" });
// 1. async memo deriving from the client hole
const derived = createMemo(async () => client().length);
<Loading fallback="…"><div>{derived()}</div></Loading>
// 2. projection deriving from the client hole
const proj = createProjection(d => { d.n = client().length; }, { n: 0 });
<Loading fallback="…"><div>{proj.n}</div></Loading>
// 3. dynamic() whose source reads the client hole, with deferStream
const Dyn = dynamic(() => (client().length ? Big : Small), { deferStream: true });
<Loading fallback="…"><Dyn /></Loading>
Without deferStream, shape 3 completes (it takes the $$f route like a sync memo). createMemo(() => client().length) (sync derived) also completes.
Why
The FINAL classification does not propagate through derived async work:
- Shapes 1 and 2 —
settleServerAsync (packages/solid/src/server/signals.ts): the compute/rerun throws NotReadyError(CLIENT_HOLE), subscribePendingRetry parks a retry on the hole (which never fires), and the computation's own deferred.promise — a fresh promise with no $clientHole tag — is what the memo/projection exposes as its NotReadyError.source and what ctx.serialize(id, deferred.promise) puts on the stream. The <Loading> boundary sees an untagged pending source, so hasFinalHole() is false and it awaits retryPromise / Promise.all(pending.p) forever; the serialized promise also never settles for seroval.
- Shape 3 —
dynamic() in packages/web/src/index.server.ts: on the first NotReadyError with deferStream set it does ctx.block(Promise.resolve(err.source).then(noop, noop)). With err.source === CLIENT_HOLE that adds a never-settling promise to the shell's blockingPromises, so allSettled(blockingPromises) never resolves and the shell never flushes.
Independent of #3657
#3657 (the per-request leak from retry reactions accumulating on CLIENT_HOLE) is fixed in #3658 by making the hole an inert thenable. That removes the retention in these shapes but not the hang: the derived deferred / the Promise.resolve(thenable) result is still a fresh untagged never-settling promise. All three shapes were verified to still time out with #3658 applied.
Likely fix shape
When the caught NotReadyError.source carries $clientHole, the derived computation should not manufacture a new opaque pending source. Either:
- propagate the tag — mark the derived
deferred.promise (and the memo's comp.error.source) $clientHole so hasFinalHole() sees it and the boundary takes the client-handoff route (the aggregate in createErrorBoundary already does exactly this for Promise.all), and skip ctx.serialize for a channel that can never land; or
- terminate it as a client handoff — settle the derived deferred terminally (reject with a client-only marker) so the boundary rejects the fragment / serializes
$$f, matching what clientHandoff() does for a late-surfacing hole.
For dynamic({ deferStream }): skip the ctx.block(...) when err.source.$clientHole is set (the block can never clear) and rethrow so the surrounding <Loading> discovery pass escalates it, mirroring the existing serverEffect guard in server/signals.ts.
Steps to Reproduce
mkdir solid-hang && cd solid-hang
echo '{ "name": "solid-hang", "type": "module" }' > package.json
npm i solid-js@next @solidjs/web@next
hang.mjs:
import { createComponent, createMemo, createProjection, Loading } from 'solid-js'
import { renderToStream, ssr, dynamic } from '@solidjs/web'
const shape = process.argv[2] || 'asyncmemo' // asyncmemo | projection | dynamic | control
const Data = () => {
const client = createMemo(() => Promise.resolve([1, 2, 3]), { ssrSource: 'client' })
switch (shape) {
case 'asyncmemo': {
const derived = createMemo(async () => client().length)
return ssr(['<div>', '</div>'], () => derived())
}
case 'projection': {
const proj = createProjection((d) => { d.n = client().length }, { n: 0 })
return ssr(['<div>', '</div>'], () => proj.n)
}
case 'dynamic': {
const Dyn = dynamic(() => (client().length ? 'span' : 'b'), { deferStream: true })
return createComponent(Dyn, { children: 'x' })
}
case 'control': {
// sync derived memo: completes, takes the $$f route
const derived = createMemo(() => client().length)
return ssr(['<div>', '</div>'], () => derived())
}
}
}
const App = () =>
createComponent(Loading, { fallback: 'loading…', get children() { return createComponent(Data, {}) } })
console.log(`shape: ${shape}`)
const timer = setTimeout(() => { console.log('HANG: response did not complete in 3s'); process.exit(2) }, 3000)
const html = await new Response(renderToStream(App).readable).text()
clearTimeout(timer)
console.log('OK', html.length, 'bytes')
node --conditions=node hang.mjs asyncmemo # HANG
node --conditions=node hang.mjs projection # HANG
node --conditions=node hang.mjs dynamic # HANG
node --conditions=node hang.mjs control # OK
Expected behavior
The response completes: the boundary hands the position to the client ($$f, or a rejected fragment on the streaming route), the same as a direct or sync-derived read of the client-only source.
Platform
solid-js / @solidjs/web 2.0.0-rc.9 (next @ 7742b28), Node 26.4
— filed by Claude via Cursor while investigating #3657
Describe the bug
On the server, a bare
ssrSource: "client"source throws aNotReadyErrorwhose source is the sharedCLIENT_HOLE, tagged$clientHole.<Loading>recognises the tag (hasFinalHole()) and hands the position to the client ($$f) instead of awaiting a settle that never comes. That works for direct reads and for derived sync memos.It does not work when the read happens inside a derived async computation. Three shapes, all inside
<Loading>, never complete the response —renderToStream(App).readablenever closes:Without
deferStream, shape 3 completes (it takes the$$froute like a sync memo).createMemo(() => client().length)(sync derived) also completes.Why
The FINAL classification does not propagate through derived async work:
settleServerAsync(packages/solid/src/server/signals.ts): the compute/rerun throwsNotReadyError(CLIENT_HOLE),subscribePendingRetryparks a retry on the hole (which never fires), and the computation's owndeferred.promise— a fresh promise with no$clientHoletag — is what the memo/projection exposes as itsNotReadyError.sourceand whatctx.serialize(id, deferred.promise)puts on the stream. The<Loading>boundary sees an untagged pending source, sohasFinalHole()is false and it awaitsretryPromise/Promise.all(pending.p)forever; the serialized promise also never settles for seroval.dynamic()inpackages/web/src/index.server.ts: on the firstNotReadyErrorwithdeferStreamset it doesctx.block(Promise.resolve(err.source).then(noop, noop)). Witherr.source === CLIENT_HOLEthat adds a never-settling promise to the shell'sblockingPromises, soallSettled(blockingPromises)never resolves and the shell never flushes.Independent of #3657
#3657 (the per-request leak from retry reactions accumulating on
CLIENT_HOLE) is fixed in #3658 by making the hole an inert thenable. That removes the retention in these shapes but not the hang: the derived deferred / thePromise.resolve(thenable)result is still a fresh untagged never-settling promise. All three shapes were verified to still time out with #3658 applied.Likely fix shape
When the caught
NotReadyError.sourcecarries$clientHole, the derived computation should not manufacture a new opaque pending source. Either:deferred.promise(and the memo'scomp.error.source)$clientHolesohasFinalHole()sees it and the boundary takes the client-handoff route (the aggregate increateErrorBoundaryalready does exactly this forPromise.all), and skipctx.serializefor a channel that can never land; or$$f, matching whatclientHandoff()does for a late-surfacing hole.For
dynamic({ deferStream }): skip thectx.block(...)whenerr.source.$clientHoleis set (the block can never clear) and rethrow so the surrounding<Loading>discovery pass escalates it, mirroring the existingserverEffectguard inserver/signals.ts.Steps to Reproduce
hang.mjs:Expected behavior
The response completes: the boundary hands the position to the client (
$$f, or a rejected fragment on the streaming route), the same as a direct or sync-derived read of the client-only source.Platform
solid-js/@solidjs/web2.0.0-rc.9(next@ 7742b28), Node 26.4— filed by Claude via Cursor while investigating #3657