From 7442b227c9c4e796716d06f3a16d37973b90578a Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 10:57:08 +0800 Subject: [PATCH 1/3] feat(client-only): accept a loader that resolves to the component clientOnly only accepted a module with a default export, so a named export needed import(...).then(m => ({ default: m.Chart })). A loader can now resolve to the component itself, which also works for named exports: clientOnly(() => import("./chart").then(m => m.Chart)) Reading the export inside the loader lets the bundler leave the module's other exports out of the chunk. An option that names the export, read with a computed key inside clientOnly, would keep every export. Closes #2064. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/client-only-named-export.md | 11 +++++++ apps/tests/src/e2e/client-only.test.ts | 26 +++++++++++++++ apps/tests/src/routes/client-only/_named.tsx | 8 +++++ apps/tests/src/routes/client-only/named.tsx | 7 ++++ .../treeshaking/client-only.server.test.ts | 17 ++++++++++ packages/start/src/shared/clientOnly.ts | 32 +++++++++++++------ 6 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 .changeset/client-only-named-export.md create mode 100644 apps/tests/src/e2e/client-only.test.ts create mode 100644 apps/tests/src/routes/client-only/_named.tsx create mode 100644 apps/tests/src/routes/client-only/named.tsx create mode 100644 apps/tests/src/routes/treeshaking/client-only.server.test.ts diff --git a/.changeset/client-only-named-export.md b/.changeset/client-only-named-export.md new file mode 100644 index 000000000..c00dd33ab --- /dev/null +++ b/.changeset/client-only-named-export.md @@ -0,0 +1,11 @@ +--- +"@solidjs/start": patch +--- + +Let a `clientOnly` loader resolve to the component itself, so a named export works without wrapping it in `{ default }`: + +```tsx +const Chart = clientOnly(() => import("./chart").then(m => m.Chart)); +``` + +Reading the export inside the loader also lets the bundler leave the module's other exports out of the chunk. diff --git a/apps/tests/src/e2e/client-only.test.ts b/apps/tests/src/e2e/client-only.test.ts new file mode 100644 index 000000000..b6b87fee1 --- /dev/null +++ b/apps/tests/src/e2e/client-only.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +test.describe("clientOnly", () => { + test("renders a default export only on the client", async ({ page, request }) => { + const html = await (await request.get("/client-only")).text(); + expect(html).not.toContain("clientWithIsServer"); + + await page.goto("/client-only"); + await expect(page.locator("#server-fn-test")).toHaveText('{"clientWithIsServer":false}'); + }); + + test("renders a named export when the loader resolves to the component", async ({ + page, + request, + }) => { + const html = await (await request.get("/client-only/named")).text(); + expect(html).toContain("CLIENT_ONLY_FALLBACK"); + expect(html).not.toContain("CLIENT_ONLY_NAMED_EXPORT_RENDERED"); + + await page.goto("/client-only/named"); + await expect(page.locator("#client-only-named")).toHaveText( + "CLIENT_ONLY_NAMED_EXPORT_RENDERED", + ); + await expect(page.locator("#client-only-fallback")).toHaveCount(0); + }); +}); diff --git a/apps/tests/src/routes/client-only/_named.tsx b/apps/tests/src/routes/client-only/_named.tsx new file mode 100644 index 000000000..2555f9213 --- /dev/null +++ b/apps/tests/src/routes/client-only/_named.tsx @@ -0,0 +1,8 @@ +export function NamedClientOnly() { + return

CLIENT_ONLY_NAMED_EXPORT_RENDERED

; +} + +// Never imported through clientOnly, so it should stay out of the client bundle. +export function UnusedClientOnly() { + return

CLIENT_ONLY_UNUSED_EXPORT_MARKER

; +} diff --git a/apps/tests/src/routes/client-only/named.tsx b/apps/tests/src/routes/client-only/named.tsx new file mode 100644 index 000000000..9a46018d9 --- /dev/null +++ b/apps/tests/src/routes/client-only/named.tsx @@ -0,0 +1,7 @@ +import { clientOnly } from "@solidjs/start"; + +const NamedClientOnly = clientOnly(() => import("./_named").then(m => m.NamedClientOnly)); + +export default function ClientOnlyNamed() { + return CLIENT_ONLY_FALLBACK

} />; +} diff --git a/apps/tests/src/routes/treeshaking/client-only.server.test.ts b/apps/tests/src/routes/treeshaking/client-only.server.test.ts new file mode 100644 index 000000000..4ac474b18 --- /dev/null +++ b/apps/tests/src/routes/treeshaking/client-only.server.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { getBuildOutputDirs, getFiles, readFileContent } from "~/utils/build-output-utils"; + +// clientOnly(() => import("./_named").then(m => m.NamedClientOnly)) reads the +// export inside the loader, so the bundler can leave the module's other +// exports out of the client bundle (#2064). +describe("clientOnly with a named export", () => { + it("bundles only the export the loader reads", async () => { + const { clientOutputRoot } = getBuildOutputDirs(); + const files = await getFiles(clientOutputRoot, /\.js$/); + const contents = await Promise.all(files.map(readFileContent)); + const client = contents.join("\n"); + + expect(client).toContain("CLIENT_ONLY_NAMED_EXPORT_RENDERED"); + expect(client).not.toContain("CLIENT_ONLY_UNUSED_EXPORT_MARKER"); + }); +}); diff --git a/packages/start/src/shared/clientOnly.ts b/packages/start/src/shared/clientOnly.ts index e482815ce..bdb21136f 100644 --- a/packages/start/src/shared/clientOnly.ts +++ b/packages/start/src/shared/clientOnly.ts @@ -4,14 +4,29 @@ import { createMemo, createSignal, onMount, sharedConfig, splitProps, untrack } import { isServer } from "solid-js/web"; /** + * What a `clientOnly` loader can resolve to: a module with a default export, + * or the component itself. + */ +type ClientOnlyModule = T | { default: T }; + +/** + * Renders a component only on the client. The loader can resolve to a module + * with a default export, or to the component itself, which also works for a + * named export: + * + * ```tsx + * const Map = clientOnly(() => import("./map")); + * const Chart = clientOnly(() => import("./chart").then(m => m.Chart)); + * ``` + * + * Reading the export inside the loader, as in the second example, lets the + * bundler leave the module's other exports out of the chunk. * * Read more: https://docs.solidjs.com/solid-start/reference/client/client-only */ // not using Suspense export default function clientOnly>( - fn: () => Promise<{ - default: T; - }>, + fn: () => Promise>, options: { lazy?: boolean } = {}, ) { if (isServer) return (props: ComponentProps & { fallback?: JSX.Element }) => props.fallback; @@ -36,11 +51,10 @@ export default function clientOnly>( }; } -function load( - fn: () => Promise<{ - default: T; - }>, - setComp: Setter, +function load>( + fn: () => Promise>, + setComp: Setter, ) { - fn().then(m => setComp(() => m.default)); + // A module namespace object is never a function, and a component always is. + fn().then(m => setComp(() => (typeof m === "function" ? m : m.default))); } From a3af80bf2b0737eaced11a8ebc5c31d1a002956d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 11:05:40 +0800 Subject: [PATCH 2/3] docs(client-only): show the destructured loader form Use import(...).then(({ Chart }) => Chart) in the JSDoc, the changeset and the test route. On Vite 6, which the 1.x line uses, the .then(m => m.Chart) form keeps every export of the module, because Vite's preload wrapper ends up between import() and .then. The destructured form tree-shakes on both Vite 6 and Vite 8. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/client-only-named-export.md | 4 ++-- apps/tests/src/routes/client-only/named.tsx | 4 +++- .../tests/src/routes/treeshaking/client-only.server.test.ts | 6 +++--- packages/start/src/shared/clientOnly.ts | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/client-only-named-export.md b/.changeset/client-only-named-export.md index c00dd33ab..30b7c86a1 100644 --- a/.changeset/client-only-named-export.md +++ b/.changeset/client-only-named-export.md @@ -5,7 +5,7 @@ Let a `clientOnly` loader resolve to the component itself, so a named export works without wrapping it in `{ default }`: ```tsx -const Chart = clientOnly(() => import("./chart").then(m => m.Chart)); +const Chart = clientOnly(() => import("./chart").then(({ Chart }) => Chart)); ``` -Reading the export inside the loader also lets the bundler leave the module's other exports out of the chunk. +Destructuring the export inside the loader also lets the bundler leave the module's other exports out of the chunk. diff --git a/apps/tests/src/routes/client-only/named.tsx b/apps/tests/src/routes/client-only/named.tsx index 9a46018d9..66449da7c 100644 --- a/apps/tests/src/routes/client-only/named.tsx +++ b/apps/tests/src/routes/client-only/named.tsx @@ -1,6 +1,8 @@ import { clientOnly } from "@solidjs/start"; -const NamedClientOnly = clientOnly(() => import("./_named").then(m => m.NamedClientOnly)); +const NamedClientOnly = clientOnly(() => + import("./_named").then(({ NamedClientOnly }) => NamedClientOnly), +); export default function ClientOnlyNamed() { return CLIENT_ONLY_FALLBACK

} />; diff --git a/apps/tests/src/routes/treeshaking/client-only.server.test.ts b/apps/tests/src/routes/treeshaking/client-only.server.test.ts index 4ac474b18..3ae2be928 100644 --- a/apps/tests/src/routes/treeshaking/client-only.server.test.ts +++ b/apps/tests/src/routes/treeshaking/client-only.server.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { getBuildOutputDirs, getFiles, readFileContent } from "~/utils/build-output-utils"; -// clientOnly(() => import("./_named").then(m => m.NamedClientOnly)) reads the -// export inside the loader, so the bundler can leave the module's other -// exports out of the client bundle (#2064). +// clientOnly(() => import("./_named").then(({ NamedClientOnly }) => NamedClientOnly)) +// reads the export inside the loader, so the bundler can leave the module's +// other exports out of the client bundle (#2064). describe("clientOnly with a named export", () => { it("bundles only the export the loader reads", async () => { const { clientOutputRoot } = getBuildOutputDirs(); diff --git a/packages/start/src/shared/clientOnly.ts b/packages/start/src/shared/clientOnly.ts index bdb21136f..27331f6f6 100644 --- a/packages/start/src/shared/clientOnly.ts +++ b/packages/start/src/shared/clientOnly.ts @@ -16,11 +16,11 @@ type ClientOnlyModule = T | { default: T }; * * ```tsx * const Map = clientOnly(() => import("./map")); - * const Chart = clientOnly(() => import("./chart").then(m => m.Chart)); + * const Chart = clientOnly(() => import("./chart").then(({ Chart }) => Chart)); * ``` * - * Reading the export inside the loader, as in the second example, lets the - * bundler leave the module's other exports out of the chunk. + * Destructuring the export inside the loader, as in the second example, lets + * the bundler leave the module's other exports out of the chunk. * * Read more: https://docs.solidjs.com/solid-start/reference/client/client-only */ From aec0c9e1696735d63332f40d19d9520df4d921fa Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 11:23:13 +0800 Subject: [PATCH 3/3] test(client-only): use a dedicated route for the default export case The default-export case visited /client-only. In bundled dev mode, once /client-only has been visited, its route module is no longer fetched lazily, and hydration-navigation.test.ts waits for that request, so it timed out when it ran after this file. Test the default export on its own /client-only/default route instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/tests/src/e2e/client-only.test.ts | 15 +++++++++++---- apps/tests/src/routes/client-only/_default.tsx | 3 +++ apps/tests/src/routes/client-only/default.tsx | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 apps/tests/src/routes/client-only/_default.tsx create mode 100644 apps/tests/src/routes/client-only/default.tsx diff --git a/apps/tests/src/e2e/client-only.test.ts b/apps/tests/src/e2e/client-only.test.ts index b6b87fee1..8c2c3af40 100644 --- a/apps/tests/src/e2e/client-only.test.ts +++ b/apps/tests/src/e2e/client-only.test.ts @@ -1,12 +1,19 @@ import { expect, test } from "@playwright/test"; +// These use their own routes rather than /client-only: in bundled dev mode, a +// visit to /client-only changes how its route module is loaded afterwards, +// which hydration-navigation.test.ts relies on. test.describe("clientOnly", () => { test("renders a default export only on the client", async ({ page, request }) => { - const html = await (await request.get("/client-only")).text(); - expect(html).not.toContain("clientWithIsServer"); + const html = await (await request.get("/client-only/default")).text(); + expect(html).toContain("CLIENT_ONLY_FALLBACK"); + expect(html).not.toContain("CLIENT_ONLY_DEFAULT_EXPORT_RENDERED"); - await page.goto("/client-only"); - await expect(page.locator("#server-fn-test")).toHaveText('{"clientWithIsServer":false}'); + await page.goto("/client-only/default"); + await expect(page.locator("#client-only-default")).toHaveText( + "CLIENT_ONLY_DEFAULT_EXPORT_RENDERED", + ); + await expect(page.locator("#client-only-fallback")).toHaveCount(0); }); test("renders a named export when the loader resolves to the component", async ({ diff --git a/apps/tests/src/routes/client-only/_default.tsx b/apps/tests/src/routes/client-only/_default.tsx new file mode 100644 index 000000000..6a808fdd7 --- /dev/null +++ b/apps/tests/src/routes/client-only/_default.tsx @@ -0,0 +1,3 @@ +export default function DefaultClientOnly() { + return

CLIENT_ONLY_DEFAULT_EXPORT_RENDERED

; +} diff --git a/apps/tests/src/routes/client-only/default.tsx b/apps/tests/src/routes/client-only/default.tsx new file mode 100644 index 000000000..54e94ec6b --- /dev/null +++ b/apps/tests/src/routes/client-only/default.tsx @@ -0,0 +1,7 @@ +import { clientOnly } from "@solidjs/start"; + +const DefaultClientOnly = clientOnly(() => import("./_default")); + +export default function ClientOnlyDefault() { + return CLIENT_ONLY_FALLBACK

} />; +}