Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -1741,19 +1767,20 @@ 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
// ESM loader. Matches Next.js behavior of bundling everything.
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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,
},
Expand Down
219 changes: 219 additions & 0 deletions tests/import-conditions.test.ts
Original file line number Diff line number Diff line change
@@ -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 <html><body>{children}</body></html>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "app", "page.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
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<any> }).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 <h1>Home</h1>; }`,
);
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);
});
Loading