Skip to content

feat(client-only): accept a loader that resolves to the component - #2346

Open
lxsmnsyc wants to merge 3 commits into
mainfrom
feat/client-only-component-loader
Open

lxsmnsyc wants to merge 3 commits into
mainfrom
feat/client-only-component-loader

Conversation

@lxsmnsyc

@lxsmnsyc lxsmnsyc commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Closes #2064. Closes #1929. The 1.x version is #2347.

clientOnly only accepted a module with a default export, so a named export needed import("./chart").then(m => ({ default: m.Chart })). A loader can now resolve to the component itself:

const Chart = clientOnly(() => import("./chart").then(({ Chart }) => Chart));

Default exports work as before: clientOnly(() => import("./map")).

This also covers #1929, a component defined in the same file:

function ClientOnlyComponent() {
  return <div>{window.location.href}</div>;
}
export default clientOnly(async () => ClientOnlyComponent);

The component's file is still part of the server bundle, so this only fits when the file's top-level code is safe on the server. Browser-only imports still need a separate file, as Ryan explained in #1929.

Why not an export option

The issue proposes clientOnly(() => import("./chart"), { export: "Chart" }). I measured both shapes with a Vite 8 (Rolldown) build of a module with a used export A, an unused export B, and a default export:

Shape A B default
{ export: "A" }, read as m[key] inside clientOnly kept kept kept
import(...).then(m => m.A) kept dropped dropped
import(...).then(({ A }) => A) kept dropped dropped
async () => (await import(...)).A kept dropped dropped
clientOnly(() => import(...)) (default, today) kept kept kept

A computed key read inside clientOnly hides which export is used, so the bundler keeps the whole module. Reading the export inside the loader keeps tree-shaking. This matters for the case the issue mentions, a component exported next to a route: with an export option, the whole route module would end up in the client-only chunk. The last row also shows that today's default form keeps every export of the imported module, which might be worth a note in the docs.

Why the docs use the destructured form

On Vite 6, which the 1.x line uses, the .then(m => m.A) form keeps every export in an app build. Vite 6 compiles it to preload(() => import(...), deps).then(m => m.A), so .then sits outside the preload wrapper and Rollup cannot see which export it reads. Vite 8 on main puts the whole chain inside the wrapper (preload(() => import(...).then(m => m.A), deps)) and tree-shakes every form. The destructured .then(({ A }) => A) form tree-shakes on both, so the JSDoc, the changeset and the tests use it. Details are in #2347.

Change

  • The loader type is () => Promise<T | { default: T }>. load() uses the result directly when it is a function, and its default otherwise. A module namespace object is never a function and a component always is, so the two cannot be confused.
  • The existing { default } form, the explicit clientOnly<typeof X>(...) generic, and the { default: m.Chart } workaround keep working.
  • JSDoc shows both forms.

Testing

  • apps/tests:
    • New /client-only/named route and e2e/client-only.test.ts. For both the existing default-export route and the named one, the test checks that the server HTML has the fallback and not the component, and that the component renders after hydration. The named test fails without this change, because the component never renders.
    • New build-output test treeshaking/client-only.server.test.ts: the named export's code is in the client bundle, and the module's unused export is not.
  • Types, checked locally with tsc against a temporary file: props are inferred for the default, .then(m => m.X), destructured and async forms. m.Missing is an error, and so are wrong or missing props. The explicit generic and the { default } workaround compile. I did not commit this as a test, because packages/start's tsconfig either skips spec files or would emit them into dist.
  • @solidjs/start test:ci (237 tests), typecheck, validate-imports and build pass. apps/tests unit tests (7), Playwright (44) and bundled-dev Playwright (44) pass. After switching to the destructured form, the build, unit tests and the client-only and hydration e2e tests were rerun and pass.

🤖 Generated with Claude Code

clientOnly only accepted a module with a default export, so a named
export needed import(...).then(m => ({ default: m.Chart })). A loader can
now resolve to the component itself, which also works for named exports:

  clientOnly(() => import("./chart").then(m => m.Chart))

Reading the export inside the loader lets the bundler leave the module's
other exports out of the chunk. An option that names the export, read
with a computed key inside clientOnly, would keep every export.

Closes #2064.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for solid-start-landing-page ready!

Name Link
🔨 Latest commit aec0c9e
🔍 Latest deploy log https://app.netlify.com/projects/solid-start-landing-page/deploys/6acef5a8613cf991a4351fd2
😎 Deploy Preview https://deploy-preview-2346--solid-start-landing-page.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aec0c9e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/start Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@solidjs/start@2346

commit: aec0c9e

Use import(...).then(({ Chart }) => Chart) in the JSDoc, the changeset
and the test route. On Vite 6, which the 1.x line uses, the
.then(m => m.Chart) form keeps every export of the module, because
Vite's preload wrapper ends up between import() and .then. The
destructured form tree-shakes on both Vite 6 and Vite 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@brenelz brenelz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved but looks like the tests failed?

The default-export case visited /client-only. In bundled dev mode, once
/client-only has been visited, its route module is no longer fetched
lazily, and hydration-navigation.test.ts waits for that request, so it
timed out when it ran after this file. Test the default export on its
own /client-only/default route instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature?]: clientOnly dx for named exports Use clientOnly without isolating component in its own file

2 participants