Skip to content

Commit 9f4ca2a

Browse files
committed
fix(optimize-imports): only prefer react-server condition in RSC environment
SSR renders with the full React runtime and must not resolve react-server export condition entries. Thread preferReactServer (env.name === 'rsc') through resolveExportsValue and resolvePackageEntry, and key entryPathCache by environment prefix to keep RSC and SSR barrel entries separate.
1 parent 4d58c90 commit 9f4ca2a

2 files changed

Lines changed: 99 additions & 13 deletions

File tree

packages/vinext/src/plugins/optimize-imports.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -155,19 +155,24 @@ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [
155155

156156
/**
157157
* Resolve a package.json exports value to a string entry path.
158-
* Prefers react-server → node → import → module → default conditions, recursing into
159-
* nested objects. The "react-server" condition is checked first because this plugin
160-
* targets RSC/SSR environments where packages like `react` and `react-dom` expose
161-
* RSC-compatible entry points under that condition.
158+
* Prefers node → import → module → default conditions, recursing into nested objects.
159+
* When `preferReactServer` is true (RSC environment), "react-server" is checked first
160+
* so that packages like `react` and `react-dom` resolve their RSC-compatible entry points.
162161
*/
163-
function resolveExportsValue(value: ExportsValue): string | null {
162+
function resolveExportsValue(value: ExportsValue, preferReactServer: boolean): string | null {
164163
if (typeof value === "string") return value;
165164
if (typeof value === "object" && value !== null) {
166-
// Prefer ESM conditions in order; "react-server" first for RSC/SSR environments
167-
for (const key of ["react-server", "node", "import", "module", "default"]) {
165+
// In the RSC environment prefer "react-server" before standard conditions so that
166+
// packages exposing RSC-only entry points (e.g. react, react-dom) are resolved
167+
// to their server-compatible barrel. In the SSR environment the "react-server"
168+
// condition must NOT be preferred — SSR renders with the full React runtime.
169+
const conditions = preferReactServer
170+
? ["react-server", "node", "import", "module", "default"]
171+
: ["node", "import", "module", "default"];
172+
for (const key of conditions) {
168173
const nested = value[key];
169174
if (nested !== undefined) {
170-
const resolved = resolveExportsValue(nested);
175+
const resolved = resolveExportsValue(nested, preferReactServer);
171176
if (resolved) return resolved;
172177
}
173178
}
@@ -231,8 +236,14 @@ function resolvePackageInfo(packageName: string, projectRoot: string): PackageIn
231236
/**
232237
* Resolve a package name to its ESM entry file path.
233238
* Checks `exports["."]` → `module` → `main`, then falls back to require.resolve.
239+
* Pass `preferReactServer: true` in the RSC environment to prefer the "react-server"
240+
* export condition over "node"/"import" when resolving the barrel entry.
234241
*/
235-
function resolvePackageEntry(packageName: string, projectRoot: string): string | null {
242+
function resolvePackageEntry(
243+
packageName: string,
244+
projectRoot: string,
245+
preferReactServer: boolean,
246+
): string | null {
236247
try {
237248
const info = resolvePackageInfo(packageName, projectRoot);
238249
if (!info) return null;
@@ -244,7 +255,7 @@ function resolvePackageEntry(packageName: string, projectRoot: string): string |
244255
// the barrel entry point, not individual sub-module paths.
245256
const dotExport = pkgJson.exports["."];
246257
if (dotExport) {
247-
const entryPath = resolveExportsValue(dotExport);
258+
const entryPath = resolveExportsValue(dotExport, preferReactServer);
248259
if (entryPath) {
249260
return path.resolve(pkgDir, entryPath).split(path.sep).join("/");
250261
}
@@ -545,6 +556,9 @@ export function createOptimizeImportsPlugin(
545556
// dep optimizer which handles barrel imports correctly.
546557
const env = (this as { environment?: { name?: string } }).environment;
547558
if (env?.name === "client") return null;
559+
// "react-server" export condition should only be preferred in the RSC environment.
560+
// SSR renders with the full React runtime and must NOT resolve react-server entries.
561+
const preferReactServer = env?.name === "rsc";
548562
// Skip virtual modules
549563
if (id.startsWith("\0")) return null;
550564

@@ -580,10 +594,14 @@ export function createOptimizeImportsPlugin(
580594

581595
// Build or retrieve the barrel export map for this package.
582596
// Cache the resolved entry path to avoid repeated FS work.
583-
let barrelEntry: string | null | undefined = entryPathCache.get(importSource);
597+
// The cache key includes the environment prefix because RSC resolves the
598+
// "react-server" export condition while SSR uses the standard conditions —
599+
// the same package can have different barrel entry paths in each environment.
600+
const cacheKey = `${preferReactServer ? "rsc" : "ssr"}:${importSource}`;
601+
let barrelEntry: string | null | undefined = entryPathCache.get(cacheKey);
584602
if (barrelEntry === undefined) {
585-
barrelEntry = resolvePackageEntry(importSource, root);
586-
entryPathCache.set(importSource, barrelEntry);
603+
barrelEntry = resolvePackageEntry(importSource, root, preferReactServer);
604+
entryPathCache.set(cacheKey, barrelEntry);
587605
}
588606
const exportMap = buildBarrelExportMap(
589607
importSource,

tests/optimize-imports.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,4 +646,72 @@ describe("vinext:optimize-imports transform", () => {
646646
// Must use an absolute path, not a relative one
647647
expect(result!.code).not.toContain(`from "./`);
648648
});
649+
650+
it("prefers react-server export condition in RSC but not in SSR", async () => {
651+
// Simulates a package (like react-dom) that exposes different barrel entries
652+
// under "react-server" vs "import" export conditions.
653+
// In the RSC environment the plugin should pick the react-server entry;
654+
// in SSR it must use the standard import entry (SSR uses the full React runtime).
655+
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-optimize-test-")));
656+
fs.writeFileSync(
657+
path.join(tmpDir, "package.json"),
658+
JSON.stringify({ name: "test-app", type: "module" }),
659+
);
660+
const pkgDir = path.join(tmpDir, "node_modules", "antd");
661+
fs.mkdirSync(pkgDir, { recursive: true });
662+
663+
// Package with diverging react-server vs import entries
664+
fs.writeFileSync(
665+
path.join(pkgDir, "package.json"),
666+
JSON.stringify({
667+
name: "antd",
668+
type: "module",
669+
exports: {
670+
".": {
671+
"react-server": "./rsc-index.js",
672+
import: "./index.js",
673+
default: "./index.js",
674+
},
675+
},
676+
main: "./index.js",
677+
}),
678+
);
679+
// RSC barrel: exports RscButton
680+
fs.writeFileSync(path.join(pkgDir, "rsc-index.js"), `export { RscButton } from "./rsc-btn";`);
681+
// Standard barrel: exports Button
682+
fs.writeFileSync(path.join(pkgDir, "index.js"), `export { Button } from "./btn";`);
683+
684+
const plugin = createOptimizeImportsPlugin(
685+
() => undefined,
686+
() => tmpDir,
687+
) as Plugin;
688+
const buildStartHook = unwrapHook((plugin as any).buildStart);
689+
if (buildStartHook) await buildStartHook.call(plugin);
690+
const transform = unwrapHook(plugin.transform)!;
691+
692+
// RSC environment: should use react-server entry → knows about RscButton
693+
const rscCall = (code: string, id: string) =>
694+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
695+
(transform as any).call({ ...plugin, environment: { name: "rsc" } }, code, id);
696+
// SSR environment: should use import entry → knows about Button, not RscButton
697+
const ssrCall = (code: string, id: string) =>
698+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
699+
(transform as any).call({ ...plugin, environment: { name: "ssr" } }, code, id);
700+
701+
// RSC: RscButton is exported from the react-server barrel → rewrite succeeds
702+
const rscResult = rscCall(`import { RscButton } from "antd";`, "/app/page.tsx");
703+
expect(rscResult).not.toBeNull();
704+
expect(rscResult!.code).not.toContain(`from "antd"`);
705+
expect(rscResult!.code).toContain("rsc-btn");
706+
707+
// SSR: RscButton is NOT in the standard barrel → rewrite must be skipped
708+
const ssrResultUnknown = ssrCall(`import { RscButton } from "antd";`, "/app/page.tsx");
709+
expect(ssrResultUnknown).toBeNull();
710+
711+
// SSR: Button IS in the standard barrel → rewrite succeeds
712+
const ssrResultKnown = ssrCall(`import { Button } from "antd";`, "/app/page.tsx");
713+
expect(ssrResultKnown).not.toBeNull();
714+
expect(ssrResultKnown!.code).not.toContain(`from "antd"`);
715+
expect(ssrResultKnown!.code).toContain("btn");
716+
});
649717
});

0 commit comments

Comments
 (0)