From ffae3afb7e197eb147cf75b177531730ad5aa260 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:58:26 -0700 Subject: [PATCH 1/2] fix(server): handle rejection in lazy() so it reaches Errored (closes #2780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load()` registered a single `p.then(mod => p.v = mod.default)`. When the lazy import rejects, there's no second argument, so the rejection falls through as a process-level `unhandledRejection` and `p.v` stays `undefined` forever — the memo body keeps throwing `NotReadyError` and SSR treats the module as still loading instead of converting the rejection into a regular error that `` can catch. Add a rejection branch that stores the error on `p.error`, then surface it from the memo via `if (p.error) throw p.error` ahead of the `!p.v` NotReadyError check. Mirror the change on the `ctx.block(p.then(...))` call site so it doesn't propagate its own unhandled rejection alongside the one captured for the render path. --- packages/solid/src/server/component.ts | 32 ++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/solid/src/server/component.ts b/packages/solid/src/server/component.ts index d6efd46b3..051d5772d 100644 --- a/packages/solid/src/server/component.ts +++ b/packages/solid/src/server/component.ts @@ -80,13 +80,23 @@ export function lazy>( fn: () => Promise<{ default: T }>, moduleUrl?: string ): T & { preload: () => Promise<{ default: T }>; moduleUrl?: string } { - let p: Promise<{ default: T }> & { v?: T }; + let p: Promise<{ default: T }> & { v?: T; error?: unknown }; let load = () => { if (!p) { p = fn() as any; - p.then(mod => { - p.v = mod.default; - }); + p.then( + mod => { + p.v = mod.default; + }, + err => { + // Capture the rejection so the SSR render path can surface it to + // `` instead of leaving p.v `undefined` forever (which + // would keep throwing `NotReadyError` and look like the module is + // still loading) and instead of leaking the rejection as a + // process-level `unhandledRejection` (#2780). + p.error = err; + } + ); } return p; }; @@ -121,13 +131,21 @@ export function lazy>( } if (ctx?.async) { ctx.block( - p.then(() => { - (p as any).s = "success"; - }) + p.then( + () => { + (p as any).s = "success"; + }, + () => { + // Rejection is captured on `p.error` by `load()` and surfaced + // through the memo below; swallow the rejection of this branch + // so `ctx.block` doesn't propagate a second unhandled rejection. + } + ) ); } return createMemo( () => { + if (p.error) throw p.error; if (!p.v) throw new NotReadyError(p); return p.v(props); }, From cb804cfc78e65b47241126e826423d2258d93fb1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 24 Jun 2026 05:35:59 -0700 Subject: [PATCH 2/2] test(server): cover rejected SSR lazy() reaching Errored + add changeset (#2780) Adds the regression test the PR was missing. The original attempt asserted the fallback rendered into the server HTML, but in the streamed case (rejected lazy under a Loading boundary) the server only serializes the error at the boundary id and the client renders the fallback via the existing streamed-fragment hydration path. The test asserts the correct server-side behavior: the rejection is captured/serialized and the render completes instead of hanging on NotReadyError or leaking an unhandledRejection. Co-authored-by: Cursor --- .../fix-ssr-rejected-lazy-reaches-errored.md | 5 +++ .../solid-web/test/server/ssr-stream.spec.tsx | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .changeset/fix-ssr-rejected-lazy-reaches-errored.md diff --git a/.changeset/fix-ssr-rejected-lazy-reaches-errored.md b/.changeset/fix-ssr-rejected-lazy-reaches-errored.md new file mode 100644 index 000000000..ed4fb9c28 --- /dev/null +++ b/.changeset/fix-ssr-rejected-lazy-reaches-errored.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Fix rejected SSR `lazy()` so it reaches `` instead of stack-overflowing or leaking an unhandled rejection (#2780). `lazy()` hand-rolls its own promise tracking and previously had no rejection handler, so a failed module load left `p.v` undefined forever (the render memo kept throwing `NotReadyError`, i.e. perpetual "loading") and the orphaned rejection escaped as a process-level `unhandledRejection`. The loader now captures the rejection on the lazy and surfaces it through the render memo, and `ctx.block` swallows its duplicate rejection branch — bringing `lazy()` to parity with async memos, whose rejections already propagate to error boundaries. Once the error reaches the boundary, the existing streamed-fragment hydration path renders the fallback as usual. diff --git a/packages/solid-web/test/server/ssr-stream.spec.tsx b/packages/solid-web/test/server/ssr-stream.spec.tsx index aec2b23db..8fc7401b4 100644 --- a/packages/solid-web/test/server/ssr-stream.spec.tsx +++ b/packages/solid-web/test/server/ssr-stream.spec.tsx @@ -266,6 +266,37 @@ describe("SSR Streaming — Basic Rendering", () => { expect(html).not.toContain("tracking scope"); }); + test("rejected lazy() under Errored serializes the error instead of hanging (#2780)", async () => { + const manifest = { "./Boom.tsx": { file: "assets/boom.js" } }; + const LazyBoom = lazy( + () => new Promise((_, rej) => setTimeout(() => rej(new Error("lazy failed")), 10)), + "./Boom.tsx" + ) as any; + + // Without the rejection capture in lazy(), the failed module load left the + // render memo throwing NotReadyError forever (the stream never completes) + // and leaked a process-level unhandledRejection. The render now completes, + // and — because the boundary's region was already streamed (Loading + // placeholder) — the error reaches `` and is serialized at its id + // for the client to render the fallback via the streamed-fragment path. + const html = await renderComplete( + () => ( + err: {String(e()?.message || e())}}> + Pending}> + + + + ), + { manifest } + ); + const rKeys = [...html.matchAll(/_\$HY\.r\["([^"]+)"\]/g)].map(m => m[1]); + // Error captured and serialized at the boundary id, the streamed fragment + // settled (rejected), and the shell did not get stuck on the placeholder. + expect(html).toContain("lazy failed"); + expect(rKeys).toContain("0"); + expect(rKeys).toContain("000_fr"); + }); + test("async memo — shell contains fallback, final has resolved value", async () => { function App() { const data = createMemo(async () => {