Skip to content

fix(rsc): reject client-only only when it survives RSC tree-shaking - #4011

Closed
james-elicx wants to merge 1 commit into
mainfrom
fix/rsc-client-only-tree-shaking
Closed

james-elicx wants to merge 1 commit into
mainfrom
fix/rsc-client-only-tree-shaking

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Fixes #4007

Problem

A Server Component that imports one export from a sideEffects: false barrel (e.g. import { Toast } from "@heroui/react") failed vite build:

[plugin rsc:validate-imports] .../react-aria-components/dist/exports/Calendar.mjs
Error: 'client-only' cannot be imported in server build ('rsc' environment)

The page never uses Calendar; the barrel just reaches it.

Root cause

@vitejs/plugin-rsc's rsc:validate-imports checks the client-only import chain in buildEnd, which runs before tree-shaking. So any client-only import reachable through re-exports fails the build, even when the bundler drops the module and its marker from the output.

What Next.js does

I checked the reporter's repro against real next@16.2.7:

Bundler Result
Turbopack next build (default) builds, serves / with 200
Turbopack next dev serves / with 200
webpack next build --webpack fails with 'client-only' cannot be imported from a Server Component module

Turbopack maps client-only in the server graph to an import error, and reports it only for modules that stay in the tree-shaken graph (crates/next-core/src/next_import_map.rs, insert_client_only_error_alias). This PR follows the default (Turbopack) behavior.

Fix

New build-only plugin, vinext:rsc-client-only-imports:

  • Runs ahead of plugin-rsc and resolves client-only in the rsc environment to a marker module with side effects.
  • In generateBundle, fails the build only if that marker is still in the emitted RSC chunks.
  • The error keeps plugin-rsc's message format. The import chain prefers importers that are still in the output, including dynamic importers, so it names the import that kept the marker rather than a dropped leaf.
  • Skips plugin-rsc's scan build.
  • Is disabled when rsc({ validateImports: false }) removes rsc:validate-imports.

Real misuse still fails the build:

  • a used module that imports client-only;
  • a package without sideEffects: false, whose module is kept for its side effects;
  • a CommonJS require("client-only");
  • a dynamic import("client-only");
  • a direct import from application code.

Dev is unchanged: Vite dev doesn't tree-shake, so plugin-rsc's dev check still applies.

Tests

  • tests/client-only-tree-shaking.test.ts builds a tmp App Router fixture with local packages that copy both shapes from the issue:

    • an unused barrel leaf that imports client-only (the Calendar shape);
    • a module that imports client-only and only re-exports a binding, inside a package with sideEffects: ["*.css"] (the react-aria Toast.mjs shape).

    The positive case builds, starts the prod server and asserts that both components render. Five negative cases assert that the build still fails, each with the expected import chain.

  • tests/fixtures/cf-app-basic gets a checked-in sideEffects: false barrel package and a /client-only-barrel page. tests/e2e/cloudflare-encoded-paths/client-only-barrel.spec.ts checks that page on the Workers build.

Validation

  • vp test run tests/client-only-tree-shaking.test.ts: 6/6 pass. Without the fix, the positive case fails with plugin-rsc's error.
  • vp test run tests/middleware-server-only.test.ts: passes.
  • cf-app-basic:
    • without the fix, vp build fails with rsc:validate-imports on client-only-barrel/widget.js;
    • with the fix, the new Playwright spec passes (cloudflare-encoded-paths project, wrangler dev).
  • The reporter's repro (@heroui/react 3.2.1) fails without the fix; with the fix it builds and the prod server returns 200 with the page content.
  • vp check passes on the changed files.

Known limitation (unchanged by this PR)

vinext dev on the reporter's repro still returns 500, with a different error: createContext is not a function. Vite dev evaluates the whole @heroui/react barrel under react-server React. That is unrelated to the client-only check, and experimental.optimizePackageImports is the existing lever for barrel evaluation in dev.

@vitejs/plugin-rsc's rsc:validate-imports checks the client-only import
chain in buildEnd, before tree-shaking, so a Server Component importing
one export from a sideEffects:false barrel failed the build when an
unused leaf imported client-only (e.g. @heroui/react -> react-aria
Calendar). Claim client-only in the RSC build and fail only when the
marker module is still in the emitted RSC chunks, matching Turbopack,
which reports the client-only import error only for modules kept in the
tree-shaken graph.

Fixes #4007
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 192f2876b25be3faf4e7be65dacf0efdee0322d5 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@pkg-pr-new

pkg-pr-new Bot commented Oct 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@4011

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@4011

@vinext/types

npm i https://pkg.pr.new/@vinext/types@4011

vinext

npm i https://pkg.pr.new/vinext@4011

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@4011

commit: 192f287

@ask-bonk

ask-bonk Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 192f287 against base abfeeac using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 145.2 KB 145.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 132.8 KB 132.8 KB ⚫ 0.0%
Dev server cold start vinext 2.51 s 2.52 s ⚫ +0.2%
Dev server cold start (large Pages Router app) vinext 21.89 s 21.96 s ⚫ +0.3%
Production build time vinext 2.75 s 2.73 s ⚫ -0.8%
RSC entry closure size (gzip) vinext 135.6 KB 135.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 240.1 KB 240.1 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx
james-elicx marked this pull request as ready for review October 15, 2026 11:16
@james-elicx

Copy link
Copy Markdown
Member Author

Closing in favour of the upstream fix in @vitejs/plugin-rsc: vitejs/vite-plugin-react#1511

That PR moves rsc:validate-imports' build check to after tree-shaking, which fixes the root cause of #4007 in plugin-rsc itself. It also covers the server-only-from-"use client" mirror case and Rollup (Vite 7), which this PR didn't. Once it's released, vinext can raise its minimum @vitejs/plugin-rsc version so users get the fix.

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.

validate-imports false positive on sideEffects:false barrels (unused leaf kills build)

1 participant