Skip to content

fix(solid/server): derived async computations over a client hole classify FINAL and hand off instead of hanging (#3659) - #3661

Merged
ryansolid merged 1 commit into
nextfrom
fix-client-hole-derived-async-hang
Sep 25, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix-client-hole-derived-async-hang

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3659

The hang

A bare ssrSource: "client" hole (CLIENT_HOLE) is the FINAL pending source: it never settles, and a <Loading> boundary that sees a direct read of it hands the subtree to the client (hasFinalHole() → fallback + "$$f"). But a derived async computation whose compute reads the hole didn't take that route. Its NotReadyError(CLIENT_HOLE) landed in settleServerAsync, which called subscribePendingRetry(error, attempt) — a retry subscribed to a source that will never fire. The computation's own deferred (serialized as a channel, awaited by seroval's onDone, and by the boundary) never settled, and the response never completed. Since #3658 made CLIENT_HOLE an inert thenable this leaks nothing, but it still hangs.

Three shapes, all reproduced deterministically before the fix (each timed out at head, all pass after):

  1. createMemo(async () => { const v = clientSource(); … }) inside <Loading>
  2. createProjection(async draft => { draft.n = clientSource().length; }) inside <Loading>
  3. dynamic({ deferStream: true }) (@solidjs/web) rendering a client-only source inside <Loading> — ctx.block(Promise.resolve(err.source)…) blocked the shell on a thenable that never settles

Mechanism

Derived from the FINAL-classification contract: a computation that threw a client-hole NotReady has no server value and never will, so it must classify FINAL and the boundary must hand off exactly as a direct read does.

  • settleServerAsync (solid, signals.ts) — the NotReadyError branch checks the source's $clientHole tag (tag, not identity, so createErrorBoundary's tagged Promise.all aggregates classify too). For a hole it does not subscribe a retry; it calls onError(new NotReadyError(CLIENT_HOLE)) — the node's own error becomes the tagged client-hole NotReady, so any re-pull throws it and hasFinalHole() sees FINAL — and resolves the deferred with undefined, the same value the stream's abandonment ledger uses for abandoned channels, so seroval and the boundary's awaiters finish. The client never reads it: "$$f" renders the content with _hydratingValue=false.
  • createPendingProxy.gate — an errored proxy whose error is the client hole now routes through clientHoleRead() before throwing, so a read of the reclassified node outside a Loading discovery pass is the loud ASYNC_OUTSIDE_LOADING_BOUNDARY error, exactly like a direct read.
  • createProjection sync-throw path — a projection whose compute throws the client hole synchronously short-circuits to createPendingProxy(state, CLIENT_HOLE): FINAL at discovery, no channel serialized, no deferred. The async-derive path goes through settleServerAsync above.
  • dynamic({ deferStream }) (web, index.server.ts) — does not ctx.block(...) a client hole; rethrows so the enclosing boundary classifies it. Mirrors serverEffect's isClientHole guard.
  • ssrLoadingBoundary.clientHandoff (solid, hydration.ts) — when a hole surfaces before the shell has flushed (the reclassified memo settles in a microtask), the boundary now takes the at-discovery route one pass late: inlines the plain fallback, serializes "$$f", settles the fragment clean. Previously it fell into the pre-flush rejection path (replacePlaceholder(…, "") → empty region, rejected _fr), which is also reachable at head with a client hole masked by a microtask-fast real await. Post-flush behaviour is unchanged (fragment rejects as client-only content, client adopts _fr).

Rejected alternatives:

  • Reject the deferred with NotReadyError(CLIENT_HOLE) — the rejection would route through verdictNow/ssrSanitizeError and be reported as a server error; the client would adopt a rejected channel for content it is supposed to render itself.
  • Replace the pending source with CLIENT_HOLE while keeping the deferred pending — the boundary would classify correctly but seroval's onDone still waits on the channel; the stream would classify FINAL and still hang.

Interactions checked

  • A client hole read outside <Loading> still throws the loud ASYNC_OUTSIDE_LOADING_BOUNDARY error — direct read, async memo, and projection proxy (renderToStream onError and mock-context tests).
  • deferStream for real async is unchanged: the shell still waits for the value (<span> before <script>).
  • Real async dependencies of a derived computation still land normally (<div>2</div>), and existing masked-hole post-flush behaviour still rejects the fragment.
  • Stage 8 Part B: live server components — frames consume live, document face, conditional reconnect, GET end to end #3660 (feat/frames-live): git merge-tree against its current head (f366c400) is clean. It does not touch settleServerAsync, subscribePendingRetry, hydration.ts, or index.server.ts; its createProjection changes (settleWith(markReady), if (serializes), the pump branch) are adjacent to but disjoint from this fix. The pump path still routes through settleServerAsync, so a pumped projection over a hole gets the same FINAL reclassification. Stage 8 Part B: live server components — frames consume live, document face, conditional reconnect, GET end to end #3660 has not merged yet — this PR is on top of next at 2d647420a; if it lands first I'll rebase (expected clean).
  • Full suites: solid-js 807/807 + test-types clean; @solidjs/web client 960/960, server 1217/1217 (2 skipped), hydrate 253/253. No __artifacts__ rewrites.

Tests

  • packages/web/test/server/client-hole-derived-async-3659.spec.tsx (new, 6): sync-derived control, async memo, projection, dynamic({ deferStream }), real-async deferStream control, outside-Loading loud error. 3 timed out at head.
  • packages/solid/test/server/ssr-async.spec.ts (+8): async memo pre-flush → $$f; post-flush → rejected fragment; node error becomes tagged client-hole NotReady; sync projection FINAL at discovery (no channel); async projection reclassifies + loud outside; outside-Loading async memo loud; real async dependency lands; masked hole settling pre-flush → $$f (pins the clientHandoff change). 5 fail at head; 3 are controls. createMockSSRContext gained a flushed option.

Public API changes

None — no new or removed exports, props, options, parameters, or diagnostic codes.

Behaviour changes worth knowing about (not API):

  • Pre-flush late client handoff now inlines the plain fallback + "$$f" instead of an empty region + rejected _fr. Harness fixtures bare-client-late-final (post-flush) and errored-loading-preflush-rejection (error route) are unaffected.
  • The SSR_CLIENT_CONTENT_MASKED dev advisory now fires for the async-memo shape (the hole surfaces on pass 2) — a correct advisory, since the recommended fix is to declare the derived memo ssrSource: "client" itself.
  • Known, pre-existing, documented not fixed here: an async memo with loadingValue deriving from a hole has the client adopt undefined; and clientHoleRead()'s ambient _loadingPhase flag means hole reads inside retry continuations after an await classify loud.

— Claude via Cursor

@changeset-bot

changeset-bot Bot commented Sep 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b26b38d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
solid-js Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
room-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 36162589281

Coverage remained the same at 73.859%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1174
Covered Lines: 917
Line Coverage: 78.11%
Relevant Branches: 907
Covered Branches: 620
Branch Coverage: 68.36%
Branches in Coverage %: Yes
Coverage Strength: 27.12 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix-client-hole-derived-async-hang (b26b38d) with next (2d64742)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants