Skip to content

docs(rsc): document barrel package workaround for validate-imports - #1519

Merged
hi-ogawa merged 4 commits into
mainfrom
test/rsc-barrel-transform-imports
Oct 20, 2026
Merged

hi-ogawa merged 4 commits into
mainfrom
test/rsc-barrel-transform-imports

Conversation

@hi-ogawa

@hi-ogawa hi-ogawa commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

A server component that imports one component from a UI library's barrel entry, such as import { Toast } from "@heroui/react", fails with 'client-only' cannot be imported in server build when an unused module the barrel re-exports imports client-only. #1511 moves the build check after tree-shaking, but dev still fails because Vite evaluates every module the barrel re-exports. In the heroui case, react-aria's top-level createContext crashes dev even when the check is disabled.

Rewriting barrel imports to subpath imports fixes both dev and build without changes to the plugin. This PR adds a README section on barrel packages that shows how to do it with @rolldown/plugin-transform-imports, lists the gotchas I hit, and points to a "use client" component when a subpath still reaches client-only code.

transformImports({
  'some-ui-library': {
    transform: [
      ['[A-Z].*', 'some-ui-library/{{kebabCase member}}'],
      ['*', 'some-ui-library'],
    ],
    skipDefaultConversion: true,
  },
})

The e2e test uses a synthetic sideEffects: false barrel package where a server component uses Button and an unused Calendar imports client-only:

dev build
without transform fails fails
with transform (Rolldown only) renders renders

For @heroui/react, I checked this setup on the reporter's repro with vinext 1.2.0 and plugin-rsc 0.5.38. Dev renders, and the build passes and renders in preview:

// vite.config.ts
import transformImports from '@rolldown/plugin-transform-imports'

export default defineConfig({
  plugins: [
    transformImports({
      '@heroui/react': {
        transform: [
          // `Skeleton` -> `@heroui/react/skeleton`
          ['[A-Z].*', '@heroui/react/{{kebabCase member}}'],
          ['*', '@heroui/react'],
        ],
        skipDefaultConversion: true,
      },
    }),
    tailwindcss(),
    vinext(),
  ],
})
// app/page.tsx (server component)
import { Skeleton } from '@heroui/react'
import { ToastProvider } from './toast-client'

export default function Page() {
  return (
    <main>
      <Skeleton />
      <ToastProvider />
    </main>
  )
}
// app/toast-client.tsx
'use client'
import { Toast } from '@heroui/react'

export function ToastProvider() {
  return <Toast.Provider />
}

Skeleton works through the barrel with the transform, because its subpath entry only reaches a "use client" module.

Toast is different, because the import that fails is inside heroui, so rewriting app imports can't reach it. In heroui 3.2.1 and react-aria-components 1.18.0, the chain is:

  1. components/toast/index.js has no directive, so it is server code. It imports toast-queue.js to build the compound Toast object.
  2. components/toast/toast-queue.js has no directive either. It imports UNSTABLE_ToastQueue from react-aria-components/Toast and creates a toastQueue instance at the module's top level.
  3. react-aria-components/dist/exports/Toast.mjs has import "client-only", and it also imports the toast components, whose module calls createContext at the top level. That call throws under the react-server condition, which is the dev error.

The queue class itself comes from react-stately/useToastState and is safe on the server, but heroui imports it through react-aria-components' client-only entry. Tools disagree on this graph. Vite dev evaluates the entry and crashes, and Next's webpack build reports client-only. Next's Turbopack follows the re-export to react-stately and never evaluates the entry, and Rolldown drops the entry from the output, which is why the build passes with #1511. vinext#4099 gets Turbopack's result in Vite by rewriting heroui's import to react-stately in the rsc environment.

Which behavior is right is debatable:

  • Rejecting it follows the packages' intent. react-aria-components put import "client-only" on the Toast entry on purpose, with a comment saying it should break RSC imports. heroui imports that entry from modules with no directive, which run on the server. Rejecting matches ESM evaluation and webpack, and it keeps dev and build consistent. The lenient result relies on the package's "sideEffects": ["*.css"], which declares the entry side-effect free even though its client-only import is a side effect by design. Under that reading, the marker can't fire for any named import. The entry defines nothing of its own, since all eight of its exports are re-exports plus the client-only import, so following re-exports skips it even for the components the marker is meant to keep out of rsc. Only a namespace, bare or dynamic import of the entry still hits it.
  • Accepting it follows what actually runs. The only binding used is a plain state class that is safe on the server, so the marker covers more than it needs to. sideEffects explicitly allows skipping modules whose own exports are unused, and Turbopack, Next.js's default bundler, renders this page. But following re-exports is no safety net: a server component importing Collection from react-aria-components/Collection fails with createContext is not a function in Turbopack, a less clear error than client-only.

This PR keeps plugin-rsc's current behavior, rejecting it in both dev and build, and documents the "use client" wrapper as the user-side fix. The cleaner fix is upstream, either heroui importing ToastQueue from react-stately/useToastState or react-aria-components exporting the queue from an entry without the marker.

Add e2e coverage with a synthetic `sideEffects: false` barrel package whose
unused leaf imports `client-only`, which fails dev and build, and passes
both with `@rolldown/plugin-transform-imports` rewriting to subpaths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hi-ogawa and others added 3 commits October 19, 2026 14:41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hi-ogawa
hi-ogawa marked this pull request as ready for review October 19, 2026 05:54
ivogt added a commit to rangojs/rango that referenced this pull request Oct 19, 2026
…overy

plugin-rsc's README recommends @rolldown/plugin-transform-imports when a
server component imports from a barrel package whose other module imports
client-only (vitejs/vite-plugin-react#1519). Rango's discovery temp server
forwarded only resolveId/load user plugins, so the rewrite never ran there:
`vite build` failed with "[rango] Build-time router discovery failed:
'client-only' cannot be imported in server build", and Cloudflare-preset dev
answered 500 on every request.

Forward that plugin by name (IMPORT_REWRITE_PLUGINS) with its transform hook.
Other transform-only plugins are still dropped; the list's JSDoc states the
rule for adding one.

Tests: a forward-user-plugins unit test, and a barrel-imports e2e (dev and
production) in the router test-app and cloudflare-basic, backed by a
rango-e2e-barrel-lib test package.
@hi-ogawa
hi-ogawa merged commit 4ffd181 into main Oct 20, 2026
25 checks passed
@hi-ogawa
hi-ogawa deleted the test/rsc-barrel-transform-imports branch October 20, 2026 00:44
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.

1 participant