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/src/e2e/client-only.test.ts b/apps/tests/src/e2e/client-only.test.ts new file mode 100644 index 000000000..8c2c3af40 --- /dev/null +++ b/apps/tests/src/e2e/client-only.test.ts @@ -0,0 +1,33 @@ +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/default")).text(); + expect(html).toContain("CLIENT_ONLY_FALLBACK"); + expect(html).not.toContain("CLIENT_ONLY_DEFAULT_EXPORT_RENDERED"); + + 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 ({ + 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/_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/_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/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

} />; +} 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..66449da7c --- /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.server.test.ts b/apps/tests/src/routes/treeshaking/client-only.server.test.ts new file mode 100644 index 000000000..3ae2be928 --- /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(({ 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(); + 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..27331f6f6 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(({ 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; @@ -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))); }