From afe53c46159ac88975cb4d6f9804206b86bbb109 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 21 Sep 2026 12:48:11 +0800 Subject: [PATCH] fix(boundary): allow client-only modules behind a lazy import A module marked `client-only` and loaded through `clientOnly(() => import(...))` failed the server build. The server build resolves every dynamic import to emit its chunk, so it resolved the client-only module and errored, even though that module never runs on the server. The resolve-time hook cannot tell a clientOnly() lazy import apart from a real server import, and throwing at eval time instead hangs the SSR stream, so `client-only` now resolves to an empty module in both environments. Client-only code that does reach the server still fails there on its own. `server-only` is unchanged and still fails a client build. Co-Authored-By: Claude Opus 4.8 --- .changeset/client-only-lazy-import.md | 7 +++ .../start/src/config/boundary-modules.spec.ts | 8 +++- packages/start/src/config/boundary-modules.ts | 48 ++++++++++--------- 3 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 .changeset/client-only-lazy-import.md diff --git a/.changeset/client-only-lazy-import.md b/.changeset/client-only-lazy-import.md new file mode 100644 index 000000000..f18487553 --- /dev/null +++ b/.changeset/client-only-lazy-import.md @@ -0,0 +1,7 @@ +--- +"@solidjs/start": patch +--- + +Stop `client-only` from failing the build for lazily imported client components. + +A module that imported `client-only` and was loaded through `clientOnly(() => import(...))` failed the server build, even though it only ever runs in the browser. The server build resolves every dynamic import to emit its chunk, so it resolved the `client-only` module and rejected it, though that module never runs on the server. `client-only` no longer fails the build. `server-only` is unchanged and still fails a client build. diff --git a/packages/start/src/config/boundary-modules.spec.ts b/packages/start/src/config/boundary-modules.spec.ts index ca62366d5..db8685baa 100644 --- a/packages/start/src/config/boundary-modules.spec.ts +++ b/packages/start/src/config/boundary-modules.spec.ts @@ -19,8 +19,12 @@ describe("boundaryModules", () => { expect(resolveWith("server-only", false)).toThrowError(/server-only.*someClient\.ts/s); }); - it("fails when a server module imports client-only", () => { - expect(resolveWith("client-only", true)).toThrowError(/client-only.*someClient\.ts/s); + it("does not fail the build when a server module resolves client-only", () => { + // The server build resolves clientOnly() dynamic imports to emit their + // chunks without ever running them, so this must not error at resolve. + const plugin = boundaryModules() as any; + const id = resolveWith("client-only", true)(); + expect(plugin.load(id)).toBe("export {}"); }); it("resolves the markers to an empty module in the allowed environment", () => { diff --git a/packages/start/src/config/boundary-modules.ts b/packages/start/src/config/boundary-modules.ts index 2325c2e1f..71b87ac67 100644 --- a/packages/start/src/config/boundary-modules.ts +++ b/packages/start/src/config/boundary-modules.ts @@ -1,18 +1,24 @@ import type { Plugin } from "vite"; -const VIRTUAL_ID = "\0solid-start:boundary-modules:id"; +const EMPTY_ID = "\0solid-start:boundary-modules:empty"; /** - * Supports `server-only` and `client-only` marker modules (#2162): importing - * `server-only` from a client module (or `client-only` from a server module) - * fails at resolve time; in the allowed environment the marker resolves to an - * empty module. + * Supports the `server-only` and `client-only` marker modules (#2162). * - * Start's own server-only entry points (`@solidjs/start/http`, - * `@solidjs/start/middleware`) import `server-only` themselves, so pulling - * them into the client bundle fails loudly instead of shipping server code to - * the browser, where it crashed hydration and broke unrelated actions/forms - * with no diagnostic (https://github.com/solidjs/solid-start/issues/2068). + * `server-only` imported from a client module fails the build at resolve time. + * A client module that reaches server-only code is always a mistake and must + * never bundle. Start's own server-only entry points (`@solidjs/start/http`, + * `@solidjs/start/middleware`) import `server-only` themselves, so pulling them + * into the client bundle fails loudly instead of shipping server code to the + * browser (https://github.com/solidjs/solid-start/issues/2068). + * + * `client-only` cannot be enforced the same way. The server build resolves + * every dynamic import to emit its chunk, so a module reached only through + * `clientOnly(() => import(...))` is resolved by the server build even though + * it never runs on the server. Failing the build there is a false positive + * that breaks the client-only lazy pattern, so `client-only` resolves to an + * empty module in both environments. Client-only code that does reach the + * server runtime still fails there on its own (e.g. a missing `window`). */ export function boundaryModules(): Plugin { return { @@ -20,26 +26,24 @@ export function boundaryModules(): Plugin { enforce: "pre", resolveId(id, importer, { ssr }) { if (id === "server-only") { - if (!ssr) + if (!ssr) { this.error( `Attempt to import 'server-only' in a client module: ${importer}. ` + `Code that uses this module must run only on the server: mark it with ` + `"use server", or make sure it is only imported by server code.`, ); - } else if (id === "client-only") { - if (ssr) - this.error( - `Attempt to import 'client-only' in a server module: ${importer}. ` + - `Code that uses this module must run only in the browser: make sure it ` + - `is only imported by client code (e.g. wrap components with clientOnly()).`, - ); - } else { - return null; + } + return EMPTY_ID; } - return VIRTUAL_ID; + if (id === "client-only") { + return EMPTY_ID; + } + return null; }, load(id) { - if (id === VIRTUAL_ID) return "export {}"; + if (id === EMPTY_ID) { + return "export {}"; + } }, }; }