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/fix-ssr-rejected-lazy-reaches-errored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

Fix rejected SSR `lazy()` so it reaches `<Errored>` 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.
31 changes: 31 additions & 0 deletions packages/solid-web/test/server/ssr-stream.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>((_, 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 `<Errored>` and is serialized at its id
// for the client to render the fallback via the streamed-fragment path.
const html = await renderComplete(
() => (
<Errored fallback={(e: any) => <span>err: {String(e()?.message || e())}</span>}>
<Loading fallback={<span>Pending</span>}>
<LazyBoom />
</Loading>
</Errored>
),
{ 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 () => {
Expand Down
32 changes: 25 additions & 7 deletions packages/solid/src/server/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,23 @@ export function lazy<T extends Component<any>>(
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
// `<Errored>` 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;
};
Expand Down Expand Up @@ -121,13 +131,21 @@ export function lazy<T extends Component<any>>(
}
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);
},
Expand Down
Loading