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.
12 changes: 12 additions & 0 deletions apps/tests/cypress/e2e/client-only.cy.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
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>;
}
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>} />;
}
20 changes: 20 additions & 0 deletions apps/tests/src/routes/treeshaking/client-only.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
32 changes: 23 additions & 9 deletions packages/start/src/shared/clientOnly.tsx
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 @@ -34,11 +49,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