Skip to content

fix(optimize-imports): only stop at use client modules in RSC - #4102

Merged
james-elicx merged 6 commits into
mainfrom
fix/optimize-imports-ssr-boundaries
Oct 19, 2026
Merged

james-elicx merged 6 commits into
mainfrom
fix/optimize-imports-ssr-boundaries

Conversation

@james-elicx

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

Copy link
Copy Markdown
Member

Follow-up to #4099, from the review notes on that PR.

Summary

#4099 stopped vinext:optimize-imports from following re-exports through modules with a "use client", "use server" or "use cache" directive, by making such a module the import source for every name it exports. The export map is shared between the rsc and ssr environments, so this also applied in SSR, where "use client" has no effect.

antd 6 is on the default list, and its entry (es/index.js) starts with "use client". Since #4099, a server-side import { Button } from "antd":

  • In SSR, is rewritten to antd's absolute entry instead of es/button/index.js, so dev loads the whole barrel again, which the default list is meant to avoid.
  • In RSC, is rewritten to the same absolute entry. That stays as it is: the entry is the client boundary, and an absolute id keeps the import in Vite's pipeline (Nitro dev externalizes bare imports outside plugin-rsc's noExternal list before resolveId runs).

Changes:

  • Export map entries keep their real source and record the outermost boundary module on their path, once per environment. RSC stops at any of the three directives. SSR stops only at "use server" and "use cache", since plugin-rsc and vinext replace those modules with references in SSR too. One cache still serves both environments.
  • The bare dependencies registered for resolveId come from this environment's sources too, so a package hidden behind a boundary isn't registered in RSC. resolveId also stops falling back to the other environment's registrations, which would otherwise bring SSR's back into RSC. Each environment registers every specifier it emits before emitting it, so the fallback only ever picked up specifiers this environment didn't emit.
  • Test cleanup: a leftover ${"/app"} interpolation in tests/optimize-imports.test.ts.

Testing

Unit tests in tests/optimize-imports.test.ts:

  • The export map records RSC and SSR boundaries through nested export *, including the outermost boundary winning.
  • Imports from a "use client" entry are rewritten to the entry in RSC and to the component module in SSR.
  • "use server" modules stay the import source in both RSC and SSR.
  • A package re-exported by a "use client" entry is resolved from the barrel in SSR but not in RSC, even after SSR has transformed the import.

The first two fail without this change. The third passes on main as well, and guards SSR's "use server" handling. The fourth covers registering leaf sources, which this change now preserves.

E2E in tests/e2e/app-router/optimize-imports-client-barrel.spec.ts, on app-basic in dev:

  • fake-client-barrel is a local file: package (__test_packages__/fake-client-barrel), added to optimizePackageImports. Its entry is a "use client" barrel, and its unused leaf sets a global when it is evaluated.
  • A "use client" page imports one component from it. The SSR HTML must show that the unused leaf never ran, and the component must hydrate and update.

The SSR check fails without this change; the hydration check passes either way. The pnpm-lock.yaml change only adds the fixture's file: dependency.

#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.
@james-elicx

Copy link
Copy Markdown
Member Author

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

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

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: f7be786

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared f7be786 against base ac3646f 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 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 1.92 s 1.94 s ⚫ +1.1%
Dev server cold start (large Pages Router app) vinext 16.04 s 16.17 s ⚫ +0.8%
Production build time vinext 2.21 s 2.20 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 142.1 KB 142.1 KB ⚫ -0.0%
Server bundle size (gzip) vinext 251.0 KB 251.0 KB ⚫ -0.0%

View detailed results and traces

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

@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

@james-elicx

Copy link
Copy Markdown
Member Author

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

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx

Copy link
Copy Markdown
Member Author

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

const isSsrBoundary = boundaryDirective !== "use client";
for (const [name, entry] of exportMap) {
exportMap.set(name, {
...entry,

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.

Preserving the leaf source here means the registration loop at lines 903-913 now registers bare dependencies hidden behind a directive boundary, even when this environment's effective source is the boundary module. In RSC, for example, a "use client" barrel that re-exports from a nested package causes resolveId to redirect unrelated direct imports of that package to the barrel's private copy (subpkgOrigin is global per environment and first-wins). Before this change the boundary replaced source, so that stale registration did not occur. Build this registration from the environment-effective entries (getEntry) so only sources this environment can actually emit are installed.

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 f5acbef: the registration loop now uses this environment's sources (getEntry), so a package behind a boundary isn't registered in RSC. Added a test that fails without it.

) {
// An entry that defines (or is the boundary for) every imported name
// gains nothing from a rewrite.
if (specifiers.every(({ imported }) => getEntry(imported)?.source === barrelEntry)) {

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 broader no-op guard also skips the absolute rewrite when the package entry itself is a "use server"/"use cache" boundary. In Nitro dev, bare server imports outside plugin-rsc's noExternal package list are externalized before resolveId/transform runs (index.ts documents this at lines 4110-4117), so such an optimized package can expose its raw implementation in SSR instead of a server reference. Keep the rewrite for server boundaries (or otherwise force configured optimized packages through Vite); only a client boundary in RSC is safe to leave bare for the case added here.

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 f5acbef: the no-op guard is back to subpath imports only, so a bare import from a boundary entry is rewritten to its absolute path again in both environments. The "use client" entry test now expects that rewrite in RSC.

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Review: 2 findings.

Posted 2 actionable inline warnings.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

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

for (const name of exportMap.keys()) {
// Only the sources this environment can emit, so a package behind
// a boundary isn't registered.
const source = getEntry(name)!.source;

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.

The per-environment filtering here is still bypassed by the resolveId cross-environment fallback at lines 785-786. After SSR transforms this use-client barrel it registers the bare leaf source here; a later RSC resolution misses its own map, reads that SSR entry, and resolves the package from the barrel location, potentially selecting the private nested copy instead of the app copy. On main, the shared boundary source prevented SSR from registering this leaf, so this is introduced by the split maps. Please prevent fallback entries that this environment cannot emit (or remove the cross-environment fallback), and cover the SSR-transform-then-RSC-resolve sequence.

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 f7be786: resolveId now looks up the map keyed like the transform (rsc or ssr) and no longer falls back to the other environment's. Every specifier an environment emits is registered in its own map first, so the fallback only ever served specifiers that environment didn't emit. The test now runs the SSR transform before the RSC one and checks that only SSR resolves from the barrel.

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Review: 1 finding.

Posted 1 actionable inline warning.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

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

// Sub-package specifiers are keyed per environment so that RSC and SSR
// barrel entries don't cross-contaminate each other's resolution context.
envOriginMap.set(entry.source, barrelEntry);
envOriginMap.set(source, barrelEntry);

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.

In SSR, this newly registers bare leaves behind a "use client" barrel, but the map is still global by source within that environment and resolveId does not inspect the importer. A module that imports { Slot } from an optimized barrel with a nested @radix-ui/react-slot and also imports @radix-ui/react-slot directly will therefore resolve both imports from the barrel location; likewise, the first of two barrels with different nested versions wins. On main the client boundary prevented this SSR registration, so this is newly reachable here. Resolve the emitted leaf to an absolute id during the transform, or scope the origin mapping to the transformed importer/edge, so unrelated SSR imports retain their own package resolution.

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

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.

Correction after testing it on a fixture: this can't happen as described. Vite's vite:resolve runs before this plugin's resolveId, so any bare import the importing module can resolve, including a direct import of @radix-ui/react-slot, is resolved from the importer and never reaches subpkgOrigin. The map only handles specifiers the importer can't resolve, which is the pnpm strict case it exists for. I checked with a barrel that re-exports its own copy of a package the app also depends on: the app's direct import got the app's copy. That leaves two optimized barrels that both re-export different versions of a package the app can't resolve itself, which applies on main too and isn't specific to this PR. Not changing this here.

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Review: 1 finding.

Posted 1 actionable inline warning.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

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

@ask-bonk

ask-bonk Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

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

Copy link
Copy Markdown

✅ Approved — No issues found. RSC and SSR now track their boundaries separately, which keeps the antd-style "use client" entry as the boundary in RSC and restores leaf rewrites in SSR. The resolveId fallback removal holds up because each environment registers every specifier it emits before emitting it.

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


✅ All checks passed

🔒 Security: ✅ LGTM

The plugin change only affects which module an import is rewritten to in the Vite dev and build pipeline. It handles no untrusted input, authentication, secrets or injection-prone data. The new fixture package is local and comes in as a file: dependency.


📖 AGENTS.md: ✅ LGTM

This pull request does not change any agent instruction files. The change follows the repository guidelines: it uses pathslash in source, adds fixture pages under tests/fixtures/, and comes with targeted unit and E2E tests.


📋 Code Quality: ✅ LGTM

I checked these parts of the change against the repository:

  • Boundary recording (optimize-imports.ts:628-640): each entry keeps its real source, and the outermost directive module overrides inner ones. When the outer module is a "use client" module, the inner ssrBoundary is kept, which is right because SSR only needs to stop at "use server" and "use cache". Entries are copied with a spread, so export maps shared through export * are never changed in place.
  • getEntry: every place that picked a source from exportMap.get now goes through it. That covers sub-package registration, the subpath no-op check, resolvedPackageSources and import grouping. The exportMap.has calls that remain only check whether a name exists. The getEntry(name)! in the registration loop is safe because the loop iterates exportMap.keys().
  • resolveId fallback removal: the transform registers each specifier in its own environment's map before emitting it. The client environment is skipped by the transform, so mapping any environment other than rsc to ssr matches the old default. This stops SSR-only packages behind a "use client" entry from being resolved in RSC.
  • Tests: the unit tests cover both environments, which boundary wins when they are nested, "use server" handling in SSR, and per-environment registration. The E2E check works because RSC never evaluates the imports of a "use client" page, so the unused global can only be set by SSR. The new route is excluded in after-response-close-worker.test.ts, like the other routes that depend on file: packages.

⚡ Performance: ✅ LGTM

No performance concerns. The change brings back leaf-module rewrites in SSR for "use client" barrels such as antd, so dev no longer loads the whole barrel there. One export-map cache still serves both environments.


📝 Documentation: ✅ LGTM

The new comments on rscBoundary/ssrBoundary, getEntry and the resolveId lookup describe the behaviour accurately.


Reviewed commit: f7be7863

@james-elicx
james-elicx merged commit 3fbfce4 into main Oct 19, 2026
109 checks passed
@james-elicx
james-elicx deleted the fix/optimize-imports-ssr-boundaries branch October 19, 2026 14:48
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