Skip to content

Commit 92d9603

Browse files
lxsmnsycclaude
andauthored
feat(1.x): accept a clientOnly loader that resolves to the component (#2347)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 273496b commit 92d9603

6 files changed

Lines changed: 83 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
describe("clientOnly", () => {
2+
it("renders a named export when the loader resolves to the component", () => {
3+
cy.request("/client-only/named").then(response => {
4+
expect(response.body).to.contain("CLIENT_ONLY_FALLBACK");
5+
expect(response.body).not.to.contain("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
6+
});
7+
8+
cy.visit("/client-only/named");
9+
cy.get("#client-only-named").contains("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
10+
cy.get("#client-only-fallback").should("not.exist");
11+
});
12+
});
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: 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { readFile, readdir } from "node:fs/promises";
2+
import path from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
// clientOnly(() => import("./_named").then(({ NamedClientOnly }) => NamedClientOnly))
6+
// reads the export inside the loader, so the bundler can leave the module's other
7+
// exports out of the client bundle (#2064).
8+
describe("clientOnly with a named export", () => {
9+
it("bundles only the export the loader reads", async () => {
10+
const buildDir = path.resolve(process.cwd(), ".vinxi/build/client/_build/assets");
11+
const files = (await readdir(buildDir)).filter(file => file.endsWith(".js"));
12+
const contents = await Promise.all(
13+
files.map(file => readFile(path.join(buildDir, file), "utf-8"))
14+
);
15+
const client = contents.join("\n");
16+
17+
expect(client).toContain("CLIENT_ONLY_NAMED_EXPORT_RENDERED");
18+
expect(client).not.toContain("CLIENT_ONLY_UNUSED_EXPORT_MARKER");
19+
});
20+
});

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

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;
@@ -34,11 +49,10 @@ export default function clientOnly<T extends Component<any>>(
3449
};
3550
}
3651

37-
function load<T>(
38-
fn: () => Promise<{
39-
default: T;
40-
}>,
41-
setComp: Setter<T>
52+
function load<T extends Component<any>>(
53+
fn: () => Promise<ClientOnlyModule<T>>,
54+
setComp: Setter<T | undefined>
4255
) {
43-
fn().then(m => setComp(() => m.default));
56+
// A module namespace object is never a function, and a component always is.
57+
fn().then(m => setComp(() => (typeof m === "function" ? m : m.default)));
4458
}

0 commit comments

Comments
 (0)