Skip to content

fix(server): handle rejection in lazy() so it reaches Errored (closes #2780)#2781

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
tsushanth:fix-server-lazy-rejection-handler
Jun 24, 2026
Merged

fix(server): handle rejection in lazy() so it reaches Errored (closes #2780)#2781
ryansolid merged 2 commits into
solidjs:nextfrom
tsushanth:fix-server-lazy-rejection-handler

Conversation

@tsushanth

@tsushanth tsushanth commented Jun 13, 2026

Copy link
Copy Markdown

Summary

Closes #2780.

lazy() server impl (packages/solid/src/server/component.ts:79+) registers p.then(mod => p.v = mod.default) with no rejection branch. When the lazy import rejects:

  1. p.v stays undefined, so the memo body keeps throwing NotReadyError and SSR thinks the module is still loading.
  2. The unhandled rejection leaks to the process as unhandledRejection.
  3. Locally that surfaces as a RangeError: Maximum call stack size exceeded in the serialized output (per reporter) — but the real bug, as the reporter points out, is that the rejection never reaches <Errored>.

Fix:

  • Register the second .then argument on load()'s p.then(...) and store the error on p.error.
  • In the render memo, check if (p.error) throw p.error ahead of the NotReadyError check, so the throw lands in the surrounding <Errored> instead of looping the loading state.
  • Mirror the same shape on the ctx.block(p.then(...)) callsite so it doesn't propagate its own unhandled rejection alongside the one we capture for the render path.

The diagnosis in the issue body (p.then(mod => p.v = mod.default) with no rejection handler) maps 1:1 to the patch — same place, same shape.

Test plan

Tried adding a regression test under SSR Streaming — Error Handling in packages/solid-web/test/server/ssr-stream.spec.tsx:

test("rejected SSR lazy() reaches Errored boundary instead of leaking (#2780)", async () => {
  const LazyBoom = lazy<Component<{}>>(
    () => Promise.reject(new Error("lazy failed")) as Promise<{ default: Component<{}> }>,
    "./Boom.tsx"
  );
  function App() {
    return (
      <Errored fallback={err => <span>error: {String((err() as Error).message)}</span>}>
        <Loading fallback={<span>Loading lazy...</span>}>
          <LazyBoom />
        </Loading>
      </Errored>
    );
  }
  const { chunks } = await collectChunks(() => <App />, {
    manifest: { "./Boom.tsx": { file: "assets/Boom.js" } }
  });
  expect(chunks.join("")).toContain("error:");
  expect(chunks.join("")).toContain("lazy failed");
});

It produced only the SSR hydration script tags rather than the <Errored> fallback — I suspect the streaming machinery doesn't re-pull the memo body after ctx.block settles in the rejection case, or the lazy-with-manifest test path needs a different harness (the existing lazy() with no manifest throws tests preload via await LazyHome.preload!() before rendering, so they never exercise the rejection-on-render path). Rather than ship a flaky/false-negative test, I dropped it from the diff and left the source fix self-contained — it's a small mechanical change you can sanity-check directly. Happy to follow up with a working test once a maintainer points at the right harness shape, or once #2779 surfaces a similar Promise-on-render path that I can borrow.

…olidjs#2780)

`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 `<Errored>` 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.
@changeset-bot

changeset-bot Bot commented Jun 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cb804cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
solid-js Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/web Patch
test-integration Patch
babel-preset-solid Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…set (solidjs#2780)

Adds the regression test the PR was missing. The original attempt asserted
the <Errored> 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 <cursoragent@cursor.com>
@ryansolid ryansolid merged commit 4f14a34 into solidjs:next Jun 24, 2026
1 of 2 checks passed
@codspeed-hq

codspeed-hq Bot commented Jun 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 118 untouched benchmarks


Comparing tsushanth:fix-server-lazy-rejection-handler (cb804cf) with next (c943c5c)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants