Skip to content

Commit 2ff5882

Browse files
lxsmnsycclaudebrenelz
authored
feat(client-only): accept a loader that resolves to the component (#2346)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent eb50f39 commit 2ff5882

8 files changed

Lines changed: 111 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Let a `clientOnly` loader resolve to the component itself, so a named export works without wrapping it in `{ default }`:
6+
7+
```tsx
8+
const Chart = clientOnly(() => import("./chart").then(({ Chart }) => Chart));
9+
```
10+
11+
Destructuring the export inside the loader also lets the bundler leave the module's other exports out of the chunk.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// These use their own routes rather than /client-only: in bundled dev mode, a
4+
// visit to /client-only changes how its route module is loaded afterwards,
5+
// which hydration-navigation.test.ts relies on.
6+
test.describe("clientOnly", () => {
7+
test("renders a default export only on the client", async ({ page, request }) => {
8+
const html = await (await request.get("/client-only/default")).text();
9+
expect(html).toContain("CLIENT_ONLY_FALLBACK");
10+
expect(html).not.toContain("CLIENT_ONLY_DEFAULT_EXPORT_RENDERED");
11+
12+
await page.goto("/client-only/default");
13+
await expect(page.locator("#client-only-default")).toHaveText(
14+
"CLIENT_ONLY_DEFAULT_EXPORT_RENDERED",
15+
);
16+
await expect(page.locator("#client-only-fallback")).toHaveCount(0);
17+
});
18+
19+
test("renders a named export when the loader resolves to the component", async ({
20+
page,
21+
request,
22+
}) => {
23+
const html = await (await request.get("/client-only/named")).text();
24+
expect(html).toContain("CLIENT_ONLY_FALLBACK");
25+
expect(html).not.toContain("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
26+
27+
await page.goto("/client-only/named");
28+
await expect(page.locator("#client-only-named")).toHaveText(
29+
"CLIENT_ONLY_NAMED_EXPORT_RENDERED",
30+
);
31+
await expect(page.locator("#client-only-fallback")).toHaveCount(0);
32+
});
33+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function DefaultClientOnly() {
2+
return <p id="client-only-default">CLIENT_ONLY_DEFAULT_EXPORT_RENDERED</p>;
3+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function NamedClientOnly() {
2+
return <p id="client-only-named">CLIENT_ONLY_NAMED_EXPORT_RENDERED</p>;
3+
}
4+
5+
// Never imported through clientOnly, so it should stay out of the client bundle.
6+
export function UnusedClientOnly() {
7+
return <p>CLIENT_ONLY_UNUSED_EXPORT_MARKER</p>;
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { clientOnly } from "@solidjs/start";
2+
3+
const DefaultClientOnly = clientOnly(() => import("./_default"));
4+
5+
export default function ClientOnlyDefault() {
6+
return <DefaultClientOnly fallback={<p id="client-only-fallback">CLIENT_ONLY_FALLBACK</p>} />;
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { clientOnly } from "@solidjs/start";
2+
3+
const NamedClientOnly = clientOnly(() =>
4+
import("./_named").then(({ NamedClientOnly }) => NamedClientOnly),
5+
);
6+
7+
export default function ClientOnlyNamed() {
8+
return <NamedClientOnly fallback={<p id="client-only-fallback">CLIENT_ONLY_FALLBACK</p>} />;
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getBuildOutputDirs, getFiles, readFileContent } from "~/utils/build-output-utils";
3+
4+
// clientOnly(() => import("./_named").then(({ NamedClientOnly }) => NamedClientOnly))
5+
// reads the export inside the loader, so the bundler can leave the module's
6+
// other exports out of the client bundle (#2064).
7+
describe("clientOnly with a named export", () => {
8+
it("bundles only the export the loader reads", async () => {
9+
const { clientOutputRoot } = getBuildOutputDirs();
10+
const files = await getFiles(clientOutputRoot, /\.js$/);
11+
const contents = await Promise.all(files.map(readFileContent));
12+
const client = contents.join("\n");
13+
14+
expect(client).toContain("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
15+
expect(client).not.toContain("CLIENT_ONLY_UNUSED_EXPORT_MARKER");
16+
});
17+
});

‎packages/start/src/shared/clientOnly.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,29 @@ import { createMemo, createSignal, onMount, sharedConfig, splitProps, untrack }
44
import { isServer } from "solid-js/web";
55

66
/**
7+
* What a `clientOnly` loader can resolve to: a module with a default export,
8+
* or the component itself.
9+
*/
10+
type ClientOnlyModule<T> = T | { default: T };
11+
12+
/**
13+
* Renders a component only on the client. The loader can resolve to a module
14+
* with a default export, or to the component itself, which also works for a
15+
* named export:
16+
*
17+
* ```tsx
18+
* const Map = clientOnly(() => import("./map"));
19+
* const Chart = clientOnly(() => import("./chart").then(({ Chart }) => Chart));
20+
* ```
21+
*
22+
* Destructuring the export inside the loader, as in the second example, lets
23+
* the bundler leave the module's other exports out of the chunk.
724
*
825
* Read more: https://docs.solidjs.com/solid-start/reference/client/client-only
926
*/
1027
// not using Suspense
1128
export default function clientOnly<T extends Component<any>>(
12-
fn: () => Promise<{
13-
default: T;
14-
}>,
29+
fn: () => Promise<ClientOnlyModule<T>>,
1530
options: { lazy?: boolean } = {},
1631
) {
1732
if (isServer) return (props: ComponentProps<T> & { fallback?: JSX.Element }) => props.fallback;
@@ -36,11 +51,10 @@ export default function clientOnly<T extends Component<any>>(
3651
};
3752
}
3853

39-
function load<T>(
40-
fn: () => Promise<{
41-
default: T;
42-
}>,
43-
setComp: Setter<T>,
54+
function load<T extends Component<any>>(
55+
fn: () => Promise<ClientOnlyModule<T>>,
56+
setComp: Setter<T | undefined>,
4457
) {
45-
fn().then(m => setComp(() => m.default));
58+
// A module namespace object is never a function, and a component always is.
59+
fn().then(m => setComp(() => (typeof m === "function" ? m : m.default)));
4660
}

0 commit comments

Comments
 (0)