Skip to content

fix(optimize-imports): support @heroui/react in Server Components - #4099

Merged
james-elicx merged 4 commits into
mainfrom
feat/optimize-imports-subpaths
Oct 19, 2026
Merged

james-elicx merged 4 commits into
mainfrom
feat/optimize-imports-subpaths

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Fixes #4007

Summary

A Server Component that imports one component from @heroui/react, such as import { Toast } from "@heroui/react", failed in dev (createContext is not a function) and in the RSC build ('client-only' cannot be imported in server build). The barrel's unused leaves reach react-aria code that calls createContext() at module scope or imports client-only.

Two changes to vinext:optimize-imports:

  • @heroui/react is now in the default optimized packages, so its barrel imports are rewritten to the component modules. This fixes Skeleton and most other components.
  • Toast still failed, because heroui's own toast-queue.js imports UNSTABLE_ToastQueue from react-aria-components/Toast, and that subpath entry is itself a barrel with the same problem. In the RSC environment, subpath imports of react-aria-components are now rewritten to the modules that define the imported names, so heroui's toast queue imports ToastQueue from react-stately directly. This drops the subpath entry's import "client-only", as Next.js's default Turbopack build does: server-safe exports work, and a client component like Collection still fails, when its own module calls createContext(). The subpath is resolved through Vite from the importer, and the entry's package imports are resolved from the entry, because react-aria-components can carry its own nested copy of react-stately at a different version than the app's. SSR and the client keep the package's own module graph, so React contexts are never instantiated twice there.

So heroui's internal files are transformed in dev too, the transform filter now accepts ids with Vite's ?v= query, as the build already did.

Re-exports are no longer followed through modules with a "use client", "use server" or "use cache" directive: names exported by such a module are imported from the module itself. Before, an export * into a "use client" file could flatten past its client reference. In RSC, "use client" modules are skipped, since plugin-rsc replaces them with client references.

Next.js comparison

Each cell is dev / build for a page whose only import is the one in the row (Next.js 16.2.7, @heroui/react 3.2.1, react-aria-components 1.18.0). "client-only" is the 'client-only' cannot be imported error; "createContext" is createContext is not a function.

Server Component imports Next webpack Next webpack + optimizePackageImports: ["@heroui/react"] Next Turbopack (default) vinext main vinext with only the heroui default (first commit) vinext with this PR
{ UNSTABLE_ToastQueue } from react-aria-components/Toast client-only / client-only (same as webpack) renders / renders createContext / client-only createContext / client-only renders / renders
{ Collection } from react-aria-components/Collection client-only / client-only (same as webpack) createContext / build fails: createContext client-only / client-only client-only / client-only createContext / build passes, request fails: createContext
{ Toast } from @heroui/react, rendering <Toast.Provider /> (#4007) client-only / client-only client-only / client-only renders / renders createContext / client-only createContext / client-only renders / renders
{ Skeleton } from @heroui/react client-only / client-only renders / renders renders / renders createContext / client-only renders / renders renders / renders

Testing

  • Unit tests in tests/optimize-imports.test.ts for subpath rewriting (nested package resolution, ?v= ids, SSR/client untouched, unresolvable sources, directive boundaries).
  • On the validate-imports false positive on sideEffects:false barrels (unused leaf kills build) #4007 repro (heroui 3.2.1): Server Component pages using Skeleton, Toast.Provider directly, and Toast.Provider with a client button calling toast() return 200 and hydrate without console errors in dev and after vinext build. The prod build used to fail. In prod, clicking the button shows the toast. No heroui or react-aria module is loaded twice in the rsc or ssr module graphs.
  • Cost, measured in-process: about +7.5 ms per cold dev load of the repro (hook plus filter time, on a ~3.8 s load) and +1.7 ms on app-basic. Build time is unchanged.

Known limitations

  • In dev, rendering heroui's Toast.Provider directly from a Server Component and calling toast() from a client component shows no toast. plugin-rsc serves the provider's internal "use client" file unbundled while the client component uses the pre-bundled @heroui/react, so the two use different toast queues. plugin-rsc warns about this ("client component dependency is inconsistently optimized") and recommends optimizeDeps.exclude: ["@heroui/react"]. Production is not affected.
  • Subpath rewriting is limited to react-aria-components. Barrels that are not listed in optimizePackageImports still fail as before.
  • A Server Component importing a react-aria-components client component such as Collection now fails with createContext is not a function (at request time in a production build) instead of the client-only build error, the same error Next.js's Turbopack build gives.

Unused leaves of the @heroui/react barrel reach react-aria code that calls
createContext() at module scope or imports client-only, which failed Server
Components importing from the barrel in dev and in the RSC build (#4007).
…in RSC

heroui's Toast reaches react-aria-components/Toast, whose other exports
call createContext() at module scope and import client-only. Rewrite RSC
subpath imports of react-aria-components to the modules that define the
imported names, resolving through Vite from the importer and the entry.
Re-exports are not followed through use client/use server/use cache
modules, and use client modules are skipped in RSC.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: 06edc50

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

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

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: cff6c2c

const isSubpath =
!packages.has(importSource) &&
preferReactServer &&
isOptimizedSubpath(importSource, SUBPATH_OPTIMIZE_PACKAGES);

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.

This broad match also rewrites direct user imports such as import { Collection } from "react-aria-components/Collection" in a Server Component. The published Collection.mjs entry intentionally imports "client-only" (its comment says this should cause a build-time error from an RSC), but this transform follows the re-export to react-aria/Collection and drops that guard. That changes Next.js-compatible behavior for every RAC subpath in order to fix one server-safe Toast queue export. Please scope this path to the known HeroUI/ToastQueue case (for example by importer and exported name), so arbitrary RAC client entries retain their client-only error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2999215: the RSC subpath rewrite now only applies to UNSTABLE_ToastQueue from react-aria-components/Toast (the server-safe react-stately class heroui's toast queue imports). Any other export, namespace import or RAC subpath is left unchanged, so its client-only guard still fails a Server Component import. Added a test for UNSTABLE_Toast, a mixed import, a namespace import and react-aria-components/Collection.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Went back to following all react-aria-components subpath exports in cff6c2c, after checking real Next.js. Next's default Turbopack build follows re-exports out of these side-effect-free entries and drops their client-only import too: UNSTABLE_ToastQueue renders, and Collection fails with createContext is not a function, not the client-only error. Webpack keeps the guard, but it also fails the #4007 repro itself, even with optimizePackageImports. The export allowlist was stricter than Next's default build.

Each cell is dev / build for a page whose only import is the one in the row (Next.js 16.2.7, @heroui/react 3.2.1, react-aria-components 1.18.0). "client-only" is the 'client-only' cannot be imported error; "createContext" is createContext is not a function.

Server Component imports Next webpack Next webpack + optimizePackageImports: ["@heroui/react"] Next Turbopack (default) vinext main vinext with only the heroui default (first commit) vinext with this PR
{ UNSTABLE_ToastQueue } from react-aria-components/Toast client-only / client-only (same as webpack) renders / renders createContext / client-only createContext / client-only renders / renders
{ Collection } from react-aria-components/Collection client-only / client-only (same as webpack) createContext / build fails: createContext client-only / client-only client-only / client-only createContext / build passes, request fails: createContext
{ Toast } from @heroui/react, rendering <Toast.Provider /> (#4007) client-only / client-only client-only / client-only renders / renders createContext / client-only createContext / client-only renders / renders
{ Skeleton } from @heroui/react client-only / client-only renders / renders renders / renders createContext / client-only renders / renders renders / renders

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline comment. Targeted tests pass.

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

…h export

RAC subpath entries import client-only on purpose, so following any
subpath export dropped that guard for client components imported from a
Server Component. Limit the RSC subpath rewrite to UNSTABLE_ToastQueue
from react-aria-components/Toast, which heroui's toast queue needs.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: 2999215

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 0 new.

LGTM!

github run

…ts again

This reverts the export allowlist from 2999215. Next.js's default
Turbopack build follows re-exports out of react-aria-components'
side-effect-free subpath entries and drops their client-only import, so
server-safe exports work and client components still fail when their own
modules run. The allowlist was stricter than Next.js.
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared cff6c2c against base da0f859 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 154.2 KB 154.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ +0.0%
Dev server cold start vinext 2.91 s 2.87 s 🟢 -1.7%
Dev server cold start (large Pages Router app) vinext 27.43 s 27.28 s ⚫ -0.5%
Production build time vinext 3.68 s 3.70 s ⚫ +0.6%
RSC entry closure size (gzip) vinext 142.0 KB 142.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 250.6 KB 250.6 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: cff6c2c

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Since last review: 0 resolved, 0 still open, 0 new.

LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review October 19, 2026 10:02
@clanky-the-clanker

Copy link
Copy Markdown

💬 Approved with comments — The subpath rewrite and directive-boundary handling look correct for the cases traced. Only an informational note on a no-op rewrite path and a small test cleanup remain.

Findings: 1 info · 1 suggestion

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #4099.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## INFO (1)

### [Code Quality] The "gains nothing from a rewrite" guard applies only when `isSubpath` is true. A regular optimized package with a "use client" barrel entry is now rewritten to its own absolute entry path in every environment, and SSR no longer flattens it.
- **File:** packages/vinext/src/plugins/optimize-imports.ts:953

## SUGGESTION (1)

### [Code Quality] The test "optimizes react-aria-components subpaths by default" only checks a constant, and `heroToastQueue` has a leftover `${"/app"}` interpolation.
- **File:** tests/optimize-imports.test.ts

📋 Code Quality: 💬 1 info · 1 suggestion

The subpath rewrite and directive-boundary logic look correct. A "use client" barrel entry can now be rewritten to itself, and one new test is vacuous.

Recommendation: 💬 Optional Nits

  • [INFO] The "gains nothing from a rewrite" guard applies only when isSubpath is true. A regular optimized package with a "use client" barrel entry is now rewritten to its own absolute entry path in every environment, and SSR no longer flattens it. (packages/vinext/src/plugins/optimize-imports.ts:953)
  • [SUGGESTION] The test "optimizes react-aria-components subpaths by default" only checks a constant, and heroToastQueue has a leftover ${"/app"} interpolation. (tests/optimize-imports.test.ts)
View full review
  • A barrel entry with a boundary directive is rewritten to itself (packages/vinext/src/plugins/optimize-imports.ts, around lines 949-958)

    • The export map now maps every name in a module that starts with "use client", "use server" or "use cache" to that module.
    • The export-map cache is shared between the rsc and ssr environments.
    • Suppose a package listed in optimizePackageImports has a barrel entry with one of these directives. Every import from it then resolves to source === barrelEntry.
    • The "gains nothing from a rewrite" guard runs only when isSubpath is true. A regular package import therefore gets rewritten to import { X } from "<absolute barrel path>". That is a no-op, except that it swaps the bare specifier for an absolute file id. SSR also loses the flattening it used to get for such barrels.
    • Fix: drop the isSubpath && condition so the guard covers every package, or apply the boundary collapse only in RSC.
    • No transform-level test covers this case: a regular optimized package whose barrel entry has "use client".
  • Weak test and leftover test code (tests/optimize-imports.test.ts)

    • optimizes react-aria-components subpaths by default only checks that a constant contains a string. The transform tests already cover this behavior.
    • heroToastQueue is built with ${"/app"}/node_modules/..., a leftover interpolation of a constant string.

✅ All other checks passed

🔒 Security: ✅ LGTM

No security issues found in the import-rewriting changes.


📖 AGENTS.md: ✅ LGTM

No issues found.


⚡ Performance: ✅ LGTM

No performance regressions found.

  • ctx.resolve runs once per matching subpath import, only in the RSC environment.
  • Export maps for subpath entries go through the existing buildBarrelExportMap cache.
  • The wider transform filter, (?:\?v=\w+)?, is still a native filter, and the code regex still gates every file before parsing.
  • findBoundaryDirective stops at the first statement that is not a directive.

📝 Documentation: ✅ LGTM

No documentation issues found.


Reviewed commit: cff6c2c1

@james-elicx
james-elicx merged commit 3aad320 into main Oct 19, 2026
106 checks passed
@james-elicx
james-elicx deleted the feat/optimize-imports-subpaths branch October 19, 2026 10:09
james-elicx added a commit that referenced this pull request Oct 19, 2026
* fix(optimize-imports): only stop at use client modules in RSC

#4099 made every directive module the import source for the names it
exports, in both server environments. Record the boundary per environment
instead: RSC stops at any directive, while SSR only stops at use server and
use cache modules, which plugin-rsc and vinext replace with references there.
SSR again rewrites imports past use client barrels such as antd's entry.

Also skip the rewrite for any package whose entry defines every imported
name, not just react-aria-components subpaths, so imports from a use client
entry keep their bare specifier in RSC.

* test(optimize-imports): e2e for SSR rewrites past a use client barrel

* style: sort fixture package.json keys

* test: keep the client barrel route out of the Workers runtime fixture

* fix(optimize-imports): register and rewrite by the environment's sources

* fix(optimize-imports): resolve sub-packages from the environment's own registrations
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