fix(solid/server): derived async computations over a client hole classify FINAL and hand off instead of hanging (#3659) - #3661
Merged
Conversation
…sify FINAL and hand off instead of hanging (#3659)
🦋 Changeset detectedLatest commit: b26b38d The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Coverage Report for CI Build 36162589281Coverage remained the same at 73.859%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Comparing Footnotes
|
This was referenced Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. ItsNotReadyError(CLIENT_HOLE)landed insettleServerAsync, which calledsubscribePendingRetry(error, attempt)— a retry subscribed to a source that will never fire. The computation's own deferred (serialized as a channel, awaited byseroval'sonDone, and by the boundary) never settled, and the response never completed. Since #3658 madeCLIENT_HOLEan 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):
createMemo(async () => { const v = clientSource(); … })inside<Loading>createProjection(async draft => { draft.n = clientSource().length; })inside<Loading>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 settlesMechanism
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) — theNotReadyErrorbranch checks the source's$clientHoletag (tag, not identity, socreateErrorBoundary's taggedPromise.allaggregates classify too). For a hole it does not subscribe a retry; it callsonError(new NotReadyError(CLIENT_HOLE))— the node's own error becomes the tagged client-hole NotReady, so any re-pull throws it andhasFinalHole()sees FINAL — and resolves the deferred withundefined, the same value the stream's abandonment ledger uses for abandoned channels, soserovaland 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 throughclientHoleRead()before throwing, so a read of the reclassified node outside a Loading discovery pass is the loudASYNC_OUTSIDE_LOADING_BOUNDARYerror, exactly like a direct read.createProjectionsync-throw path — a projection whose compute throws the client hole synchronously short-circuits tocreatePendingProxy(state, CLIENT_HOLE): FINAL at discovery, no channel serialized, no deferred. The async-derive path goes throughsettleServerAsyncabove.dynamic({ deferStream })(web,index.server.ts) — does notctx.block(...)a client hole; rethrows so the enclosing boundary classifies it. MirrorsserverEffect'sisClientHoleguard.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:
NotReadyError(CLIENT_HOLE)— the rejection would route throughverdictNow/ssrSanitizeErrorand be reported as a server error; the client would adopt a rejected channel for content it is supposed to render itself.CLIENT_HOLEwhile keeping the deferred pending — the boundary would classify correctly butseroval'sonDonestill waits on the channel; the stream would classify FINAL and still hang.Interactions checked
<Loading>still throws the loudASYNC_OUTSIDE_LOADING_BOUNDARYerror — direct read, async memo, and projection proxy (renderToStreamonErrorand mock-context tests).deferStreamfor real async is unchanged: the shell still waits for the value (<span>before<script>).<div>2</div>), and existing masked-hole post-flush behaviour still rejects the fragment.feat/frames-live):git merge-treeagainst its current head (f366c400) is clean. It does not touchsettleServerAsync,subscribePendingRetry,hydration.ts, orindex.server.ts; itscreateProjectionchanges (settleWith(markReady),if (serializes), the pump branch) are adjacent to but disjoint from this fix. The pump path still routes throughsettleServerAsync, 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 ofnextat2d647420a; if it lands first I'll rebase (expected clean).solid-js807/807 +test-typesclean;@solidjs/webclient 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-asyncdeferStreamcontrol, 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 theclientHandoffchange). 5 fail at head; 3 are controls.createMockSSRContextgained aflushedoption.Public API changes
None — no new or removed exports, props, options, parameters, or diagnostic codes.
Behaviour changes worth knowing about (not API):
"$$f"instead of an empty region + rejected_fr. Harness fixturesbare-client-late-final(post-flush) anderrored-loading-preflush-rejection(error route) are unaffected.SSR_CLIENT_CONTENT_MASKEDdev 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 memossrSource: "client"itself.loadingValuederiving from a hole has the client adoptundefined; andclientHoleRead()'s ambient_loadingPhaseflag means hole reads inside retry continuations after anawaitclassify loud.— Claude via Cursor