From d9379983786de9c7c810827bda3e8ab0e580040f Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Jul 2026 20:12:58 +0200 Subject: [PATCH 1/3] fix: fix-syntax-error-overlay --- .changeset/tall-hoops-repeat.md | 5 ++ packages/start/src/shared/lazy.spec.ts | 73 ++++++++++++++++++++++++++ packages/start/src/shared/lazy.ts | 33 +++++++++--- packages/start/vitest.config.ts | 8 +++ 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 .changeset/tall-hoops-repeat.md create mode 100644 packages/start/src/shared/lazy.spec.ts 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..0cb814c9d --- /dev/null +++ b/packages/start/src/shared/lazy.spec.ts @@ -0,0 +1,73 @@ +import { createComponent, ErrorBoundary, Suspense } from "solid-js"; +import { renderToStream } from "solid-js/web"; +import { describe, expect, it } from "vitest"; + +import lazy from "./lazy.ts"; + +/** Render `code` to a stream and resolve once Solid closes it, or reject on timeout. */ +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, + ).unref?.(); + + renderToStream(code).pipe({ + write(payload: string) { + html += payload; + }, + end() { + clearTimeout(timer as any); + resolve(html); + }, + }); + }); +} + +describe("lazy", () => { + // A module with a syntax error rejects the dynamic import. Solid's server-side + // `lazy` has no rejection path, so without our handling the Suspense boundary + // stays pending, the stream never closes, and the browser is left on a blank + // page with no error reported anywhere. + 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..155bb22da 100644 --- a/packages/start/src/shared/lazy.ts +++ b/packages/start/src/shared/lazy.ts @@ -18,18 +18,35 @@ const getAssets = async (id: string) => { const withAssets = function Promise<{ default: Component }>>(fn: T): T { const wrapper = async () => { - const mod = await fn(); + let mod: { default: Component }; + let assets: Asset[]; - // This id$$ export is generated by the lazy vite plugin - const id: string = (mod as any).id$$; - if (!id) return mod; + try { + mod = await fn(); - if (!mod.default) { - console.error(`Module ${id} does not export default`); - return { default: () => [] }; + // This id$$ export is generated by the lazy vite plugin + const id: string = (mod as any).id$$; + if (!id) return mod; + + if (!mod.default) { + console.error(`Module ${id} does not export default`); + return { default: () => [] }; + } + + assets = await getAssets(id); + } catch (error) { + // Solid's server-side `lazy` has no rejection path: a module promise that + // rejects leaves its Suspense boundary pending forever, so the SSR stream + // never closes and the browser is left with a blank, half-streamed page. + // Resolve with a component that throws while rendering instead, so the + // error reaches the nearest ErrorBoundary (and with it the dev overlay). + return { + default: () => { + throw error; + }, + }; } - const assets: Asset[] = await getAssets(id); if (!assets.length) return mod; return { diff --git a/packages/start/vitest.config.ts b/packages/start/vitest.config.ts index e2a27fadd..c47ba60bc 100644 --- a/packages/start/vitest.config.ts +++ b/packages/start/vitest.config.ts @@ -13,6 +13,8 @@ import { VIRTUAL_MODULES } from "./src/config/constants.ts"; function virtualModuleStubs() { const stubs: Record = { [VIRTUAL_MODULES.serovalPlugins]: "export default globalThis.SEROVAL_PLUGINS_STUB ?? [];", + [VIRTUAL_MODULES.getManifest]: + "export const getManifest = () => ({ getAssets: async () => [] });", }; return { name: "solid-start:test-virtual-module-stubs", @@ -27,6 +29,12 @@ function virtualModuleStubs() { export default defineConfig({ plugins: [virtualModuleStubs()], + // A few specs reach modules that import `.tsx` files for their non-JSX + // exports. There is no Solid JSX compiler here, so point the transform at a + // placeholder factory that is only ever parsed, never called. + oxc: { + jsx: { runtime: "classic", pragma: "__jsx", pragmaFrag: "__jsxFragment" }, + }, test: { globals: true, environment: "node", From e62281c1e9d3b6d0c39ec471a51d079209849ded Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Jul 2026 20:15:23 +0200 Subject: [PATCH 2/3] mock --- packages/start/src/shared/lazy.spec.ts | 11 +++++++++-- packages/start/vitest.config.ts | 8 -------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/start/src/shared/lazy.spec.ts b/packages/start/src/shared/lazy.spec.ts index 0cb814c9d..55769a635 100644 --- a/packages/start/src/shared/lazy.spec.ts +++ b/packages/start/src/shared/lazy.spec.ts @@ -1,8 +1,15 @@ import { createComponent, ErrorBoundary, Suspense } from "solid-js"; import { renderToStream } from "solid-js/web"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import lazy from "./lazy.ts"; +// `lazy.ts` only touches these to attach a route's CSS/preload assets during +// SSR; neither is available (or relevant) 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"); /** Render `code` to a stream and resolve once Solid closes it, or reject on timeout. */ function render(code: () => any, timeout = 1000) { diff --git a/packages/start/vitest.config.ts b/packages/start/vitest.config.ts index c47ba60bc..e2a27fadd 100644 --- a/packages/start/vitest.config.ts +++ b/packages/start/vitest.config.ts @@ -13,8 +13,6 @@ import { VIRTUAL_MODULES } from "./src/config/constants.ts"; function virtualModuleStubs() { const stubs: Record = { [VIRTUAL_MODULES.serovalPlugins]: "export default globalThis.SEROVAL_PLUGINS_STUB ?? [];", - [VIRTUAL_MODULES.getManifest]: - "export const getManifest = () => ({ getAssets: async () => [] });", }; return { name: "solid-start:test-virtual-module-stubs", @@ -29,12 +27,6 @@ function virtualModuleStubs() { export default defineConfig({ plugins: [virtualModuleStubs()], - // A few specs reach modules that import `.tsx` files for their non-JSX - // exports. There is no Solid JSX compiler here, so point the transform at a - // placeholder factory that is only ever parsed, never called. - oxc: { - jsx: { runtime: "classic", pragma: "__jsx", pragmaFrag: "__jsxFragment" }, - }, test: { globals: true, environment: "node", From 079400b79c43bb9476847261e68cb0752d6e4031 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Jul 2026 20:18:59 +0200 Subject: [PATCH 3/3] trim --- packages/start/src/shared/lazy.spec.ts | 21 +++--------- packages/start/src/shared/lazy.ts | 45 +++++++++++--------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/packages/start/src/shared/lazy.spec.ts b/packages/start/src/shared/lazy.spec.ts index 55769a635..71f68721e 100644 --- a/packages/start/src/shared/lazy.spec.ts +++ b/packages/start/src/shared/lazy.spec.ts @@ -2,8 +2,7 @@ import { createComponent, ErrorBoundary, Suspense } from "solid-js"; import { renderToStream } from "solid-js/web"; import { describe, expect, it, vi } from "vitest"; -// `lazy.ts` only touches these to attach a route's CSS/preload assets during -// SSR; neither is available (or relevant) outside a Vite build. +// 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 () => [] }), })); @@ -11,21 +10,15 @@ vi.mock("../server/assets/index.ts", () => ({ useAssets: () => {} })); const { default: lazy } = await import("./lazy.ts"); -/** Render `code` to a stream and resolve once Solid closes it, or reject on timeout. */ 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, - ).unref?.(); + const timer = setTimeout(() => reject(new Error("the SSR stream never closed")), timeout); renderToStream(code).pipe({ - write(payload: string) { - html += payload; - }, - end() { - clearTimeout(timer as any); + write: (payload: string) => void (html += payload), + end: () => { + clearTimeout(timer); resolve(html); }, }); @@ -33,10 +26,6 @@ function render(code: () => any, timeout = 1000) { } describe("lazy", () => { - // A module with a syntax error rejects the dynamic import. Solid's server-side - // `lazy` has no rejection path, so without our handling the Suspense boundary - // stays pending, the stream never closes, and the browser is left on a blank - // page with no error reported anywhere. it("surfaces a failed module import instead of hanging the stream", async () => { const error = new Error("Unexpected token"); const Broken = lazy(() => Promise.reject(error)); diff --git a/packages/start/src/shared/lazy.ts b/packages/start/src/shared/lazy.ts index 155bb22da..eee2d7ce3 100644 --- a/packages/start/src/shared/lazy.ts +++ b/packages/start/src/shared/lazy.ts @@ -17,36 +17,19 @@ const getAssets = async (id: string) => { }; const withAssets = function Promise<{ default: Component }>>(fn: T): T { - const wrapper = async () => { - let mod: { default: Component }; - let assets: Asset[]; + const load = async () => { + const mod = await fn(); - try { - mod = await fn(); + // This id$$ export is generated by the lazy vite plugin + const id: string = (mod as any).id$$; + if (!id) return mod; - // This id$$ export is generated by the lazy vite plugin - const id: string = (mod as any).id$$; - if (!id) return mod; - - if (!mod.default) { - console.error(`Module ${id} does not export default`); - return { default: () => [] }; - } - - assets = await getAssets(id); - } catch (error) { - // Solid's server-side `lazy` has no rejection path: a module promise that - // rejects leaves its Suspense boundary pending forever, so the SSR stream - // never closes and the browser is left with a blank, half-streamed page. - // Resolve with a component that throws while rendering instead, so the - // error reaches the nearest ErrorBoundary (and with it the dev overlay). - return { - default: () => { - throw error; - }, - }; + if (!mod.default) { + console.error(`Module ${id} does not export default`); + return { default: () => [] }; } + const assets: Asset[] = await getAssets(id); if (!assets.length) return mod; return { @@ -59,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; };