Skip to content
Closed
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/tall-hoops-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions packages/start/src/shared/lazy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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");
});
});
12 changes: 11 additions & 1 deletion packages/start/src/shared/lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const getAssets = async (id: string) => {
};

const withAssets = function <T extends () => Promise<{ default: Component<any> }>>(fn: T): T {
const wrapper = async () => {
const load = async () => {
const mod = await fn();

// This id$$ export is generated by the lazy vite plugin
Expand All @@ -42,6 +42,16 @@ const withAssets = function <T extends () => Promise<{ default: Component<any> }
};
};

// 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;
};

Expand Down
Loading