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