Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/client-only-named-export.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions apps/tests/src/e2e/client-only.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 3 additions & 0 deletions apps/tests/src/routes/client-only/_default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function DefaultClientOnly() {
return <p id="client-only-default">CLIENT_ONLY_DEFAULT_EXPORT_RENDERED</p>;
}
8 changes: 8 additions & 0 deletions apps/tests/src/routes/client-only/_named.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function NamedClientOnly() {
return <p id="client-only-named">CLIENT_ONLY_NAMED_EXPORT_RENDERED</p>;
}

// Never imported through clientOnly, so it should stay out of the client bundle.
export function UnusedClientOnly() {
return <p>CLIENT_ONLY_UNUSED_EXPORT_MARKER</p>;
}
7 changes: 7 additions & 0 deletions apps/tests/src/routes/client-only/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { clientOnly } from "@solidjs/start";

const DefaultClientOnly = clientOnly(() => import("./_default"));

export default function ClientOnlyDefault() {
return <DefaultClientOnly fallback={<p id="client-only-fallback">CLIENT_ONLY_FALLBACK</p>} />;
}
9 changes: 9 additions & 0 deletions apps/tests/src/routes/client-only/named.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { clientOnly } from "@solidjs/start";

const NamedClientOnly = clientOnly(() =>
import("./_named").then(({ NamedClientOnly }) => NamedClientOnly),
);

export default function ClientOnlyNamed() {
return <NamedClientOnly fallback={<p id="client-only-fallback">CLIENT_ONLY_FALLBACK</p>} />;
}
17 changes: 17 additions & 0 deletions apps/tests/src/routes/treeshaking/client-only.server.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
32 changes: 23 additions & 9 deletions packages/start/src/shared/clientOnly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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<T extends Component<any>>(
fn: () => Promise<{
default: T;
}>,
fn: () => Promise<ClientOnlyModule<T>>,
options: { lazy?: boolean } = {},
) {
if (isServer) return (props: ComponentProps<T> & { fallback?: JSX.Element }) => props.fallback;
Expand All @@ -36,11 +51,10 @@ export default function clientOnly<T extends Component<any>>(
};
}

function load<T>(
fn: () => Promise<{
default: T;
}>,
setComp: Setter<T>,
function load<T extends Component<any>>(
fn: () => Promise<ClientOnlyModule<T>>,
setComp: Setter<T | undefined>,
) {
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)));
}
Loading