Skip to content

feat(1.x): accept a clientOnly loader that resolves to the component - #2347

Open
lxsmnsyc wants to merge 1 commit into
1.xfrom
feat/client-only-component-loader-1x
Open

lxsmnsyc wants to merge 1 commit into
1.xfrom
feat/client-only-component-loader-1x

Conversation

@lxsmnsyc

Copy link
Copy Markdown
Member

Closes #2064 on the 1.x line. Same change as #2346 on main.

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")).

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.

Tree-shaking on 1.x

#2346 explains why this is a loader form rather than an export option: an option read with a computed key inside clientOnly keeps every export of the module. On 1.x there is one more caveat. In the apps/tests production build (Vite 6.3.6, Rollup 4.52):

Loader form Unused export of the module
.then(({ X }) => X) dropped
async () => { const { X } = await import(...); return X; } dropped
async () => (await import(...)).X dropped
.then(m => m.X) kept

Vite 6 compiles import(...).then(m => m.X) to preload(() => import(...), deps).then(m => m.X), so the .then sits outside the preload wrapper and Rollup cannot see which export it reads. A standalone Vite 6 library build, which has no preload wrapper, does tree-shake that form, so the cause is the wrapper and not Rollup. Vite 8 on main puts the whole chain inside the wrapper and tree-shakes every form. So the docs, the changeset and the tests use the destructured form, which tree-shakes on both lines.

Testing

  • apps/tests:
    • New /client-only/named route and cypress/e2e/client-only.cy.ts. The test checks that the server HTML has the fallback and not the component, and that the component renders after hydration. It fails without this change, because the component never renders. The default-export route is already covered by hydration.cy.ts.
    • New build-output test treeshaking/client-only.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 5.9.2 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.
  • @solidjs/start test:ci (95 tests), typecheck and build pass. apps/tests unit:ci (2) and the full Cypress suite against the production build (20) 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(({ Chart }) => Chart))

Destructuring the export inside the loader lets the bundler leave the
module's other exports out of the chunk. On Vite 6, which 1.x uses, the
.then(m => m.Chart) form keeps every export, because Vite's preload
wrapper ends up between import() and .then.

Closes #2064.

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

changeset-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ac130a5

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@2347

commit: ac130a5

@lxsmnsyc

Copy link
Copy Markdown
Member Author

This also covers #1929 on 1.x: a loader can now resolve a component defined in the same file, for example clientOnly(async () => ClientOnlyComponent). #2346 closes #1929 when it merges into main, since a PR into 1.x doesn't close issues automatically.

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.

2 participants