From a8396d3ed9a5a122879b04c0178fcfb059edb655 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 5 Oct 2026 21:06:50 -0500 Subject: [PATCH 1/2] fix(ssr): a memo joining a pending slot adopts the slot's answer A retry pass that re-creates an async memo while its slot is in flight hands it the shared deferred, but only the earlier memo's promise settles that deferred. The joined memo kept its NotReadyError on the resolved promise, so any memo reading it retried every microtask and the stream never ended. Fixes #3815. --- .changeset/joined-slot-adopts-answer.md | 5 + packages/solid/src/server/signals.ts | 42 +++---- .../hole-retry-shared-slot-3815.spec.tsx | 109 ++++++++++++++++++ 3 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 .changeset/joined-slot-adopts-answer.md create mode 100644 packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx diff --git a/.changeset/joined-slot-adopts-answer.md b/.changeset/joined-slot-adopts-answer.md new file mode 100644 index 000000000..2920978dd --- /dev/null +++ b/.changeset/joined-slot-adopts-answer.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +A server memo re-created at a still-pending slot now takes the slot's answer when the earlier flight settles it (#3815). A retry pass that re-creates a component while its async memo is in flight hands the new memo the slot's shared deferred, but only the earlier memo's promise settles it, so the new memo kept its `NotReadyError` on that already-resolved promise. A memo reading it then retried every microtask, so no timer fired again, the stream never ended and the process sat at 100% CPU. On rc.13 the trigger was a function hole that created a component returning a pending `lazy()` view. On `next` it is an `` retry that re-creates a pending ``, with a fresh promise per setup and a memo reading it. The joined memo now adopts the slot's value or error when the shared deferred settles, the same way a re-created async-iterable node already did. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 272849c11..fd27c51f7 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1049,6 +1049,17 @@ const SLOTS = /* @__PURE__ */ Symbol("settledSlots"); // fresh one per pass. Same lifetime and keying rationale as SLOTS above. const PROJECTION_SLOTS = /* @__PURE__ */ Symbol("projectionSlots"); +function adoptSlot(comp: ServerComputation, slot: SlotRecord) { + if (slot.s === 1) { + comp.value = slot.v; + comp.error = undefined; + comp.errored = false; + } else { + comp.error = slot.v; + comp.errored = true; + } +} + function settleServerAsync( initial: T | PromiseLike, rerun: () => T | PromiseLike, @@ -1679,14 +1690,7 @@ function processResult( // Observe its rejection so a rejecting duplicate doesn't surface as an // unhandled rejection (fatal under --unhandled-rejections=strict). (result as any).then(undefined, () => {}); - if (slot.s === 1) { - comp.value = slot.v; - comp.error = undefined; - comp.errored = false; - } else { - comp.error = slot.v; - comp.errored = true; - } + adoptSlot(comp, slot); return; } const deferred: DeferredPromise = slot ? slot.d! : createDeferredPromise(); @@ -1694,6 +1698,14 @@ function processResult( if (!slot) { recordSlot(0, undefined, deferred); if (serializes) ctx.serialize(id, deferred.promise, deferStream); + } else { + // Joined flight (#3815): the slot may settle from the earlier node's + // promise, which never touches this comp — adopt it, or readers retry + // on that settled promise in microtasks forever. + const settle = () => { + if (slot.s !== 1 || !(loadingState?.served && serializes)) adoptSlot(comp, slot); + }; + deferred.promise.then(settle, settle); } // Flatten one async level, mirroring the client core's handleAsync: a // thenable that RESOLVES to an AsyncIterable — the shape an async stub @@ -1859,21 +1871,11 @@ function processResult( const slotted = !!(id && ctx) && (serializes || !pumpsInScope(ctx, scopeOwner)); const slot: SlotRecord | undefined = slotted ? (ctx as any)[SLOTS]?.[id!] : undefined; if (slot) { - const adopt = () => { - if (slot.s === 1) { - comp.value = slot.v; - comp.error = undefined; - comp.errored = false; - } else { - comp.error = slot.v; - comp.errored = true; - } - }; - if (slot.s) return adopt(); + if (slot.s) return adoptSlot(comp, slot); // A known answer lands in this node too; under a served loading value // the markup stays at commit #0 (the first-value lock). const settle = () => { - if (slot.s !== 1 || !(loadingState?.served && serializes)) adopt(); + if (slot.s !== 1 || !(loadingState?.served && serializes)) adoptSlot(comp, slot); }; slot.d!.promise.then(settle, settle); if (loadingState) { diff --git a/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx b/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx new file mode 100644 index 000000000..a602761a1 --- /dev/null +++ b/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx @@ -0,0 +1,109 @@ +/** + * @jsxImportSource @solidjs/web + */ +/** + * #3815 — a retry pass re-creates a component under the same owner ids while + * its async memo is still in flight. The re-created memo joins the pending + * slot (`ctx[SLOTS]`, server signals.ts) and must take the slot's answer when + * the earlier flight settles it, or a memo reading it retries on that settled + * promise in microtasks forever and no timer ever fires again. + */ +import { describe, expect, test } from "vitest"; +import { renderToStream } from "@solidjs/web"; +import { + createComponent as h, + createContext, + createMemo, + Errored, + lazy, + Loading, + Show +} from "solid-js"; + +const delay = (value: T, ms: number) => new Promise(r => setTimeout(() => r(value), ms)); + +// Microtask starvation blocks every timer, so the guard lives in the memo. +const RUN_CAP = 10_000; + +function render(code: () => any) { + return new Promise((resolve, reject) => { + let html = ""; + renderToStream(code, { manifest: {}, onError: reject } as any).pipe({ + write: (c: unknown) => void (html += String(c)), + end: () => resolve(html) + } as any); + }).then( + html => ({ html, error: undefined }), + error => ({ html: "", error: String(error) }) + ); +} + +describe("#3815 a re-created memo joining a pending slot", () => { + test("the issue's shape: a hole returning a pending lazy view", async () => { + const Profile = lazy(() => delay({ default: (props: any) =>

{props.user.name}

}, 5)); + let runs = 0; + function Page() { + const user = createMemo(() => delay({ name: "Jon" }, 20)); + const info = createMemo(() => { + if (++runs > RUN_CAP) throw new Error("info memo did not converge"); + user(); + return delay(["a", "b"], 20); + }); + return ; + } + const out = await render(() =>
{() => }
); + expect(out.error).toBeUndefined(); + expect(runs).toBeLessThan(10); + expect(out.html).toContain("Jon"); + }); + + // An retry re-creating a pending (#3750's shape) with a + // fresh promise per setup and a memo reading it. + test.each([false, true])( + "an Errored retry re-creating a pending Loading settles its reader (async reader: %s)", + async asyncInfo => { + let runs = 0; + const Page = () => { + const user = createMemo(() => delay({ name: "Jon" }, 30)); + const info = createMemo(() => { + if (++runs > RUN_CAP) throw new Error("info memo did not converge"); + const name = user().name; + return asyncInfo ? delay(name, 5) : name; + }); + return h(Loading, { + fallback: "pending", + get children() { + return h(Show, { + get when() { + return info(); + }, + children: (n: any) => n() + } as any); + } + }); + }; + const Context = createContext(); + const App = () => { + const later = createMemo(() => delay("LATER", 10)); + return h(Context as any, { + value: true, + get children() { + return [ + h(Errored, { + fallback: (e: any) => e().message, + get children() { + return h(Page, {}); + } + }), + () => later() + ]; + } + }); + }; + const out = await render(() => h(App, {})); + expect(out.error).toBeUndefined(); + expect(runs).toBeLessThan(20); + expect(out.html).toContain("Jon"); + } + ); +}); From ff3b466287cef49954dd405ab8fbbd0cab53ddcc Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 5 Oct 2026 23:16:34 -0700 Subject: [PATCH 2/2] test(web): cast the #3815 spec's function child for test-types Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx b/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx index a602761a1..0ae74df83 100644 --- a/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx +++ b/packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx @@ -51,7 +51,7 @@ describe("#3815 a re-created memo joining a pending slot", () => { }); return ; } - const out = await render(() =>
{() => }
); + const out = await render(() =>
{(() => ) as any}
); expect(out.error).toBeUndefined(); expect(runs).toBeLessThan(10); expect(out.html).toContain("Jon");