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
5 changes: 5 additions & 0 deletions .changeset/joined-slot-adopts-answer.md
Original file line number Diff line number Diff line change
@@ -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 `<Errored>` retry that re-creates a pending `<Loading>`, 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.
42 changes: 22 additions & 20 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>, 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<T, U>(
initial: T | PromiseLike<T>,
rerun: () => T | PromiseLike<T>,
Expand Down Expand Up @@ -1679,21 +1690,22 @@ function processResult<T>(
// 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<T> = slot ? slot.d! : createDeferredPromise<T>();
const serializes = !!(ctx?.async && ctx.serialize && id && !noHydrate);
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
Expand Down Expand Up @@ -1859,21 +1871,11 @@ function processResult<T>(
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) {
Expand Down
109 changes: 109 additions & 0 deletions packages/web/test/server/hole-retry-shared-slot-3815.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 = <T,>(value: T, ms: number) => new Promise<T>(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<string>((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) => <h1>{props.user.name}</h1> }, 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 <Profile user={user()} info={info()} />;
}
const out = await render(() => <main>{(() => <Page />) as any}</main>);
expect(out.error).toBeUndefined();
expect(runs).toBeLessThan(10);
expect(out.html).toContain("Jon</h1>");
});

// An <Errored> retry re-creating a pending <Loading> (#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<boolean>();
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");
}
);
});
Loading