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

Home

; }`, + ); + await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), `export default {};`); + + return tmpDir; +} + +async function runConfigHook(mockConfig: unknown) { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext(); + const mainPlugin = plugins.find( + (p: unknown) => + !!p && + typeof p === "object" && + (p as { name?: string }).name === "vinext:config" && + typeof (p as { config?: unknown }).config === "function", + ); + if (!mainPlugin) throw new Error("vinext:config plugin not found"); + return await (mainPlugin as { config: (c: unknown, env: unknown) => Promise }).config( + mockConfig, + { command: "build" }, + ); +} + +describe("resolve.conditions per environment (App Router)", () => { + it("sets `react-server` condition on the RSC environment", async () => { + const tmpDir = await makeAppDirFixture("rsc"); + try { + const result = await runConfigHook({ root: tmpDir, build: {}, plugins: [] }); + const rscConditions = result.environments?.rsc?.resolve?.conditions ?? []; + expect( + rscConditions, + `rsc env should include "react-server"; got: ${JSON.stringify(rscConditions)}`, + ).toContain("react-server"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("sets `node` condition on the SSR environment", async () => { + const tmpDir = await makeAppDirFixture("ssr"); + try { + const result = await runConfigHook({ root: tmpDir, build: {}, plugins: [] }); + const ssrConditions = result.environments?.ssr?.resolve?.conditions ?? []; + expect( + ssrConditions, + `ssr env should include "node"; got: ${JSON.stringify(ssrConditions)}`, + ).toContain("node"); + expect( + ssrConditions, + `ssr env should NOT include "react-server"; got: ${JSON.stringify(ssrConditions)}`, + ).not.toContain("react-server"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("sets `browser` condition on the client environment", async () => { + const tmpDir = await makeAppDirFixture("client"); + try { + const result = await runConfigHook({ root: tmpDir, build: {}, plugins: [] }); + const clientConditions = result.environments?.client?.resolve?.conditions ?? []; + expect( + clientConditions, + `client env should include "browser"; got: ${JSON.stringify(clientConditions)}`, + ).toContain("browser"); + expect( + clientConditions, + `client env should NOT include "react-server"; got: ${JSON.stringify(clientConditions)}`, + ).not.toContain("react-server"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("adds `edge-light` and `worker` conditions to RSC environment when Cloudflare plugin is present", async () => { + const tmpDir = await makeAppDirFixture("rsc-cf"); + try { + // The vinext config hook detects the Cloudflare plugin by name. + const fakeCloudflarePlugin = { name: "vite-plugin-cloudflare" }; + const result = await runConfigHook({ + root: tmpDir, + build: {}, + plugins: [fakeCloudflarePlugin], + }); + const rscConditions = result.environments?.rsc?.resolve?.conditions ?? []; + // RSC on workerd: must keep `react-server` and add edge runtime conditions + // so packages like `library-with-exports` resolve their edge-light exports. + expect( + rscConditions, + `rsc env on cloudflare should include "react-server"; got: ${JSON.stringify(rscConditions)}`, + ).toContain("react-server"); + expect( + rscConditions, + `rsc env on cloudflare should include "edge-light"; got: ${JSON.stringify(rscConditions)}`, + ).toContain("edge-light"); + expect( + rscConditions, + `rsc env on cloudflare should include "worker"; got: ${JSON.stringify(rscConditions)}`, + ).toContain("worker"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("adds `edge-light` and `worker` conditions to SSR environment when Cloudflare plugin is present", async () => { + const tmpDir = await makeAppDirFixture("ssr-cf"); + try { + const fakeCloudflarePlugin = { name: "vite-plugin-cloudflare" }; + const result = await runConfigHook({ + root: tmpDir, + build: {}, + plugins: [fakeCloudflarePlugin], + }); + const ssrConditions = result.environments?.ssr?.resolve?.conditions ?? []; + expect( + ssrConditions, + `ssr env on cloudflare should include "edge-light"; got: ${JSON.stringify(ssrConditions)}`, + ).toContain("edge-light"); + expect( + ssrConditions, + `ssr env on cloudflare should include "worker"; got: ${JSON.stringify(ssrConditions)}`, + ).toContain("worker"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); +}); + +describe("resolve.conditions per environment (Pages Router on Node)", () => { + async function makePagesDirFixture(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, "pages"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "pages", "index.tsx"), + `export default function Home() { return

Home

; }`, + ); + await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), `export default {};`); + + return tmpDir; + } + + it("sets `node` condition on the SSR environment", async () => { + const tmpDir = await makePagesDirFixture("pages-ssr"); + try { + const result = await runConfigHook({ root: tmpDir, build: {}, plugins: [] }); + const ssrConditions = result.environments?.ssr?.resolve?.conditions ?? []; + expect( + ssrConditions, + `pages ssr env should include "node"; got: ${JSON.stringify(ssrConditions)}`, + ).toContain("node"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("sets `browser` condition on the client environment", async () => { + const tmpDir = await makePagesDirFixture("pages-client"); + try { + const result = await runConfigHook({ root: tmpDir, build: {}, plugins: [] }); + const clientConditions = result.environments?.client?.resolve?.conditions ?? []; + expect( + clientConditions, + `pages client env should include "browser"; got: ${JSON.stringify(clientConditions)}`, + ).toContain("browser"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); +});