Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-client-hole-derived-async-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"solid-js": patch
"@solidjs/web": patch
---

Fix SSR hanging when a derived async computation reads a bare `ssrSource: "client"` hole inside `<Loading>`. `createMemo(async …)`, `createProjection`, and `dynamic({ deferStream: true })` whose compute throws the client-hole NotReady now classify FINAL — the boundary hands the subtree off to the client exactly as a direct read does — instead of subscribing a retry to a source that never settles and leaving the response open. A client hole that surfaces before the shell has flushed now takes the same fallback + `$$f` route as one found at discovery, rather than rejecting the fragment over an empty region.
41 changes: 33 additions & 8 deletions packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,13 +497,25 @@ function ssrLoadingBoundary(
const finalAtDiscovery = ctx.async && hasFinalHole();

const fallbackOwner = createOwner({ id });
// The placeholder wrapper around a streaming fallback (see `plainFallback`).
const tpl = collapseFallback
? [`<template id="pl-${id}">`, `</template><!--pl-${id}-->`]
: [`<template id="pl-${id}"></template>`, `<!--pl-${id}-->`];
const fallbackResult = runWithOwner(fallbackOwner, () => {
if (!ctx.async || finalAtDiscovery) return fallback();
const tpl = collapseFallback
? [`<template id="pl-${id}">`, `</template><!--pl-${id}-->`]
: [`<template id="pl-${id}"></template>`, `<!--pl-${id}-->`];
return ctx.ssr(tpl, ctx.escape(fallback()));
});
// The streaming fallback without its placeholder wrapper — what the
// "$$f" route inlines. The wrapper is ours (`tpl`), so the markup between
// its two halves is exactly the fallback. A resolved template is one
// segment (`t` a string, or a single-segment array — `h.length + 1`);
// more segments mean the fallback itself is still resolving (an async
// hole in it) and there is no plain markup to inline: `undefined`.
const plainFallback = (): string | undefined => {
const raw = (fallbackResult as any)?.t;
const t = Array.isArray(raw) ? (raw.length === 1 ? raw[0] : undefined) : raw;
return typeof t === "string" ? t.slice(tpl[0].length, t.length - tpl[1].length) : undefined;
};

if (finalAtDiscovery) {
commitBoundaryState();
Expand All @@ -518,14 +530,27 @@ function ssrLoadingBoundary(
if (ctx.async) {
const regOpts = revealGroup ? { revealGroup: revealGroup.id } : undefined;
done = ctx.registerFragment(id, regOpts);
// A final hole surfacing only now (an earlier real async read masked it
// during the initial discovery) can't take the "$$f" route anymore: the
// fragment protocol requires a settle, and "settle but keep the fallback"
// is not expressible. Reject instead — the placeholder swaps out and the
// client renders this boundary's content fresh after hydration
// A final hole surfacing only now: an earlier real async read masked it
// during the initial discovery, or the hole was reached through a
// derived async computation, whose FINAL classification lands a
// microtask after discovery (#3659). Before the shell has flushed the
// position is still the shell's to shape: the placeholder inlines away to
// the PLAIN fallback and the boundary serializes "$$f" — the at-discovery
// route, one pass late — with the fragment settling clean (the client's
// "$$f" branch takes precedence over a settled `_fr`). After the flush
// "settle but keep the fallback" is not expressible: the fragment
// protocol requires a swap. Reject instead — the placeholder swaps out
// and the client renders this boundary's content fresh after hydration
// (resume(false)), the closest streaming analogue of the client-continue.
const clientHandoff = () => {
if (!flushed) commitBoundaryState();
const plain = ctx.flushed !== undefined && !ctx.flushed() ? plainFallback() : undefined;
if (plain !== undefined) {
ctx.serialize(id, "$$f");
done!(plain);
record("client", false);
return;
}
const streamed = done!(
undefined,
new Error(`client-only content (bare ssrSource: "client")`)
Expand Down
53 changes: 48 additions & 5 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,33 @@ function settleServerAsync<T, U>(
) {
let first = true;

// A pending read inside the compute. A real source's NotReady joins the
// retry chain: the attempt re-runs once it settles. A CLIENT HOLE's is
// FINAL for this computation (#3659): the compute derives from a value the
// server can never have, so the node has no server answer and never will.
// Retrying is pointless (the hole never settles), and leaving `deferred`
// pending wedged everything that waited on it — the enclosing <Loading>
// (`Promise.all(pending.p)` over an untagged promise) and seroval's onDone
// for the serialized channel — so the response never completed. Classify
// the node as the bare client source it derives from: its error becomes
// the tagged client-hole NotReady (reads take that path — loud outside a
// boundary, FINAL inside one, where the boundary's re-pull sees the tag
// and hands off to the client), and the deferred resolves `undefined`,
// the abandonment ledger's value for a channel nobody consumes (the
// handed-off subtree renders fresh on the client, unhydrated). The tag is
// the classification (`hasFinalHole`'s rule), not identity: an <Errored>
// aggregate over a hole carries it too.
const pending = (error: any): boolean => {
if (!(error instanceof NotReadyError)) return false;
if ((error.source as any)?.$clientHole === true) {
onError(new NotReadyError(CLIENT_HOLE));
deferred.resolve(undefined as U);
return true;
}
subscribePendingRetry(error, attempt);
return true;
};

const attempt = () => {
if (isDisposed()) return;

Expand All @@ -1040,7 +1067,7 @@ function settleServerAsync<T, U>(
current = first ? initial : rerun();
first = false;
} catch (error) {
if (subscribePendingRetry(error, attempt)) return;
if (pending(error)) return;
onError(error);
deferred.reject(error);
return;
Expand All @@ -1057,9 +1084,10 @@ function settleServerAsync<T, U>(
},
error => {
// NotReady defers to the retry chain (`attempt` no-ops once disposed —
// a re-created node joins the flight and drives the shared deferred).
// Terminal errors settle unconditionally, same as the success path.
if (subscribePendingRetry(error, attempt)) return;
// a re-created node joins the flight and drives the shared deferred)
// or, for a client hole, ends it (see `pending`). Terminal errors
// settle unconditionally, same as the success path.
if (pending(error)) return;
onError(error);
deferred.reject(error);
}
Expand Down Expand Up @@ -2302,7 +2330,13 @@ function createPendingProxy<T extends object>(
let error: any;
let readTarget: T = state;
const gate = () => {
if (status > 1) throw error;
if (status > 1) {
// A derive that landed on a client hole (settleServerAsync's FINAL
// reclassification, #3659) errors with the tagged NotReady: the same
// loud-outside-a-boundary rule as the bare client store below.
if (isClientHole((error as NotReadyError)?.source)) clientHoleRead();
throw error;
}
if (status) return;
// Bare client store: same loud-outside-a-boundary rule as the memo
// read path (see clientHoleRead).
Expand Down Expand Up @@ -2527,6 +2561,15 @@ export function createProjection<T extends object = {}>(
result = runProjection();
} catch (error) {
if (!(error instanceof NotReadyError)) throw error;
// The derive read a client hole synchronously: FINAL at discovery, the
// structural form of a bare client projection (#3659) — no deferred to
// retry, no channel to serialize; the nearest <Loading> boundary hands
// the position to the client at once. (A hole reached asynchronously —
// after an await, or on a retry — lands in settleServerAsync's FINAL
// reclassification instead.)
if ((error.source as any)?.$clientHole === true) {
return createPendingProxy(state, CLIENT_HOLE)[0];
}

const deferred = createDeferredPromise<T>();
const [pending, markReady, markError] = createPendingProxy(state, deferred.promise);
Expand Down
Loading
Loading