From ac130a50a2816ccca7d298e52470d06bd1d062b8 Mon Sep 17 00:00:00 2001
From: "Alexis H. Munsayac"
Date: Wed, 14 Oct 2026 11:04:20 +0800
Subject: [PATCH] feat(1.x): accept a clientOnly 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(({ Chart }) => Chart))
Destructuring the export inside the loader lets the bundler leave the
module's other exports out of the chunk. On Vite 6, which 1.x uses, the
.then(m => m.Chart) form keeps every export, because Vite's preload
wrapper ends up between import() and .then.
Closes #2064.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/client-only-named-export.md | 11 +++++++
apps/tests/cypress/e2e/client-only.cy.ts | 12 +++++++
apps/tests/src/routes/client-only/_named.tsx | 8 +++++
apps/tests/src/routes/client-only/named.tsx | 9 ++++++
.../routes/treeshaking/client-only.test.ts | 20 ++++++++++++
packages/start/src/shared/clientOnly.tsx | 32 +++++++++++++------
6 files changed, 83 insertions(+), 9 deletions(-)
create mode 100644 .changeset/client-only-named-export.md
create mode 100644 apps/tests/cypress/e2e/client-only.cy.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.test.ts
diff --git a/.changeset/client-only-named-export.md b/.changeset/client-only-named-export.md
new file mode 100644
index 000000000..30b7c86a1
--- /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(({ Chart }) => Chart));
+```
+
+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/cypress/e2e/client-only.cy.ts b/apps/tests/cypress/e2e/client-only.cy.ts
new file mode 100644
index 000000000..ac069485a
--- /dev/null
+++ b/apps/tests/cypress/e2e/client-only.cy.ts
@@ -0,0 +1,12 @@
+describe("clientOnly", () => {
+ it("renders a named export when the loader resolves to the component", () => {
+ cy.request("/client-only/named").then(response => {
+ expect(response.body).to.contain("CLIENT_ONLY_FALLBACK");
+ expect(response.body).not.to.contain("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
+ });
+
+ cy.visit("/client-only/named");
+ cy.get("#client-only-named").contains("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
+ cy.get("#client-only-fallback").should("not.exist");
+ });
+});
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..60edf0bc7
--- /dev/null
+++ b/apps/tests/src/routes/client-only/named.tsx
@@ -0,0 +1,9 @@
+import { clientOnly } from "@solidjs/start";
+
+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.test.ts b/apps/tests/src/routes/treeshaking/client-only.test.ts
new file mode 100644
index 000000000..c439652b4
--- /dev/null
+++ b/apps/tests/src/routes/treeshaking/client-only.test.ts
@@ -0,0 +1,20 @@
+import { readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+// 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 buildDir = path.resolve(process.cwd(), ".vinxi/build/client/_build/assets");
+ const files = (await readdir(buildDir)).filter(file => file.endsWith(".js"));
+ const contents = await Promise.all(
+ files.map(file => readFile(path.join(buildDir, file), "utf-8"))
+ );
+ 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.tsx b/packages/start/src/shared/clientOnly.tsx
index bee0711db..b208336e7 100644
--- a/packages/start/src/shared/clientOnly.tsx
+++ b/packages/start/src/shared/clientOnly.tsx
@@ -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(({ Chart }) => Chart));
+ * ```
+ *
+ * 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
*/
// 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;
@@ -34,11 +49,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)));
}