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 () => { 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); },