Skip to content

[2.0 SSR] Derived async computations over an ssrSource: "client" hole hang the response (async memo, projection, dynamic deferStream) #3659

Description

@ryansolid

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

Activity

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