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