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..0ae74df83 --- /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(() =>
{(() => ) as any}
); + 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"); + } + ); +});