diff --git a/.changeset/tall-hoops-repeat.md b/.changeset/tall-hoops-repeat.md new file mode 100644 index 000000000..c5bfc685e --- /dev/null +++ b/.changeset/tall-hoops-repeat.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Report errors from lazily loaded route modules instead of hanging the SSR stream. A syntax error in a route (or anything it imports) made the module's dynamic import reject, which left its Suspense boundary pending forever: the response never finished and the browser was stuck on a blank page with no error shown. The failure now reaches the nearest `ErrorBoundary`, so the dev overlay displays it. diff --git a/packages/start/src/shared/lazy.spec.ts b/packages/start/src/shared/lazy.spec.ts new file mode 100644 index 000000000..71f68721e --- /dev/null +++ b/packages/start/src/shared/lazy.spec.ts @@ -0,0 +1,69 @@ +import { createComponent, ErrorBoundary, Suspense } from "solid-js"; +import { renderToStream } from "solid-js/web"; +import { describe, expect, it, vi } from "vitest"; + +// Only used to attach a route's assets during SSR; neither exists outside a Vite build. +vi.mock("solid-start:get-manifest", () => ({ + getManifest: () => ({ getAssets: async () => [] }), +})); +vi.mock("../server/assets/index.ts", () => ({ useAssets: () => {} })); + +const { default: lazy } = await import("./lazy.ts"); + +function render(code: () => any, timeout = 1000) { + return new Promise((resolve, reject) => { + let html = ""; + const timer = setTimeout(() => reject(new Error("the SSR stream never closed")), timeout); + + renderToStream(code).pipe({ + write: (payload: string) => void (html += payload), + end: () => { + clearTimeout(timer); + resolve(html); + }, + }); + }); +} + +describe("lazy", () => { + it("surfaces a failed module import instead of hanging the stream", async () => { + const error = new Error("Unexpected token"); + const Broken = lazy(() => Promise.reject(error)); + + let caught: unknown; + const html = await render(() => + createComponent(ErrorBoundary, { + fallback: (err: unknown) => { + caught = err; + return "boundary"; + }, + get children() { + return createComponent(Suspense, { + fallback: "loading", + get children() { + return createComponent(Broken, {}); + }, + }); + }, + }), + ); + + expect(caught).toBe(error); + expect(html).toContain("boundary"); + }); + + it("still renders a module that loads", async () => { + const Ok = lazy(async () => ({ default: () => "loaded" })); + + const html = await render(() => + createComponent(Suspense, { + fallback: "loading", + get children() { + return createComponent(Ok, {}); + }, + }), + ); + + expect(html).toContain("loaded"); + }); +}); diff --git a/packages/start/src/shared/lazy.ts b/packages/start/src/shared/lazy.ts index 4904e57ba..eee2d7ce3 100644 --- a/packages/start/src/shared/lazy.ts +++ b/packages/start/src/shared/lazy.ts @@ -17,7 +17,7 @@ const getAssets = async (id: string) => { }; const withAssets = function Promise<{ default: Component }>>(fn: T): T { - const wrapper = async () => { + const load = async () => { const mod = await fn(); // This id$$ export is generated by the lazy vite plugin @@ -42,6 +42,16 @@ const withAssets = function Promise<{ default: Component } }; }; + // Solid's server-side `lazy` has no rejection path: a rejected module promise + // leaves its Suspense boundary pending and the SSR stream never closes. Throw + // while rendering instead, so the error reaches the nearest ErrorBoundary. + const wrapper = () => + load().catch(error => ({ + default: () => { + throw error; + }, + })); + return wrapper as T; };