Skip to content

Commit 2d64742

Browse files
authored
fix(solid/server): CLIENT_HOLE is an inert thenable, not a Promise — no per-request retry reactions accumulate (#3657) (#3658)
1 parent 7742b28 commit 2d64742

3 files changed

Lines changed: 116 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"solid-js": patch
3+
---
4+
5+
Fix a per-request SSR memory leak for bare `ssrSource: "client"` sources (#3657). A derived read of a client-only source (`<Show when={client().length}>`, a `dynamic()` source memo, an `<Errored>` aggregate) subscribed its retry to the shared never-settling client-hole promise; those subscriptions could never fire but were never released either, pinning each request's computation, props and data for the life of the process. The client hole is now an inert thenable rather than a native promise — `then` drops its callbacks — so no subscription site can accumulate anything on it. Rendered output is unchanged.

‎packages/solid/src/server/signals.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -858,15 +858,32 @@ type NoFn<T> = T extends Function ? never : T;
858858
/**
859859
* The pending source for BARE `ssrSource: "client"` (no declared commit #0):
860860
* a hole the server can never fill. Reads throw a `NotReadyError` carrying
861-
* this promise; the `$clientHole` tag classifies the suspension as FINAL —
861+
* this thenable; the `$clientHole` tag classifies the suspension as FINAL —
862862
* boundaries hand the position to the client (the "$$f" client-continue
863-
* route) instead of awaiting a settle that will never come. One shared,
864-
* never-settling instance: retry subscriptions attached to it (e.g.
865-
* `subscribePendingRetry`) are inert by design.
863+
* route) instead of awaiting a settle that will never come.
864+
*
865+
* One shared instance, and deliberately NOT a native Promise. A never-
866+
* settling Promise still records every `then`/`await` against it in its
867+
* reaction list, and that list is reachable from the module scope — so each
868+
* retry subscription a derived read attached (`subscribePendingRetry` from
869+
* a `<Show when={client().length}>`, a `dynamic()` source memo, …) pinned
870+
* that request's computation, props and data for the life of the process
871+
* (#3657). This thenable's `then` drops its callbacks: subscribing to it is
872+
* inert at the object, not by convention at each call site. `await` and the
873+
* `Promise` combinators go through `Promise.resolve(thenable)`, which mints
874+
* a fresh never-settling promise per call and holds no reference back here.
866875
*/
867-
const CLIENT_HOLE: Promise<never> = /* @__PURE__ */ Object.assign(new Promise<never>(() => {}), {
868-
$clientHole: true
869-
});
876+
type ClientHole = PromiseLike<never> & { $clientHole: true };
877+
const CLIENT_HOLE: ClientHole = /* @__PURE__ */ (() => {
878+
const hole = {
879+
$clientHole: true as const,
880+
then: () => hole,
881+
catch: () => hole,
882+
finally: () => hole
883+
};
884+
return Object.freeze(hole) as unknown as ClientHole;
885+
})();
886+
const isClientHole = (source: unknown): boolean => source === CLIENT_HOLE;
870887

871888
/**
872889
* A final (client-hole) suspension is only meaningful where a `<Loading>`
@@ -2080,7 +2097,7 @@ function serverEffect<T>(
20802097
// response forever. Rethrow so the surrounding render (a Loading
20812098
// discovery pass — the read throws loudly anywhere else) escalates
20822099
// the suspension to the boundary, which hands off to the client.
2083-
if (source === CLIENT_HOLE) throw err;
2100+
if (isClientHole(source)) throw err;
20842101
const retry = () => {
20852102
if (comp.disposed) return;
20862103
try {
@@ -2096,7 +2113,7 @@ function serverEffect<T>(
20962113
// is no render on the stack to escalate to, so swallow: the
20972114
// effect simply never fires server-side (the client runs it
20982115
// after hydration), instead of blocking the stream forever.
2099-
if (next !== CLIENT_HOLE) ctx.block(next.then(retry, () => {}));
2116+
if (!isClientHole(next)) ctx.block(next.then(retry, () => {}));
21002117
return;
21012118
}
21022119
// Out-of-band by now — route to the boundary's error handler.
@@ -2279,7 +2296,7 @@ export function createOptimisticStore<T extends object = {}>(
22792296
*/
22802297
function createPendingProxy<T extends object>(
22812298
state: T,
2282-
source: Promise<any>
2299+
source: PromiseLike<any>
22832300
): [proxy: Store<T>, markReady: (frozenState?: T) => void, markError: (error: any) => void] {
22842301
let status: 0 | 1 | 2 = 0;
22852302
let error: any;

‎packages/solid/test/server/ssr-async.spec.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,90 @@ describe("ssrSource server modes", () => {
20992099
expect([...serialized.values()]).toContain("$$f");
21002100
expect(result()).toBe("Shell");
21012101
});
2102+
2103+
// #3657: the shared client hole is read by every request that touches a
2104+
// bare client source, and derived reads (a `<Show when={client().length}>`
2105+
// memo, an Errored aggregate's `Promise.all`) subscribe to it as their
2106+
// retry source. A native never-settling Promise records each of those
2107+
// subscriptions in a reaction list rooted in module scope — one retained
2108+
// computation (props, data, owner tree) per request, forever. The hole is
2109+
// therefore not a Promise: a frozen thenable whose `then` drops its
2110+
// callbacks, so no call site can accumulate anything on it.
2111+
test("the client hole is an inert thenable, not a native promise (#3657)", async () => {
2112+
const { context } = createSerializeTrackingContext();
2113+
sharedConfig.context = context;
2114+
2115+
let hole: any;
2116+
createRoot(
2117+
() => {
2118+
const read = (createMemo as any)(() => 1, { ssrSource: "client" });
2119+
(context as any)._loadingPhase = true;
2120+
try {
2121+
read();
2122+
} catch (e) {
2123+
hole = (e as NotReadyError).source;
2124+
} finally {
2125+
(context as any)._loadingPhase = undefined;
2126+
}
2127+
},
2128+
{ id: "t" }
2129+
);
2130+
2131+
expect(hole.$clientHole).toBe(true);
2132+
// No reaction list exists to grow: not a promise by brand or by prototype.
2133+
expect(hole instanceof Promise).toBe(false);
2134+
expect(Object.prototype.toString.call(hole)).not.toBe("[object Promise]");
2135+
expect(Object.isFrozen(hole)).toBe(true);
2136+
2137+
// Every subscription shape the server takes against a pending source is
2138+
// dropped on the floor: direct `then` (subscribePendingRetry), `catch`
2139+
// (the boundary's await guard), the Promise combinators and `await`
2140+
// (which route through Promise.resolve and mint a fresh promise — no
2141+
// reference back to the hole).
2142+
const spy = vi.fn();
2143+
hole.then(spy, spy);
2144+
hole.catch(spy);
2145+
hole.finally(spy);
2146+
Promise.all([hole]).then(spy, spy);
2147+
Promise.resolve(hole).then(spy, spy);
2148+
expect(Promise.resolve(hole)).not.toBe(hole);
2149+
await tick();
2150+
await tick();
2151+
expect(spy).not.toHaveBeenCalled();
2152+
});
2153+
2154+
test("derived read of a client hole (Show over client().length) still hands off to $$f", () => {
2155+
const { context, serialized, registeredFragments } = createMockSSRContext();
2156+
sharedConfig.context = context;
2157+
2158+
let result: any;
2159+
createRoot(
2160+
() => {
2161+
result = Loading({
2162+
fallback: "Shell",
2163+
get children() {
2164+
const data = (createMemo as any)(() => [1, 2, 3], { ssrSource: "client" });
2165+
// The reporter's shape (#3657): the read is in a DERIVED memo —
2166+
// Show's `when` — whose update() catches the NotReady and
2167+
// subscribes its retry to the hole's source.
2168+
return ssr(["<div>", "</div>"], () =>
2169+
Show({
2170+
get when() {
2171+
return data().length;
2172+
},
2173+
children: "loaded"
2174+
})
2175+
) as any;
2176+
}
2177+
});
2178+
},
2179+
{ id: "t" }
2180+
);
2181+
2182+
expect(registeredFragments.size).toBe(0);
2183+
expect([...serialized.values()]).toContain("$$f");
2184+
expect(result()).toBe("Shell");
2185+
});
21022186
});
21032187

21042188
test("ssrSource 'hybrid' runs computation (same as default for Promises)", () => {

0 commit comments

Comments
 (0)