diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index aeb86803a..35ab0367e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1704,12 +1704,38 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ].flatMap((entry) => (entry ? [toRelativeFileEntry(root, entry)] : [])); const optimizeEntries = [...new Set([...appEntries, ...explicitInstrumentationEntries])]; + // Resolve conditions per environment so package `exports` maps pick + // the correct file for each runtime context. Matches Next.js webpack + // behavior (see Next.js `build/webpack-config.ts` reactServerConditionNames) + // and Next.js' `test/e2e/import-conditions/`. See #1356. + // + // RSC env : `react-server` + server defaults (+ edge conditions on workerd) + // SSR env : server defaults (`module`, `node`, …) (+ edge conditions on workerd) + // Client : client defaults (`module`, `browser`, …) + // + // When running on Cloudflare Workers / Nitro, the runtime is workerd, + // so we add `edge-light`, `workerd`, and `worker` to the server-side + // environments. `@cloudflare/vite-plugin` already sets these for its + // own worker environment; we set them here for vinext's rsc/ssr envs + // because Cloudflare's plugin doesn't reach inside them. Without this, + // packages that gate exports on `edge-light` (e.g. Next.js' + // `library-with-exports` test fixture) resolve to their `node` export + // when running on workerd. + const SERVER_DEFAULT_CONDITIONS = ["module", "node", "development|production"]; + const CLIENT_DEFAULT_CONDITIONS = ["module", "browser", "development|production"]; + const EDGE_CONDITIONS = + hasCloudflarePlugin || hasNitroPlugin ? ["edge-light", "workerd", "worker"] : []; + const rscConditions = ["react-server", ...EDGE_CONDITIONS, ...SERVER_DEFAULT_CONDITIONS]; + const ssrConditions = [...EDGE_CONDITIONS, ...SERVER_DEFAULT_CONDITIONS]; + const clientConditions = [...CLIENT_DEFAULT_CONDITIONS]; + viteConfig.environments = { rsc: { - ...(hasCloudflarePlugin || hasNitroPlugin - ? {} - : { - resolve: { + resolve: { + conditions: rscConditions, + ...(hasCloudflarePlugin || hasNitroPlugin + ? {} + : { // Externalize native/heavy packages so the RSC environment // loads them natively via Node rather than through Vite's // ESM module evaluator (which can't handle native addons). @@ -1727,8 +1753,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // When user sets `ssr.external: true`, skip noExternal since // everything is already externalized. ...(userSsrExternal === true ? {} : { noExternal: true as const }), - }, - }), + }), + }, optimizeDeps: { exclude: mergeOptimizeDepsExclude(incomingExclude, VINEXT_OPTIMIZE_DEPS_EXCLUDE), entries: optimizeEntries, @@ -1741,10 +1767,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, ssr: { - ...(hasCloudflarePlugin || hasNitroPlugin - ? {} - : { - resolve: { + resolve: { + conditions: ssrConditions, + ...(hasCloudflarePlugin || hasNitroPlugin + ? {} + : { external: userSsrExternal === true ? true : [...userSsrExternal, "ipaddr.js"], // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native @@ -1752,8 +1779,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // When user sets `ssr.external: true`, skip noExternal since // everything is already externalized. ...(userSsrExternal === true ? {} : { noExternal: true as const }), - }, - }), + }), + }, optimizeDeps: { // When userSsrExternal === true, exclude React from the SSR // optimizer so plugin-rsc's crawlFrameworkPkgs doesn't pre-bundle @@ -1794,6 +1821,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // service for the client environment, causing virtual module // imports to leak to Node's native ESM loader (ERR_UNSUPPORTED_ESM_URL_SCHEME). consumer: "client", + resolve: { + conditions: clientConditions, + }, optimizeDeps: { // Exclude server-external packages from the client dep optimizer. // These packages are server-only by design (listed in next.config's @@ -1850,6 +1880,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { viteConfig.environments = { client: { consumer: "client", + resolve: { + // See #1356: package `exports` must resolve to the browser entry + // in the client environment so e.g. isomorphic libs pick their + // browser file. + conditions: ["module", "browser", "development|production"], + }, optimizeDeps: pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined, build: { @@ -1875,6 +1911,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { viteConfig.environments = { client: { consumer: "client", + resolve: { + // See #1356: package `exports` must resolve to the browser entry + // in the client environment so e.g. isomorphic libs pick their + // browser file. + conditions: ["module", "browser", "development|production"], + }, optimizeDeps: pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined, build: { @@ -1890,6 +1932,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, ssr: { resolve: { + // See #1356: package `exports` must resolve to the node entry in + // the Pages Router SSR environment so server-only libs (e.g. + // ones that gate on `node`) pick their server file. + conditions: ["module", "node", "development|production"], external: ["react", "react-dom", "react-dom/server", "ipaddr.js"], noExternal: true as const, }, diff --git a/tests/import-conditions.test.ts b/tests/import-conditions.test.ts new file mode 100644 index 000000000..97eb3bc73 --- /dev/null +++ b/tests/import-conditions.test.ts @@ -0,0 +1,219 @@ +/** + * Import conditions tests — verifies that `resolve.conditions` is configured + * correctly per Vite environment so package `exports` resolve to the right + * file in each runtime context. + * + * Mirrors Next.js' behavior from `test/e2e/import-conditions/`: + * - RSC environment must include `react-server` + * - Edge / Cloudflare Workers environment must include `edge-light` (and `worker`) + * - Client environment must include `browser` + * - SSR/Node environment must include `node` + * + * Regression for: https://github.com/cloudflare/vinext/issues/1356 + * Ported behavior from: https://github.com/vercel/next.js/blob/canary/test/e2e/import-conditions/import-conditions.test.ts + */ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test"; + +// The vinext config hook mutates process.env.NODE_ENV as a side effect. +let originalNodeEnv: string | undefined; + +beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; +}); + +afterEach(() => { + if (originalNodeEnv === undefined) { + Reflect.deleteProperty(process.env, "NODE_ENV"); + } else { + Reflect.set(process.env, "NODE_ENV", originalNodeEnv); + } +}); + +async function makeAppDirFixture(prefix: string) { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), `vinext-import-conditions-${prefix}-`)); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return
{children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return