From 11e8b3001fbabb9e04aaaa48b7520531a9e7e9b1 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 21:08:04 +0100 Subject: [PATCH 01/16] fix(app-router): use experimental React channel --- .../vinext/src/config/experimental-react.ts | 147 ++++++++++++++++++ packages/vinext/src/config/next-config.ts | 5 + packages/vinext/src/index.ts | 55 +++++++ tests/app-router-experimental-react.test.ts | 78 ++++++++++ tests/experimental-react.test.ts | 119 ++++++++++++++ .../app/client-react.tsx | 21 +++ .../app-experimental-react/app/layout.tsx | 7 + .../app-experimental-react/app/page.tsx | 13 ++ .../app-experimental-react/next.config.ts | 5 + .../app-experimental-react/package.json | 4 + 10 files changed, 454 insertions(+) create mode 100644 packages/vinext/src/config/experimental-react.ts create mode 100644 tests/app-router-experimental-react.test.ts create mode 100644 tests/experimental-react.test.ts create mode 100644 tests/fixtures/app-experimental-react/app/client-react.tsx create mode 100644 tests/fixtures/app-experimental-react/app/layout.tsx create mode 100644 tests/fixtures/app-experimental-react/app/page.tsx create mode 100644 tests/fixtures/app-experimental-react/next.config.ts create mode 100644 tests/fixtures/app-experimental-react/package.json diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts new file mode 100644 index 0000000000..c7eb1e6b2b --- /dev/null +++ b/packages/vinext/src/config/experimental-react.ts @@ -0,0 +1,147 @@ +import path from "node:path"; +import { createRequire } from "node:module"; +import fs from "node:fs"; + +export type ExperimentalReactAliases = Record; +export type ExperimentalReactEnvironment = "rsc" | "ssr" | "client"; +export type ExperimentalReactAliasEntry = { find: RegExp; replacement: string }; + +type PackageExport = string | PackageExportConditions; +type PackageExportConditions = { + [condition: string]: PackageExport; +}; + +export function needsExperimentalReact(experimental: Record | undefined): boolean { + return ( + experimental?.taint === true || + experimental?.transitionIndicator === true || + experimental?.gestureTransition === true + ); +} + +export function resolveExperimentalReactAliases(root: string): ExperimentalReactAliases { + const projectRequire = createRequire(path.join(root, "package.json")); + let nextPackagePath: string; + try { + nextPackagePath = projectRequire.resolve("next/package.json"); + } catch (cause) { + throw new Error( + "[vinext] React's experimental channel requires the project's installed `next` package.", + { cause }, + ); + } + + const compiledDir = path.join(path.dirname(nextPackagePath), "dist", "compiled"); + const packages = { + react: path.join(compiledDir, "react-experimental"), + "react-dom": path.join(compiledDir, "react-dom-experimental"), + "react-server-dom-webpack": path.join(compiledDir, "react-server-dom-webpack-experimental"), + }; + + for (const [specifier, packageDir] of Object.entries(packages)) { + if (!fs.existsSync(path.join(packageDir, "package.json"))) { + throw new Error( + `[vinext] The installed Next.js package does not include the experimental ${specifier} runtime.`, + ); + } + } + + return packages; +} + +export function resolveExperimentalReactSpecifier( + packages: ExperimentalReactAliases, + specifier: string, + environment: "rsc" | "ssr" | "client", +): string | null { + const packageName = Object.keys(packages).find( + (candidate) => specifier === candidate || specifier.startsWith(`${candidate}/`), + ); + if (!packageName) return null; + + const packageDir = packages[packageName]; + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageDir, "package.json"), "utf8"), + ) as { exports?: Record }; + const exportKey = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`; + const packageExport = packageJson.exports?.[exportKey]; + if (!packageExport) return null; + + const conditions = + environment === "rsc" + ? ["react-server", "workerd", "edge-light", "node", "browser", "default"] + : environment === "client" + ? ["browser", "worker", "default"] + : ["workerd", "edge-light", "node", "default"]; + const target = selectPackageExport(packageExport, conditions); + return target ? path.join(packageDir, target) : null; +} + +const EXPERIMENTAL_REACT_SPECIFIERS = [ + "react", + "react/compiler-runtime", + "react/jsx-dev-runtime", + "react/jsx-runtime", + "react-dom", + "react-dom/client", + "react-dom/server", + "react-dom/server.browser", + "react-dom/server.edge", + "react-dom/server.node", + "react-dom/static", + "react-dom/static.browser", + "react-dom/static.edge", + "react-dom/static.node", + "react-server-dom-webpack/client", + "react-server-dom-webpack/client.browser", + "react-server-dom-webpack/client.edge", + "react-server-dom-webpack/client.node", + "react-server-dom-webpack/server", + "react-server-dom-webpack/server.browser", + "react-server-dom-webpack/server.edge", + "react-server-dom-webpack/server.node", + "react-server-dom-webpack/static", + "react-server-dom-webpack/static.browser", + "react-server-dom-webpack/static.edge", + "react-server-dom-webpack/static.node", +] as const; + +export function createExperimentalReactEnvironmentAliases( + packages: ExperimentalReactAliases, + environment: ExperimentalReactEnvironment, +): ExperimentalReactAliasEntry[] { + const packageAliases = EXPERIMENTAL_REACT_SPECIFIERS.flatMap((specifier) => { + const replacement = resolveExperimentalReactSpecifier(packages, specifier, environment); + return replacement + ? [{ find: new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`), replacement }] + : []; + }); + + return [ + ...packageAliases, + { + find: /^next\/dist\/compiled\/react-experimental$/, + replacement: resolveExperimentalReactSpecifier(packages, "react", environment)!, + }, + { + find: /^next\/dist\/compiled\/react-dom-experimental$/, + replacement: resolveExperimentalReactSpecifier(packages, "react-dom", environment)!, + }, + { + find: /^next\/dist\/compiled\/react-server-dom-webpack-experimental$/, + replacement: path.join(packages["react-server-dom-webpack"], "index.js"), + }, + ]; +} + +function selectPackageExport( + packageExport: PackageExport, + conditions: readonly string[], +): string | null { + if (typeof packageExport === "string") return packageExport; + for (const condition of conditions) { + const target = packageExport[condition]; + if (target) return selectPackageExport(target, conditions); + } + return null; +} diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index b9e0b7e953..6cbd4c52b6 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -18,6 +18,7 @@ import { applyLocaleToRoutes, isExternalUrl } from "./config-matchers.js"; import { loadTsconfigResolutionForRoot } from "./tsconfig-paths.js"; import { getViteMajorVersion } from "../utils/vite-version.js"; import { loadCommonJsModule, shouldRetryAsCommonJs } from "../utils/commonjs-loader.js"; +import { needsExperimentalReact } from "./experimental-react.js"; /** * Parse a body size limit value (string or number) into bytes. @@ -356,6 +357,8 @@ export type ResolvedNextConfig = { * `useRouter().experimental_gesturePush()`. */ gestureTransition: boolean; + /** Whether App Router environments should use Next.js's vendored experimental React channel. */ + useExperimentalReact: boolean; /** * Whether `experimental.prefetchInlining` is configured. Next.js uses this * with the Segment Cache to fetch the route tree before the bundled inlined @@ -1335,6 +1338,7 @@ export async function resolveNextConfig( cacheComponents: false, appNavFailHandling: false, gestureTransition: false, + useExperimentalReact: false, prefetchInlining: false, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, @@ -1659,6 +1663,7 @@ export async function resolveNextConfig( cacheComponents: config.cacheComponents ?? false, appNavFailHandling: experimental?.appNavFailHandling === true, gestureTransition: experimental?.gestureTransition === true, + useExperimentalReact: needsExperimentalReact(experimental), prefetchInlining, redirects, rewrites, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 5919b80804..6b7cd57413 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -76,6 +76,12 @@ import { type ResolvedNextConfig, } from "./config/next-config.js"; import { mergeServerExternalPackages } from "./config/server-external-packages.js"; +import { + createExperimentalReactEnvironmentAliases, + resolveExperimentalReactAliases, + type ExperimentalReactAliasEntry, + type ExperimentalReactEnvironment, +} from "./config/experimental-react.js"; import { findMiddlewareFile, isProxyFile, runMiddleware } from "./server/middleware.js"; import { isNextDataPathname, parseNextDataPathname } from "./server/pages-data-route.js"; @@ -882,6 +888,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let hasAppDir = false; let hasPagesDir = false; let nextConfig: ResolvedNextConfig; + let experimentalReactAliases: Record< + ExperimentalReactEnvironment, + ExperimentalReactAliasEntry[] + > | null = null; + let experimentalReactFlightDir: string | null = null; let fileMatcher: ReturnType; let middlewarePath: string | null = null; let instrumentationPath: string | null = null; @@ -1182,6 +1193,35 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ...(viteMajorVersion >= 8 ? [] : [loadVite7TsconfigPathsPlugin(earlyBaseDir)]), // React Fast Refresh + JSX transform for client components. reactPluginPromise, + { + name: "vinext:experimental-react-channel", + enforce: "pre", + resolveId(id) { + const environment = this.environment?.name; + if ( + !experimentalReactAliases || + (environment !== "rsc" && environment !== "ssr" && environment !== "client") + ) { + return null; + } + const alias = experimentalReactAliases[environment].find(({ find }) => find.test(id)); + return alias?.replacement ?? null; + }, + transform: { + filter: { code: "globalThis.__next_require__" }, + handler(code, id) { + const cleanId = normalizePathSeparators(id.split("?", 1)[0]); + if ( + !experimentalReactFlightDir || + !cleanId.startsWith(`${experimentalReactFlightDir}/`) || + !code.includes("globalThis.__next_require__") + ) { + return null; + } + return code.replaceAll("globalThis.__next_require__", "__vite_rsc_require__"); + }, + }, + }, // Next.js ignores requests without any statically known path component // during graph analysis and leaves a deterministic runtime failure. createIgnoreDynamicRequestsPlugin(() => nextConfig?.turbopackTranspilePackages ?? []), @@ -1411,6 +1451,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (sharedBuildId && sharedBuildId.length > 0) { nextConfig = { ...nextConfig, buildId: sharedBuildId }; } + + if (hasAppDir && nextConfig.useExperimentalReact) { + const packages = resolveExperimentalReactAliases(root); + experimentalReactAliases = { + rsc: createExperimentalReactEnvironmentAliases(packages, "rsc"), + ssr: createExperimentalReactEnvironmentAliases(packages, "ssr"), + client: createExperimentalReactEnvironmentAliases(packages, "client"), + }; + experimentalReactFlightDir = normalizePathSeparators( + packages["react-server-dom-webpack"], + ); + } } // RSC-compat ID coordination across plugin instances — same rationale as // the build ID above. createRscCompatibilityId() falls back to a random @@ -1464,6 +1516,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (key === "NODE_ENV") continue; defines[`process.env.${key}`] = JSON.stringify(value); } + if (experimentalReactAliases) { + defines["process.env.__NEXT_EXPERIMENTAL_REACT"] = JSON.stringify("true"); + } // Expose basePath to client-side code defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify(nextConfig.basePath); // Let shared client shims compile out Pages-only behavior in pure App diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts new file mode 100644 index 0000000000..08486f65ce --- /dev/null +++ b/tests/app-router-experimental-react.test.ts @@ -0,0 +1,78 @@ +// Ported from Next.js: +// test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { createBuilder, preview } from "vite"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "fixtures/app-experimental-react"); +const DIST_DIR = path.join(FIXTURE_DIR, "dist"); +const require = createRequire(import.meta.url); + +describe("App Router experimental React channel", () => { + let previewServer: Awaited>; + let baseUrl = ""; + const fixtureNodeModules = path.join(FIXTURE_DIR, "node_modules"); + + beforeAll(async () => { + const nextPackageDir = process.env.VINEXT_TEST_NEXT_PACKAGE_DIR + ? path.resolve(process.env.VINEXT_TEST_NEXT_PACKAGE_DIR) + : path.dirname(require.resolve("next/package.json")); + try { + fs.mkdirSync(fixtureNodeModules, { recursive: true }); + fs.symlinkSync(nextPackageDir, path.join(fixtureNodeModules, "next"), "junction"); + const builder = await createBuilder({ + root: FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: FIXTURE_DIR })], + logLevel: "silent", + }); + await builder.buildApp(); + + previewServer = await preview({ + root: FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: FIXTURE_DIR })], + preview: { port: 0 }, + logLevel: "silent", + }); + } catch (error) { + fs.rmSync(DIST_DIR, { recursive: true, force: true }); + fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); + throw error; + } + const address = previewServer.httpServer.address(); + baseUrl = address && typeof address === "object" ? `http://localhost:${address.port}` : ""; + }, 120_000); + + afterAll(() => { + previewServer?.httpServer.close(); + fs.rmSync(DIST_DIR, { recursive: true, force: true }); + fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); + }); + + it("uses Next.js's vendored experimental React in RSC, SSR, and client bundles", async () => { + const html = await fetch(baseUrl).then((response) => response.text()); + const versions = [ + ...html + .replaceAll("", "") + .matchAll(/(?:React|ReactDOM|ReactDOMServer)\.version=([^<]+)/g), + ].map((match) => match[1]); + + expect(versions.length).toBeGreaterThanOrEqual(5); + expect(versions.every((version) => version.includes("-experimental-"))).toBe(true); + + const clientJavaScript = fs + .readdirSync(path.join(DIST_DIR, "client", "_next", "static", "chunks")) + .filter((file) => file.endsWith(".js")) + .map((file) => + fs.readFileSync(path.join(DIST_DIR, "client", "_next", "static", "chunks", file), "utf8"), + ) + .join("\n"); + expect(clientJavaScript).toContain("-experimental-"); + }); +}); diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts new file mode 100644 index 0000000000..9fb48d1622 --- /dev/null +++ b/tests/experimental-react.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + createExperimentalReactEnvironmentAliases, + needsExperimentalReact, + resolveExperimentalReactAliases, + resolveExperimentalReactSpecifier, +} from "../packages/vinext/src/config/experimental-react.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe("experimental React channel", () => { + it.each([ + [undefined, false], + [{}, false], + [{ taint: true }, true], + [{ transitionIndicator: true }, true], + [{ gestureTransition: true }, true], + [{ useExperimentalReact: false, taint: true }, true], + ])("resolves the channel from Next.js experimental flags", (experimental, expected) => { + expect(needsExperimentalReact(experimental)).toBe(expected); + }); + + it("resolves vendored experimental packages from the project's Next.js installation", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-")); + tempDirs.push(root); + const nextDir = path.join(root, "node_modules", "next"); + fs.mkdirSync(path.join(nextDir, "dist", "compiled"), { recursive: true }); + fs.writeFileSync(path.join(root, "package.json"), "{}"); + fs.writeFileSync(path.join(nextDir, "package.json"), '{"name":"next"}'); + for (const packageName of [ + "react-experimental", + "react-dom-experimental", + "react-server-dom-webpack-experimental", + ]) { + const packageDir = path.join(nextDir, "dist", "compiled", packageName); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, "package.json"), "{}"); + } + + const canonicalNextDir = fs.realpathSync(nextDir); + + expect(resolveExperimentalReactAliases(root)).toEqual({ + react: path.join(canonicalNextDir, "dist", "compiled", "react-experimental"), + "react-dom": path.join(canonicalNextDir, "dist", "compiled", "react-dom-experimental"), + "react-server-dom-webpack": path.join( + canonicalNextDir, + "dist", + "compiled", + "react-server-dom-webpack-experimental", + ), + }); + }); + + it("resolves package exports using environment-specific React conditions", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-")); + tempDirs.push(root); + const reactDir = path.join(root, "react-experimental"); + const reactDomDir = path.join(root, "react-dom-experimental"); + const reactServerDomDir = path.join(root, "react-server-dom-webpack-experimental"); + fs.mkdirSync(reactDir, { recursive: true }); + fs.mkdirSync(reactDomDir, { recursive: true }); + fs.mkdirSync(reactServerDomDir, { recursive: true }); + fs.writeFileSync( + path.join(reactDir, "package.json"), + JSON.stringify({ + exports: { + ".": { "react-server": "./react-server.js", default: "./index.js" }, + "./jsx-runtime": { + "react-server": "./jsx-runtime.react-server.js", + default: "./jsx-runtime.js", + }, + }, + }), + ); + fs.writeFileSync( + path.join(reactDomDir, "package.json"), + JSON.stringify({ + exports: { ".": { "react-server": "./server.js", default: "./index.js" } }, + }), + ); + fs.writeFileSync( + path.join(reactServerDomDir, "package.json"), + JSON.stringify({ exports: { "./server.edge": "./server.edge.js" } }), + ); + const packages = { + react: reactDir, + "react-dom": reactDomDir, + "react-server-dom-webpack": reactServerDomDir, + }; + + expect(resolveExperimentalReactSpecifier(packages, "react", "rsc")).toBe( + path.join(reactDir, "react-server.js"), + ); + expect(resolveExperimentalReactSpecifier(packages, "react", "ssr")).toBe( + path.join(reactDir, "index.js"), + ); + expect(resolveExperimentalReactSpecifier(packages, "react/jsx-runtime", "client")).toBe( + path.join(reactDir, "jsx-runtime.js"), + ); + const clientAliases = createExperimentalReactEnvironmentAliases(packages, "client"); + expect(clientAliases.find(({ find }) => find.test("react"))?.replacement).toBe( + path.join(reactDir, "index.js"), + ); + expect( + createExperimentalReactEnvironmentAliases(packages, "rsc").find(({ find }) => + find.test("react-server-dom-webpack/server.edge"), + )?.replacement, + ).toBe(path.join(reactServerDomDir, "server.edge.js")); + }); +}); diff --git a/tests/fixtures/app-experimental-react/app/client-react.tsx b/tests/fixtures/app-experimental-react/app/client-react.tsx new file mode 100644 index 0000000000..3dee63ea4b --- /dev/null +++ b/tests/fixtures/app-experimental-react/app/client-react.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; +import ReactDOM from "react-dom"; +import ReactDOMServer from "react-dom/server.edge"; +import { renderToStaticMarkup } from "react-dom/server"; + +export default function ClientReact() { + const markup = renderToStaticMarkup( +
React Static Markup
, + ); + + return ( +
+

React.version={React.version}

+

ReactDOM.version={ReactDOM.version}

+

ReactDOMServer.version={ReactDOMServer.version}

+

{markup}

+
+ ); +} diff --git a/tests/fixtures/app-experimental-react/app/layout.tsx b/tests/fixtures/app-experimental-react/app/layout.tsx new file mode 100644 index 0000000000..c8f9cee0b7 --- /dev/null +++ b/tests/fixtures/app-experimental-react/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-experimental-react/app/page.tsx b/tests/fixtures/app-experimental-react/app/page.tsx new file mode 100644 index 0000000000..a9afdc904c --- /dev/null +++ b/tests/fixtures/app-experimental-react/app/page.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import ClientReact from "./client-react"; + +export default function Page() { + return ( +
+

React.version={React.version}

+

ReactDOM.version={ReactDOM.version}

+ +
+ ); +} diff --git a/tests/fixtures/app-experimental-react/next.config.ts b/tests/fixtures/app-experimental-react/next.config.ts new file mode 100644 index 0000000000..30127e5e58 --- /dev/null +++ b/tests/fixtures/app-experimental-react/next.config.ts @@ -0,0 +1,5 @@ +export default { + experimental: { + taint: true, + }, +}; diff --git a/tests/fixtures/app-experimental-react/package.json b/tests/fixtures/app-experimental-react/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/tests/fixtures/app-experimental-react/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} From 6032e573077cf0a3b1a8e04091296e2402b576b5 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 21:11:50 +0100 Subject: [PATCH 02/16] fix(app-router): resolve experimental React for fixtures --- .../vinext/src/config/experimental-react.ts | 19 ++++++++++++++----- tests/experimental-react.test.ts | 10 ++++++++++ tests/next-config.test.ts | 1 + 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts index c7eb1e6b2b..591af52472 100644 --- a/packages/vinext/src/config/experimental-react.ts +++ b/packages/vinext/src/config/experimental-react.ts @@ -20,11 +20,20 @@ export function needsExperimentalReact(experimental: Record | u } export function resolveExperimentalReactAliases(root: string): ExperimentalReactAliases { - const projectRequire = createRequire(path.join(root, "package.json")); - let nextPackagePath: string; - try { - nextPackagePath = projectRequire.resolve("next/package.json"); - } catch (cause) { + const requireRoots = [root, process.cwd()]; + let nextPackagePath: string | undefined; + let cause: unknown; + for (const requireRoot of requireRoots) { + try { + nextPackagePath = createRequire(path.join(requireRoot, "package.json")).resolve( + "next/package.json", + ); + break; + } catch (error) { + cause = error; + } + } + if (!nextPackagePath) { throw new Error( "[vinext] React's experimental channel requires the project's installed `next` package.", { cause }, diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts index 9fb48d1622..b8d58d89a3 100644 --- a/tests/experimental-react.test.ts +++ b/tests/experimental-react.test.ts @@ -60,6 +60,16 @@ describe("experimental React channel", () => { }); }); + it("falls back to the running workspace for isolated fixtures without Next.js", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-fixture-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "package.json"), "{}"); + + const packages = resolveExperimentalReactAliases(root); + + expect(packages.react).toContain("/next/dist/compiled/react-experimental"); + }); + it("resolves package exports using environment-specific React conditions", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-")); tempDirs.push(root); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index b468312856..2578f9ff4b 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -1967,6 +1967,7 @@ describe("detectNextIntlConfig", () => { cacheComponents: false, appNavFailHandling: false, gestureTransition: false, + useExperimentalReact: false, prefetchInlining: false, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, From 404654fbd28e1d040093796d80a265f02cb439d1 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:18:11 +0100 Subject: [PATCH 03/16] fix(app-router): align experimental React in dev --- .../vinext/src/config/experimental-react.ts | 26 +++++-- packages/vinext/src/index.ts | 71 ++++++++++++++++++- tests/app-router-experimental-react.test.ts | 50 ++++++++++++- tests/experimental-react.test.ts | 2 +- 4 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts index 591af52472..4c199165a2 100644 --- a/packages/vinext/src/config/experimental-react.ts +++ b/packages/vinext/src/config/experimental-react.ts @@ -86,7 +86,7 @@ export function resolveExperimentalReactSpecifier( return target ? path.join(packageDir, target) : null; } -const EXPERIMENTAL_REACT_SPECIFIERS = [ +export const EXPERIMENTAL_REACT_SPECIFIERS = [ "react", "react/compiler-runtime", "react/jsx-dev-runtime", @@ -130,19 +130,37 @@ export function createExperimentalReactEnvironmentAliases( ...packageAliases, { find: /^next\/dist\/compiled\/react-experimental$/, - replacement: resolveExperimentalReactSpecifier(packages, "react", environment)!, + replacement: requireExperimentalReactSpecifier(packages, "react", environment), }, { find: /^next\/dist\/compiled\/react-dom-experimental$/, - replacement: resolveExperimentalReactSpecifier(packages, "react-dom", environment)!, + replacement: requireExperimentalReactSpecifier(packages, "react-dom", environment), }, { find: /^next\/dist\/compiled\/react-server-dom-webpack-experimental$/, - replacement: path.join(packages["react-server-dom-webpack"], "index.js"), + replacement: requireExperimentalReactSpecifier( + packages, + "react-server-dom-webpack", + environment, + ), }, ]; } +function requireExperimentalReactSpecifier( + packages: ExperimentalReactAliases, + specifier: string, + environment: ExperimentalReactEnvironment, +): string { + const resolved = resolveExperimentalReactSpecifier(packages, specifier, environment); + if (!resolved) { + throw new Error( + `[vinext] Next.js's experimental React runtime does not export ${specifier} for the ${environment} environment.`, + ); + } + return resolved; +} + function selectPackageExport( packageExport: PackageExport, conditions: readonly string[], diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6b7cd57413..84660cce9e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -78,6 +78,7 @@ import { import { mergeServerExternalPackages } from "./config/server-external-packages.js"; import { createExperimentalReactEnvironmentAliases, + EXPERIMENTAL_REACT_SPECIFIERS, resolveExperimentalReactAliases, type ExperimentalReactAliasEntry, type ExperimentalReactEnvironment, @@ -1218,7 +1219,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ) { return null; } - return code.replaceAll("globalThis.__next_require__", "__vite_rsc_require__"); + return { + code: code.replaceAll("globalThis.__next_require__", "__vite_rsc_require__"), + map: null, + }; }, }, }, @@ -1453,6 +1457,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (hasAppDir && nextConfig.useExperimentalReact) { + if (config.ssr?.external === true) { + throw new Error( + "[vinext] `ssr.external: true` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", + ); + } const packages = resolveExperimentalReactAliases(root); experimentalReactAliases = { rsc: createExperimentalReactEnvironmentAliases(packages, "rsc"), @@ -2337,7 +2346,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? true : nextServerExternal; const externalizeSsrReactInDev = - env.command === "serve" && !hasCloudflarePlugin && !hasNitroPlugin; + env.command === "serve" && + !hasCloudflarePlugin && + !hasNitroPlugin && + !experimentalReactAliases; // Capture top-level optimizeDeps populated by earlier plugins // (e.g. @lingui/vite-plugin) so we merge rather than overwrite. @@ -5749,6 +5761,61 @@ export const loadServerActionClient = ${ // Append auto-injected RSC plugins if applicable if (rscPluginPromise) { plugins.push(rscPluginPromise); + plugins.push({ + name: "vinext:experimental-react-dev-optimizer", + enforce: "post", + configEnvironment(name, config, env) { + if ( + env.command !== "serve" || + !experimentalReactAliases || + (name !== "rsc" && name !== "ssr" && name !== "client") + ) { + return null; + } + + const aliases = experimentalReactAliases[name]; + if (name === "ssr") { + if (Array.isArray(config.resolve?.external)) { + config.resolve.external = config.resolve.external.filter( + (entry) => + typeof entry !== "string" || + !EXPERIMENTAL_REACT_SPECIFIERS.some( + (specifier) => entry === specifier || entry.startsWith(`${specifier}/`), + ), + ); + } + } + config.optimizeDeps ??= {}; + config.optimizeDeps.include = config.optimizeDeps.include?.map((specifier) => { + const normalizedSpecifier = specifier.replace( + "@vitejs/plugin-rsc/vendor/react-server-dom/", + "react-server-dom-webpack/", + ); + return ( + aliases.find(({ find }) => find.test(normalizedSpecifier))?.replacement ?? specifier + ); + }); + const rolldownOptions = config.optimizeDeps.rolldownOptions ?? {}; + const existingPlugins = rolldownOptions.plugins + ? Array.isArray(rolldownOptions.plugins) + ? rolldownOptions.plugins + : [rolldownOptions.plugins] + : []; + config.optimizeDeps.rolldownOptions = { + ...rolldownOptions, + plugins: [ + { + name: `vinext:experimental-react-dep-optimize:${name}`, + resolveId(id: string) { + return aliases.find(({ find }) => find.test(id))?.replacement; + }, + }, + ...existingPlugins, + ], + }; + return null; + }, + }); plugins.push(createRscClientReferenceLoadersPlugin()); } diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts index 08486f65ce..ebab91f4f1 100644 --- a/tests/app-router-experimental-react.test.ts +++ b/tests/app-router-experimental-react.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { createBuilder, preview } from "vite"; +import { createBuilder, createServer, preview, type ViteDevServer } from "vite"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import vinext from "../packages/vinext/src/index.js"; @@ -15,7 +15,9 @@ const require = createRequire(import.meta.url); describe("App Router experimental React channel", () => { let previewServer: Awaited>; + let devServer: ViteDevServer; let baseUrl = ""; + let devBaseUrl = ""; const fixtureNodeModules = path.join(FIXTURE_DIR, "node_modules"); beforeAll(async () => { @@ -40,6 +42,14 @@ describe("App Router experimental React channel", () => { preview: { port: 0 }, logLevel: "silent", }); + devServer = await createServer({ + root: FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: FIXTURE_DIR })], + server: { port: 0 }, + logLevel: "silent", + }); + await devServer.listen(); } catch (error) { fs.rmSync(DIST_DIR, { recursive: true, force: true }); fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); @@ -47,10 +57,14 @@ describe("App Router experimental React channel", () => { } const address = previewServer.httpServer.address(); baseUrl = address && typeof address === "object" ? `http://localhost:${address.port}` : ""; + const devAddress = devServer.httpServer?.address(); + devBaseUrl = + devAddress && typeof devAddress === "object" ? `http://localhost:${devAddress.port}` : ""; }, 120_000); - afterAll(() => { + afterAll(async () => { previewServer?.httpServer.close(); + await devServer?.close(); fs.rmSync(DIST_DIR, { recursive: true, force: true }); fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); }); @@ -61,7 +75,9 @@ describe("App Router experimental React channel", () => { ...html .replaceAll("", "") .matchAll(/(?:React|ReactDOM|ReactDOMServer)\.version=([^<]+)/g), - ].map((match) => match[1]); + ] + .map((match) => match[1]) + .filter((version) => /^\d+\.\d+\.\d+/.test(version)); expect(versions.length).toBeGreaterThanOrEqual(5); expect(versions.every((version) => version.includes("-experimental-"))).toBe(true); @@ -75,4 +91,32 @@ describe("App Router experimental React channel", () => { .join("\n"); expect(clientJavaScript).toContain("-experimental-"); }); + + it("uses the experimental React channel in the dev dependency optimizer", async () => { + const response = await fetch(devBaseUrl); + const html = await response.text(); + expect(response.status).toBe(200); + const versions = [ + ...html + .replaceAll("", "") + .matchAll(/(?:React|ReactDOM|ReactDOMServer)\.version=([^<]+)/g), + ] + .map((match) => match[1]) + .filter((version) => /^\d+\.\d+\.\d+/.test(version)); + + expect(versions.length).toBeGreaterThanOrEqual(5); + expect(versions).toEqual(versions.map(() => expect.stringContaining("-experimental-"))); + }); + + it("rejects ssr.external true because it bypasses experimental React", async () => { + await expect( + createServer({ + root: FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: FIXTURE_DIR })], + ssr: { external: true }, + logLevel: "silent", + }), + ).rejects.toThrow("`ssr.external: true` is incompatible"); + }); }); diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts index b8d58d89a3..f8c28cf3bd 100644 --- a/tests/experimental-react.test.ts +++ b/tests/experimental-react.test.ts @@ -99,7 +99,7 @@ describe("experimental React channel", () => { ); fs.writeFileSync( path.join(reactServerDomDir, "package.json"), - JSON.stringify({ exports: { "./server.edge": "./server.edge.js" } }), + JSON.stringify({ exports: { ".": "./index.js", "./server.edge": "./server.edge.js" } }), ); const packages = { react: reactDir, From d4072133e6ec1a918bd25bfec8468171a0c3ff51 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:20:51 +0100 Subject: [PATCH 04/16] fix(app-router): cover experimental React edge cases --- .../vinext/src/config/experimental-react.ts | 24 +++++++++++++++++ packages/vinext/src/index.ts | 26 ++++++++++++++++--- tests/app-router-experimental-react.test.ts | 16 +++++++++--- tests/experimental-react.test.ts | 16 ++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts index 4c199165a2..875905423a 100644 --- a/packages/vinext/src/config/experimental-react.ts +++ b/packages/vinext/src/config/experimental-react.ts @@ -5,6 +5,15 @@ import fs from "node:fs"; export type ExperimentalReactAliases = Record; export type ExperimentalReactEnvironment = "rsc" | "ssr" | "client"; export type ExperimentalReactAliasEntry = { find: RegExp; replacement: string }; +type EsbuildPlugin = { + name: string; + setup(build: { + onResolve( + options: { filter: RegExp }, + callback: (args: { path: string }) => { path: string } | undefined, + ): void; + }): void; +}; type PackageExport = string | PackageExportConditions; type PackageExportConditions = { @@ -147,6 +156,21 @@ export function createExperimentalReactEnvironmentAliases( ]; } +export function createExperimentalReactEsbuildPlugin( + aliases: ExperimentalReactAliasEntry[], + environment: ExperimentalReactEnvironment, +): EsbuildPlugin { + return { + name: `vinext:experimental-react-dep-optimize:${environment}`, + setup(build) { + build.onResolve({ filter: /^(?:react|next\/dist\/compiled\/react)/ }, (args) => { + const replacement = aliases.find(({ find }) => find.test(args.path))?.replacement; + return replacement ? { path: replacement } : undefined; + }); + }, + }; +} + function requireExperimentalReactSpecifier( packages: ExperimentalReactAliases, specifier: string, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 84660cce9e..0cd556f125 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -78,6 +78,7 @@ import { import { mergeServerExternalPackages } from "./config/server-external-packages.js"; import { createExperimentalReactEnvironmentAliases, + createExperimentalReactEsbuildPlugin, EXPERIMENTAL_REACT_SPECIFIERS, resolveExperimentalReactAliases, type ExperimentalReactAliasEntry, @@ -1457,9 +1458,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (hasAppDir && nextConfig.useExperimentalReact) { - if (config.ssr?.external === true) { + const configuredSsrExternal = config.ssr?.external; + if ( + configuredSsrExternal === true || + (Array.isArray(configuredSsrExternal) && + configuredSsrExternal.some( + (entry) => + typeof entry === "string" && + EXPERIMENTAL_REACT_SPECIFIERS.some( + (specifier) => entry === specifier || entry.startsWith(`${specifier}/`), + ), + )) + ) { throw new Error( - "[vinext] `ssr.external: true` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", + "[vinext] Externalizing React through `ssr.external` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", ); } const packages = resolveExperimentalReactAliases(root); @@ -5774,7 +5786,7 @@ export const loadServerActionClient = ${ } const aliases = experimentalReactAliases[name]; - if (name === "ssr") { + if (name === "rsc" || name === "ssr") { if (Array.isArray(config.resolve?.external)) { config.resolve.external = config.resolve.external.filter( (entry) => @@ -5813,6 +5825,14 @@ export const loadServerActionClient = ${ ...existingPlugins, ], }; + const esbuildOptions = config.optimizeDeps.esbuildOptions ?? {}; + config.optimizeDeps.esbuildOptions = { + ...esbuildOptions, + plugins: [ + createExperimentalReactEsbuildPlugin(aliases, name), + ...(esbuildOptions.plugins ?? []), + ], + }; return null; }, }); diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts index ebab91f4f1..a95c64b5b3 100644 --- a/tests/app-router-experimental-react.test.ts +++ b/tests/app-router-experimental-react.test.ts @@ -108,15 +108,23 @@ describe("App Router experimental React channel", () => { expect(versions).toEqual(versions.map(() => expect.stringContaining("-experimental-"))); }); - it("rejects ssr.external true because it bypasses experimental React", async () => { + async function expectExternalReactRejection(external: true | string[]) { await expect( createServer({ root: FIXTURE_DIR, configFile: false, - plugins: [vinext({ appDir: FIXTURE_DIR })], - ssr: { external: true }, + plugins: vinext({ appDir: FIXTURE_DIR }), + ssr: { external }, logLevel: "silent", }), - ).rejects.toThrow("`ssr.external: true` is incompatible"); + ).rejects.toThrow("Externalizing React through `ssr.external` is incompatible"); + } + + it("rejects ssr.external true because it bypasses experimental React", async () => { + await expectExternalReactRejection(true); + }); + + it("rejects React entries in ssr.external", async () => { + await expectExternalReactRejection(["react"]); }); }); diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts index f8c28cf3bd..12de78860e 100644 --- a/tests/experimental-react.test.ts +++ b/tests/experimental-react.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { createExperimentalReactEnvironmentAliases, + createExperimentalReactEsbuildPlugin, needsExperimentalReact, resolveExperimentalReactAliases, resolveExperimentalReactSpecifier, @@ -126,4 +127,19 @@ describe("experimental React channel", () => { )?.replacement, ).toBe(path.join(reactServerDomDir, "server.edge.js")); }); + + it("creates a Vite 7 esbuild optimizer resolver", () => { + const aliases = [{ find: /^react$/, replacement: "/next/react-experimental/index.js" }]; + let resolver: ((args: { path: string }) => { path: string } | undefined) | undefined; + createExperimentalReactEsbuildPlugin(aliases, "client").setup({ + onResolve(_options, callback) { + resolver = callback; + }, + }); + + expect(resolver?.({ path: "react" })).toEqual({ + path: "/next/react-experimental/index.js", + }); + expect(resolver?.({ path: "react-dom" })).toBeUndefined(); + }); }); From 308e99c8a56b2d10e85fa0b0a0f11f0d6457e406 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:29:54 +0100 Subject: [PATCH 05/16] fix(pages-router): share experimental React renderer in dev --- packages/vinext/src/server/dev-server.ts | 45 ++++++++++++++++++++---- tests/app-router-dev-server.test.ts | 35 ++++++++++++++---- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 9c697ca468..498f05a1e5 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -41,7 +41,6 @@ import { getScriptNonceFromNodeHeaderSources } from "./csp.js"; import { mergeRouteParamsIntoQuery, parseQueryString as parseQuery } from "../utils/query.js"; import path from "node:path"; import React from "react"; -import { renderToReadableStream } from "react-dom/server.edge"; import { logRequest, now } from "./request-log.js"; import { createValidFileMatcher, @@ -85,13 +84,23 @@ import { isUnknownRecord } from "../utils/record.js"; * boundaries to resolve via stream.allReady before collecting output. * Used for _document rendering and error pages (small, non-streaming). */ -async function renderToStringAsync(element: React.ReactElement): Promise { +type RenderToReadableStream = ( + element: React.ReactElement, +) => Promise & { allReady: Promise }>; + +async function renderToStringAsync( + renderToReadableStream: RenderToReadableStream, + element: React.ReactElement, +): Promise { const stream = await renderToReadableStream(element); await stream.allReady; return new Response(stream).text(); } -async function renderIsrPassToStringAsync(element: React.ReactElement): Promise { +async function renderIsrPassToStringAsync( + renderToReadableStream: RenderToReadableStream, + element: React.ReactElement, +): Promise { // The cache-fill render is a second render pass for the same request. // Reset render-scoped state so it cannot leak from the streamed response // render or affect async work that is still draining from that stream. @@ -100,7 +109,9 @@ async function renderIsrPassToStringAsync(element: React.ReactElement): Promise< return await runWithServerInsertedHTMLState(() => runWithHeadState(() => _runWithCacheState(() => - runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), + runWithPrivateCache(() => + runWithFetchCache(async () => renderToStringAsync(renderToReadableStream, element)), + ), ), ), ); @@ -212,6 +223,7 @@ async function streamPageToResponse( * into `getSSRHeadHTML()`'s output. Called before `getHeadHTML()`. */ setDocumentInitialHead?: (head: React.ReactNode[]) => void; + renderToReadableStream: RenderToReadableStream; /** Buffer the body before writing headers so error-page fallback remains safe. */ bufferBodyBeforeHeaders?: boolean; }, @@ -229,6 +241,7 @@ async function streamPageToResponse( scriptNonce, documentContext, setDocumentInitialHead, + renderToReadableStream, bufferBodyBeforeHeaders = false, } = options; @@ -243,7 +256,7 @@ async function streamPageToResponse( DocumentComponent, enhancePageElement, renderToReadableStream, - renderStylesToString: renderToStringAsync, + renderStylesToString: (element) => renderToStringAsync(renderToReadableStream, element), scriptNonce, context: documentContext, }); @@ -301,7 +314,7 @@ async function streamPageToResponse( const docElement = docProps ? React.createElement(DocumentComponent, docProps) : React.createElement(DocumentComponent); - let docHtml = await renderToStringAsync(docElement); + let docHtml = await renderToStringAsync(renderToReadableStream, docElement); // Replace __NEXT_MAIN__ with our stream marker docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER); // Inject head tags @@ -442,6 +455,14 @@ export function createSSRHandler( htmlLimitedBots?: string, ) { const matcher = fileMatcher ?? createValidFileMatcher(); + const reactDomServer = importModule(runner, "react-dom/server.edge"); + const renderToReadableStream: RenderToReadableStream = async (element) => { + const renderer = await reactDomServer; + if (typeof renderer.renderToReadableStream !== "function") { + throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); + } + return renderer.renderToReadableStream(element); + }; // Page route patterns in Next.js bracket format, sorted by specificity // (sortRoutes via pagesRouter). Mirrors the production client entry's @@ -1195,6 +1216,7 @@ export function createSSRHandler( el = routerShim.wrapWithRouterContext(el); } const freshBody = await renderIsrPassToStringAsync( + renderToReadableStream, withScriptNonce(el, scriptNonce), ); @@ -1782,6 +1804,7 @@ hydrate(); ? headShim.setDocumentInitialHead : undefined, bufferBodyBeforeHeaders: true, + renderToReadableStream, }); _renderEnd = now(); @@ -1805,6 +1828,7 @@ hydrate(); isrElement = wrapWithRouterContext(isrElement); } const isrBodyHtml = await renderIsrPassToStringAsync( + renderToReadableStream, withScriptNonce(isrElement, scriptNonce), ); const isrHtml = `
${isrBodyHtml}
${allScripts}`; @@ -1887,6 +1911,12 @@ async function renderErrorPage( err?: Error, ): Promise { const matcher = fileMatcher ?? createValidFileMatcher(); + const reactDomServer = await importModule(runner, "react-dom/server.edge"); + if (typeof reactDomServer.renderToReadableStream !== "function") { + throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); + } + const renderToReadableStream: RenderToReadableStream = + reactDomServer.renderToReadableStream.bind(reactDomServer); // Try specific status page first, then _error, then fallback const candidates = statusCode === 404 ? ["404", "_error"] : statusCode === 500 ? ["500", "_error"] : ["_error"]; @@ -2004,9 +2034,10 @@ async function renderErrorPage( typeof headShim.setDocumentInitialHead === "function" ? headShim.setDocumentInitialHead : undefined, + renderToReadableStream, }); } else { - const bodyHtml = await renderToStringAsync(element); + const bodyHtml = await renderToStringAsync(renderToReadableStream, element); const html = ` diff --git a/tests/app-router-dev-server.test.ts b/tests/app-router-dev-server.test.ts index 26b0fec500..0a8e1af4ad 100644 --- a/tests/app-router-dev-server.test.ts +++ b/tests/app-router-dev-server.test.ts @@ -2009,12 +2009,29 @@ describe("App Router integration", () => { const ssrInclude = server.config.environments.ssr?.optimizeDeps?.include; const clientInclude = server.config.environments.client?.optimizeDeps?.include; - // react-dom/server.edge should be present (added by @vitejs/plugin-rsc) - expect(ssrInclude).toContain("react-dom/server.edge"); + // Experimental React rewrites plugin-rsc's framework include to Next.js's + // vendored renderer so the optimizer and runtime share one React channel. + expect(ssrInclude).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /(?:react-dom\/server\.edge|react-dom-experimental\/server\.edge\.js)$/, + ), + ]), + ); - expect(clientInclude).toContain("react"); - expect(clientInclude).toContain("react-dom"); - expect(clientInclude).toContain("react-dom/client"); + for (const [specifier, experimentalFile] of [ + ["react", "react-experimental/index.js"], + ["react-dom", "react-dom-experimental/index.js"], + ["react-dom/client", "react-dom-experimental/client.js"], + ]) { + expect(clientInclude).toEqual( + expect.arrayContaining([ + expect.stringMatching( + new RegExp(`(?:${specifier.replace("/", "\\/")}|${experimentalFile})$`), + ), + ]), + ); + } }); it("drops unused NODE_ENV branches from optimized server dependencies", async () => { @@ -2050,7 +2067,13 @@ describe("App Router integration", () => { const optimizedDependencies = rscEnvironment.depsOptimizer?.metadata.depInfoList.map((dep) => dep.id) ?? []; - expect(optimizedDependencies).toContain("react-server-dom-webpack/static.edge"); + expect(optimizedDependencies).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /(?:react-server-dom-webpack\/static\.edge|react-server-dom-webpack-experimental\/static\.edge\.js)$/, + ), + ]), + ); }); // ── CSRF protection for server actions ─────────────────────────────── From 72a6b8648dfb196d625b47f109e42d37933dd75d Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:30:52 +0100 Subject: [PATCH 06/16] fix(app-router): reject external experimental React packages --- .../vinext/src/config/experimental-react.ts | 6 ++++++ packages/vinext/src/index.ts | 12 +++++------- tests/app-router-experimental-react.test.ts | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts index 875905423a..f1ffbd3e65 100644 --- a/packages/vinext/src/config/experimental-react.ts +++ b/packages/vinext/src/config/experimental-react.ts @@ -124,6 +124,12 @@ export const EXPERIMENTAL_REACT_SPECIFIERS = [ "react-server-dom-webpack/static.node", ] as const; +export function isExperimentalReactSpecifier(specifier: string): boolean { + return EXPERIMENTAL_REACT_SPECIFIERS.some( + (candidate) => specifier === candidate || specifier.startsWith(`${candidate}/`), + ); +} + export function createExperimentalReactEnvironmentAliases( packages: ExperimentalReactAliases, environment: ExperimentalReactEnvironment, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 0cd556f125..744b180ff7 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -80,6 +80,7 @@ import { createExperimentalReactEnvironmentAliases, createExperimentalReactEsbuildPlugin, EXPERIMENTAL_REACT_SPECIFIERS, + isExperimentalReactSpecifier, resolveExperimentalReactAliases, type ExperimentalReactAliasEntry, type ExperimentalReactEnvironment, @@ -1463,15 +1464,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { configuredSsrExternal === true || (Array.isArray(configuredSsrExternal) && configuredSsrExternal.some( - (entry) => - typeof entry === "string" && - EXPERIMENTAL_REACT_SPECIFIERS.some( - (specifier) => entry === specifier || entry.startsWith(`${specifier}/`), - ), - )) + (entry) => typeof entry === "string" && isExperimentalReactSpecifier(entry), + )) || + nextConfig.serverExternalPackages.some(isExperimentalReactSpecifier) ) { throw new Error( - "[vinext] Externalizing React through `ssr.external` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", + "[vinext] Externalizing React through `ssr.external` or `serverExternalPackages` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", ); } const packages = resolveExperimentalReactAliases(root); diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts index a95c64b5b3..99712a22f2 100644 --- a/tests/app-router-experimental-react.test.ts +++ b/tests/app-router-experimental-react.test.ts @@ -127,4 +127,21 @@ describe("App Router experimental React channel", () => { it("rejects React entries in ssr.external", async () => { await expectExternalReactRejection(["react"]); }); + + it("rejects React entries in serverExternalPackages", async () => { + await expect( + createServer({ + root: FIXTURE_DIR, + configFile: false, + plugins: vinext({ + appDir: FIXTURE_DIR, + nextConfig: { + experimental: { taint: true }, + serverExternalPackages: ["react-dom/server.edge"], + }, + }), + logLevel: "silent", + }), + ).rejects.toThrow("`serverExternalPackages` is incompatible"); + }); }); From 577264a6bf82e0872e50fb46b1087e13c8944537 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:34:05 +0100 Subject: [PATCH 07/16] fix(pages-router): preserve stable React dev renderer --- packages/vinext/src/index.ts | 1 + packages/vinext/src/server/dev-server.ts | 57 +++++++++++++++++------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 744b180ff7..7ab85899c8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4465,6 +4465,7 @@ export const loadServerActionClient = ${ (nextConfig?.rewrites.fallback.length ?? 0) > 0, nextConfig?.clientTraceMetadata, nextConfig?.htmlLimitedBots, + nextConfig?.useExperimentalReact, ), }; } diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 498f05a1e5..b7cffc419a 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -41,6 +41,7 @@ import { getScriptNonceFromNodeHeaderSources } from "./csp.js"; import { mergeRouteParamsIntoQuery, parseQueryString as parseQuery } from "../utils/query.js"; import path from "node:path"; import React from "react"; +import { renderToReadableStream as nativeRenderToReadableStream } from "react-dom/server.edge"; import { logRequest, now } from "./request-log.js"; import { createValidFileMatcher, @@ -88,6 +89,22 @@ type RenderToReadableStream = ( element: React.ReactElement, ) => Promise & { allReady: Promise }>; +function createPagesRenderToReadableStream( + runner: ModuleImporter, + useExperimentalReact: boolean, +): RenderToReadableStream { + if (!useExperimentalReact) return nativeRenderToReadableStream; + + const reactDomServer = importModule(runner, "react-dom/server.edge"); + return async (element) => { + const renderer = await reactDomServer; + if (typeof renderer.renderToReadableStream !== "function") { + throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); + } + return renderer.renderToReadableStream(element); + }; +} + async function renderToStringAsync( renderToReadableStream: RenderToReadableStream, element: React.ReactElement, @@ -453,16 +470,10 @@ export function createSSRHandler( */ clientTraceMetadata?: readonly string[], htmlLimitedBots?: string, + useExperimentalReact = false, ) { const matcher = fileMatcher ?? createValidFileMatcher(); - const reactDomServer = importModule(runner, "react-dom/server.edge"); - const renderToReadableStream: RenderToReadableStream = async (element) => { - const renderer = await reactDomServer; - if (typeof renderer.renderToReadableStream !== "function") { - throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); - } - return renderer.renderToReadableStream(element); - }; + const renderToReadableStream = createPagesRenderToReadableStream(runner, useExperimentalReact); // Page route patterns in Next.js bracket format, sorted by specificity // (sortRoutes via pagesRouter). Mirrors the production client entry's @@ -586,7 +597,19 @@ export function createSSRHandler( return; } // No route matched — try to render custom 404 page - await renderErrorPage(server, runner, req, res, url, pagesDir, 404, undefined, matcher); + await renderErrorPage( + server, + runner, + req, + res, + url, + pagesDir, + 404, + undefined, + matcher, + undefined, + renderToReadableStream, + ); return; } @@ -783,6 +806,8 @@ export function createSSRHandler( 404, routerShim.wrapWithRouterContext, matcher, + undefined, + renderToReadableStream, ); return; } @@ -930,6 +955,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + renderToReadableStream, ); return; } @@ -1367,6 +1395,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + renderToReadableStream, ); return; } @@ -1875,6 +1906,7 @@ hydrate(); undefined, matcher, e instanceof Error ? e : new Error(String(e)), + renderToReadableStream, ); } catch (fallbackErr) { // If error page itself fails, fall back to plain text. @@ -1909,14 +1941,9 @@ async function renderErrorPage( wrapWithRouterContext?: ((el: React.ReactElement) => React.ReactElement) | null, fileMatcher?: ValidFileMatcher, err?: Error, + renderToReadableStream: RenderToReadableStream = nativeRenderToReadableStream, ): Promise { const matcher = fileMatcher ?? createValidFileMatcher(); - const reactDomServer = await importModule(runner, "react-dom/server.edge"); - if (typeof reactDomServer.renderToReadableStream !== "function") { - throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); - } - const renderToReadableStream: RenderToReadableStream = - reactDomServer.renderToReadableStream.bind(reactDomServer); // Try specific status page first, then _error, then fallback const candidates = statusCode === 404 ? ["404", "_error"] : statusCode === 500 ? ["500", "_error"] : ["_error"]; From fbbdb9a3cd822b927fdb703dd3b35d0cf83b1199 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:36:52 +0100 Subject: [PATCH 08/16] test(app-router): match experimental React external error --- tests/app-router-experimental-react.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts index 99712a22f2..1d800cbb5d 100644 --- a/tests/app-router-experimental-react.test.ts +++ b/tests/app-router-experimental-react.test.ts @@ -117,7 +117,7 @@ describe("App Router experimental React channel", () => { ssr: { external }, logLevel: "silent", }), - ).rejects.toThrow("Externalizing React through `ssr.external` is incompatible"); + ).rejects.toThrow("Externalizing React through `ssr.external` or `serverExternalPackages`"); } it("rejects ssr.external true because it bypasses experimental React", async () => { From 85932ec682c4b8d784b133157eaef040fcb092b6 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 09:37:26 +0100 Subject: [PATCH 09/16] fix(pages-router): gate experimental renderer on aliases --- packages/vinext/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7ab85899c8..6e02ad32df 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4465,7 +4465,7 @@ export const loadServerActionClient = ${ (nextConfig?.rewrites.fallback.length ?? 0) > 0, nextConfig?.clientTraceMetadata, nextConfig?.htmlLimitedBots, - nextConfig?.useExperimentalReact, + experimentalReactAliases !== null, ), }; } From dd68992044c3deaaea2675898c08671f6e542f30 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:13:23 +0100 Subject: [PATCH 10/16] fix(app-router): interop experimental React in dev --- .../vinext/src/config/experimental-react.ts | 13 +++++++++ packages/vinext/src/index.ts | 16 +++++++++++ tests/experimental-react.test.ts | 27 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts index f1ffbd3e65..2e1c367470 100644 --- a/packages/vinext/src/config/experimental-react.ts +++ b/packages/vinext/src/config/experimental-react.ts @@ -54,6 +54,7 @@ export function resolveExperimentalReactAliases(root: string): ExperimentalReact react: path.join(compiledDir, "react-experimental"), "react-dom": path.join(compiledDir, "react-dom-experimental"), "react-server-dom-webpack": path.join(compiledDir, "react-server-dom-webpack-experimental"), + scheduler: path.join(compiledDir, "scheduler-experimental"), }; for (const [specifier, packageDir] of Object.entries(packages)) { @@ -95,6 +96,14 @@ export function resolveExperimentalReactSpecifier( return target ? path.join(packageDir, target) : null; } +export function resolveExperimentalReactDevelopmentEntry(entry: string): string { + const code = fs.readFileSync(entry, "utf8"); + const developmentRequire = code.match( + /process\.env\.NODE_ENV\s*===\s*["']production["'][\s\S]*?else\s*\{\s*module\.exports\s*=\s*require\(["'](\.\/cjs\/[^"']+\.development\.js)["']\)/, + )?.[1]; + return developmentRequire ? path.resolve(path.dirname(entry), developmentRequire) : entry; +} + export const EXPERIMENTAL_REACT_SPECIFIERS = [ "react", "react/compiler-runtime", @@ -159,6 +168,10 @@ export function createExperimentalReactEnvironmentAliases( environment, ), }, + { + find: /^next\/dist\/compiled\/scheduler-experimental$/, + replacement: path.join(packages.scheduler, "index.js"), + }, ]; } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6e02ad32df..190950aed5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -82,6 +82,7 @@ import { EXPERIMENTAL_REACT_SPECIFIERS, isExperimentalReactSpecifier, resolveExperimentalReactAliases, + resolveExperimentalReactDevelopmentEntry, type ExperimentalReactAliasEntry, type ExperimentalReactEnvironment, } from "./config/experimental-react.js"; @@ -895,6 +896,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ExperimentalReactEnvironment, ExperimentalReactAliasEntry[] > | null = null; + const experimentalReactClientDevelopmentEntries = new Set(); let experimentalReactFlightDir: string | null = null; let fileMatcher: ReturnType; let middlewarePath: string | null = null; @@ -908,6 +910,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // initializer guards any unexpected hook ordering. let clientAssetsInlineLimit: NonNullable["assetsInlineLimit"] = 0; let hasCloudflarePlugin = false; + let isServeCommand = false; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; let rscCompatibilityId: string | undefined; @@ -1208,6 +1211,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return null; } const alias = experimentalReactAliases[environment].find(({ find }) => find.test(id)); + if (alias && environment === "client" && isServeCommand) { + const developmentEntry = resolveExperimentalReactDevelopmentEntry(alias.replacement); + experimentalReactClientDevelopmentEntries.add(normalizePathSeparators(developmentEntry)); + return developmentEntry; + } return alias?.replacement ?? null; }, transform: { @@ -1234,6 +1242,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Transform CJS require()/module.exports to ESM before other plugins // analyze imports (RSC directive scanning, shim resolution, etc.) commonjs(), + commonjs({ + filter(id: string) { + return experimentalReactClientDevelopmentEntries.has( + normalizePathSeparators(stripViteModuleQuery(id)), + ); + }, + }), // Enable JSX in plain .js files. Next.js allows JSX in .js files // (Babel/SWC handle it transparently), but Vite 8's built-in `vite:oxc` // plugin excludes .js files by default (`exclude: /\.js$/`) AND infers @@ -1337,6 +1352,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { enforce: "pre", async config(config, env) { + isServeCommand = env.command === "serve"; root = normalizePathSeparators(config.root ?? process.cwd()); const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; const shouldEnableNativeTsconfigPaths = diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts index 12de78860e..2b053e96f3 100644 --- a/tests/experimental-react.test.ts +++ b/tests/experimental-react.test.ts @@ -7,6 +7,7 @@ import { createExperimentalReactEsbuildPlugin, needsExperimentalReact, resolveExperimentalReactAliases, + resolveExperimentalReactDevelopmentEntry, resolveExperimentalReactSpecifier, } from "../packages/vinext/src/config/experimental-react.js"; @@ -41,6 +42,7 @@ describe("experimental React channel", () => { "react-experimental", "react-dom-experimental", "react-server-dom-webpack-experimental", + "scheduler-experimental", ]) { const packageDir = path.join(nextDir, "dist", "compiled", packageName); fs.mkdirSync(packageDir, { recursive: true }); @@ -58,6 +60,7 @@ describe("experimental React channel", () => { "compiled", "react-server-dom-webpack-experimental", ), + scheduler: path.join(canonicalNextDir, "dist", "compiled", "scheduler-experimental"), }); }); @@ -77,9 +80,11 @@ describe("experimental React channel", () => { const reactDir = path.join(root, "react-experimental"); const reactDomDir = path.join(root, "react-dom-experimental"); const reactServerDomDir = path.join(root, "react-server-dom-webpack-experimental"); + const schedulerDir = path.join(root, "scheduler-experimental"); fs.mkdirSync(reactDir, { recursive: true }); fs.mkdirSync(reactDomDir, { recursive: true }); fs.mkdirSync(reactServerDomDir, { recursive: true }); + fs.mkdirSync(schedulerDir, { recursive: true }); fs.writeFileSync( path.join(reactDir, "package.json"), JSON.stringify({ @@ -106,6 +111,7 @@ describe("experimental React channel", () => { react: reactDir, "react-dom": reactDomDir, "react-server-dom-webpack": reactServerDomDir, + scheduler: schedulerDir, }; expect(resolveExperimentalReactSpecifier(packages, "react", "rsc")).toBe( @@ -126,6 +132,27 @@ describe("experimental React channel", () => { find.test("react-server-dom-webpack/server.edge"), )?.replacement, ).toBe(path.join(reactServerDomDir, "server.edge.js")); + expect( + clientAliases.find(({ find }) => find.test("next/dist/compiled/scheduler-experimental")), + ).toEqual({ + find: /^next\/dist\/compiled\/scheduler-experimental$/, + replacement: path.join(schedulerDir, "index.js"), + }); + }); + + it("unwraps vendored development channel selectors for client dev", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-entry-")); + tempDirs.push(root); + const entry = path.join(root, "index.js"); + fs.mkdirSync(path.join(root, "cjs")); + fs.writeFileSync( + entry, + `if (process.env.NODE_ENV === 'production') {\n module.exports = require('./cjs/runtime.production.js')\n} else {\n module.exports = require('./cjs/runtime.development.js')\n}`, + ); + + expect(resolveExperimentalReactDevelopmentEntry(entry)).toBe( + path.join(root, "cjs", "runtime.development.js"), + ); }); it("creates a Vite 7 esbuild optimizer resolver", () => { From 03d23d5ca2961d0152d809d85638320ef17cd925 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:19:14 +0100 Subject: [PATCH 11/16] test(app-router): allow React dev eval in CSP fixture --- tests/e2e/app-router/nextjs-compat/csp.spec.ts | 6 +++--- tests/fixtures/app-basic/middleware.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/app-router/nextjs-compat/csp.spec.ts b/tests/e2e/app-router/nextjs-compat/csp.spec.ts index 04fc98c3d7..555ec848d5 100644 --- a/tests/e2e/app-router/nextjs-compat/csp.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/csp.spec.ts @@ -27,7 +27,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); await waitForAppRouterHydration(page); @@ -45,7 +45,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); await waitForAppRouterHydration(page); @@ -73,7 +73,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); await waitForAppRouterHydration(page); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 4a7ca1b320..299cd97ccf 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -292,7 +292,7 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { ) { r.headers.set( "content-security-policy", - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); } if ( @@ -329,7 +329,7 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { ) { r.headers.set( "content-security-policy", - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); } r.headers.set("x-mw-pathname", pathname); From 737aaec15cd189c33cee969b805ca691402f201d Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:22:34 +0100 Subject: [PATCH 12/16] test(app-router): align dynamic CSP expectation --- tests/nextjs-compat/dynamic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/nextjs-compat/dynamic.test.ts b/tests/nextjs-compat/dynamic.test.ts index 7eb211a877..62a16d3ce0 100644 --- a/tests/nextjs-compat/dynamic.test.ts +++ b/tests/nextjs-compat/dynamic.test.ts @@ -144,7 +144,7 @@ describe("Next.js compat: next/dynamic", () => { it("SSR: adds nonce attributes to preload links when next/dynamic runs under CSP", async () => { const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/dynamic?csp-nonce=1"); expect(res.headers.get("content-security-policy")).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); const preloadLinks = [ From 4537fd4b5e9321e6fc1d8ed6b3a4e1903f714e90 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:25:36 +0100 Subject: [PATCH 13/16] test(app-router): align navigation CSP expectation --- tests/nextjs-compat/use-client-page-pathname.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/nextjs-compat/use-client-page-pathname.test.ts b/tests/nextjs-compat/use-client-page-pathname.test.ts index 0cbc8ba050..048af890f0 100644 --- a/tests/nextjs-compat/use-client-page-pathname.test.ts +++ b/tests/nextjs-compat/use-client-page-pathname.test.ts @@ -105,7 +105,7 @@ describe('"use client" page component: usePathname() SSR (issue #688)', () => { it("adds CSP nonce to inline hydration and bootstrap scripts", async () => { const res = await fetch(`${_baseUrl}${ROUTE}?csp-nonce=1`); expect(res.headers.get("content-security-policy")).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", ); const html = await res.text(); From d39f2bcaf1378ba999bb9546d5b5479e500bb369 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:31:48 +0100 Subject: [PATCH 14/16] test(app-router): scope unsafe eval CSP to dev --- tests/fixtures/app-basic/middleware.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 299cd97ccf..db2778843b 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -290,9 +290,10 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { pathname.startsWith("/use-client-page-pathname") && request.nextUrl.searchParams.has("csp-nonce") ) { + const devEvalSource = process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""; r.headers.set( "content-security-policy", - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + `script-src 'nonce-vinext-test-nonce' 'strict-dynamic'${devEvalSource};`, ); } if ( @@ -327,9 +328,10 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { pathname.startsWith("/nextjs-compat/dynamic") && request.nextUrl.searchParams.has("csp-nonce") ) { + const devEvalSource = process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""; r.headers.set( "content-security-policy", - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + `script-src 'nonce-vinext-test-nonce' 'strict-dynamic'${devEvalSource};`, ); } r.headers.set("x-mw-pathname", pathname); From 85cdd07a5aeebadf94f44d9a611a94bc119b5d00 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:42:51 +0100 Subject: [PATCH 15/16] fix(pages-router): preserve experimental React channel --- packages/vinext/src/index.ts | 2 +- tests/app-router-experimental-react.test.ts | 32 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 190950aed5..a3d9c9a09d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1474,7 +1474,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig = { ...nextConfig, buildId: sharedBuildId }; } - if (hasAppDir && nextConfig.useExperimentalReact) { + if (nextConfig.useExperimentalReact) { const configuredSsrExternal = config.ssr?.external; if ( configuredSsrExternal === true || diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts index 1d800cbb5d..b50da0cea4 100644 --- a/tests/app-router-experimental-react.test.ts +++ b/tests/app-router-experimental-react.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { createBuilder, createServer, preview, type ViteDevServer } from "vite"; +import { createBuilder, createServer, preview, resolveConfig, type ViteDevServer } from "vite"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import vinext from "../packages/vinext/src/index.js"; @@ -108,6 +108,36 @@ describe("App Router experimental React channel", () => { expect(versions).toEqual(versions.map(() => expect.stringContaining("-experimental-"))); }); + it("keeps the experimental React channel in router-disabled hybrid Pages builds", async () => { + const config = await resolveConfig( + { + root: FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: FIXTURE_DIR, disableAppRouter: true })], + logLevel: "silent", + }, + "build", + ); + + expect(config.define?.["process.env.__NEXT_EXPERIMENTAL_REACT"]).toBe('"true"'); + const channelPlugin = config.plugins.find( + (plugin) => plugin.name === "vinext:experimental-react-channel", + ); + const resolveId = + typeof channelPlugin?.resolveId === "function" + ? channelPlugin.resolveId + : channelPlugin?.resolveId?.handler; + const resolvedReact = await resolveId?.call( + { environment: { name: "ssr" } } as unknown as ThisParameterType< + NonNullable + >, + "react", + undefined, + { isEntry: false }, + ); + expect(resolvedReact).toContain("/next/dist/compiled/react-experimental/"); + }); + async function expectExternalReactRejection(external: true | string[]) { await expect( createServer({ From 4a7914196a7a42e8f670d0c4fa51918c2b860516 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 10:47:48 +0100 Subject: [PATCH 16/16] revert: experimental React channel parity attempt --- .../vinext/src/config/experimental-react.ts | 217 ------------------ packages/vinext/src/config/next-config.ts | 5 - packages/vinext/src/index.ts | 159 +------------ packages/vinext/src/server/dev-server.ts | 74 +----- tests/app-router-dev-server.test.ts | 35 +-- tests/app-router-experimental-react.test.ts | 177 -------------- .../e2e/app-router/nextjs-compat/csp.spec.ts | 6 +- tests/experimental-react.test.ts | 172 -------------- tests/fixtures/app-basic/middleware.ts | 6 +- .../app/client-react.tsx | 21 -- .../app-experimental-react/app/layout.tsx | 7 - .../app-experimental-react/app/page.tsx | 13 -- .../app-experimental-react/next.config.ts | 5 - .../app-experimental-react/package.json | 4 - tests/next-config.test.ts | 1 - tests/nextjs-compat/dynamic.test.ts | 2 +- .../use-client-page-pathname.test.ts | 2 +- 17 files changed, 22 insertions(+), 884 deletions(-) delete mode 100644 packages/vinext/src/config/experimental-react.ts delete mode 100644 tests/app-router-experimental-react.test.ts delete mode 100644 tests/experimental-react.test.ts delete mode 100644 tests/fixtures/app-experimental-react/app/client-react.tsx delete mode 100644 tests/fixtures/app-experimental-react/app/layout.tsx delete mode 100644 tests/fixtures/app-experimental-react/app/page.tsx delete mode 100644 tests/fixtures/app-experimental-react/next.config.ts delete mode 100644 tests/fixtures/app-experimental-react/package.json diff --git a/packages/vinext/src/config/experimental-react.ts b/packages/vinext/src/config/experimental-react.ts deleted file mode 100644 index 2e1c367470..0000000000 --- a/packages/vinext/src/config/experimental-react.ts +++ /dev/null @@ -1,217 +0,0 @@ -import path from "node:path"; -import { createRequire } from "node:module"; -import fs from "node:fs"; - -export type ExperimentalReactAliases = Record; -export type ExperimentalReactEnvironment = "rsc" | "ssr" | "client"; -export type ExperimentalReactAliasEntry = { find: RegExp; replacement: string }; -type EsbuildPlugin = { - name: string; - setup(build: { - onResolve( - options: { filter: RegExp }, - callback: (args: { path: string }) => { path: string } | undefined, - ): void; - }): void; -}; - -type PackageExport = string | PackageExportConditions; -type PackageExportConditions = { - [condition: string]: PackageExport; -}; - -export function needsExperimentalReact(experimental: Record | undefined): boolean { - return ( - experimental?.taint === true || - experimental?.transitionIndicator === true || - experimental?.gestureTransition === true - ); -} - -export function resolveExperimentalReactAliases(root: string): ExperimentalReactAliases { - const requireRoots = [root, process.cwd()]; - let nextPackagePath: string | undefined; - let cause: unknown; - for (const requireRoot of requireRoots) { - try { - nextPackagePath = createRequire(path.join(requireRoot, "package.json")).resolve( - "next/package.json", - ); - break; - } catch (error) { - cause = error; - } - } - if (!nextPackagePath) { - throw new Error( - "[vinext] React's experimental channel requires the project's installed `next` package.", - { cause }, - ); - } - - const compiledDir = path.join(path.dirname(nextPackagePath), "dist", "compiled"); - const packages = { - react: path.join(compiledDir, "react-experimental"), - "react-dom": path.join(compiledDir, "react-dom-experimental"), - "react-server-dom-webpack": path.join(compiledDir, "react-server-dom-webpack-experimental"), - scheduler: path.join(compiledDir, "scheduler-experimental"), - }; - - for (const [specifier, packageDir] of Object.entries(packages)) { - if (!fs.existsSync(path.join(packageDir, "package.json"))) { - throw new Error( - `[vinext] The installed Next.js package does not include the experimental ${specifier} runtime.`, - ); - } - } - - return packages; -} - -export function resolveExperimentalReactSpecifier( - packages: ExperimentalReactAliases, - specifier: string, - environment: "rsc" | "ssr" | "client", -): string | null { - const packageName = Object.keys(packages).find( - (candidate) => specifier === candidate || specifier.startsWith(`${candidate}/`), - ); - if (!packageName) return null; - - const packageDir = packages[packageName]; - const packageJson = JSON.parse( - fs.readFileSync(path.join(packageDir, "package.json"), "utf8"), - ) as { exports?: Record }; - const exportKey = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`; - const packageExport = packageJson.exports?.[exportKey]; - if (!packageExport) return null; - - const conditions = - environment === "rsc" - ? ["react-server", "workerd", "edge-light", "node", "browser", "default"] - : environment === "client" - ? ["browser", "worker", "default"] - : ["workerd", "edge-light", "node", "default"]; - const target = selectPackageExport(packageExport, conditions); - return target ? path.join(packageDir, target) : null; -} - -export function resolveExperimentalReactDevelopmentEntry(entry: string): string { - const code = fs.readFileSync(entry, "utf8"); - const developmentRequire = code.match( - /process\.env\.NODE_ENV\s*===\s*["']production["'][\s\S]*?else\s*\{\s*module\.exports\s*=\s*require\(["'](\.\/cjs\/[^"']+\.development\.js)["']\)/, - )?.[1]; - return developmentRequire ? path.resolve(path.dirname(entry), developmentRequire) : entry; -} - -export const EXPERIMENTAL_REACT_SPECIFIERS = [ - "react", - "react/compiler-runtime", - "react/jsx-dev-runtime", - "react/jsx-runtime", - "react-dom", - "react-dom/client", - "react-dom/server", - "react-dom/server.browser", - "react-dom/server.edge", - "react-dom/server.node", - "react-dom/static", - "react-dom/static.browser", - "react-dom/static.edge", - "react-dom/static.node", - "react-server-dom-webpack/client", - "react-server-dom-webpack/client.browser", - "react-server-dom-webpack/client.edge", - "react-server-dom-webpack/client.node", - "react-server-dom-webpack/server", - "react-server-dom-webpack/server.browser", - "react-server-dom-webpack/server.edge", - "react-server-dom-webpack/server.node", - "react-server-dom-webpack/static", - "react-server-dom-webpack/static.browser", - "react-server-dom-webpack/static.edge", - "react-server-dom-webpack/static.node", -] as const; - -export function isExperimentalReactSpecifier(specifier: string): boolean { - return EXPERIMENTAL_REACT_SPECIFIERS.some( - (candidate) => specifier === candidate || specifier.startsWith(`${candidate}/`), - ); -} - -export function createExperimentalReactEnvironmentAliases( - packages: ExperimentalReactAliases, - environment: ExperimentalReactEnvironment, -): ExperimentalReactAliasEntry[] { - const packageAliases = EXPERIMENTAL_REACT_SPECIFIERS.flatMap((specifier) => { - const replacement = resolveExperimentalReactSpecifier(packages, specifier, environment); - return replacement - ? [{ find: new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`), replacement }] - : []; - }); - - return [ - ...packageAliases, - { - find: /^next\/dist\/compiled\/react-experimental$/, - replacement: requireExperimentalReactSpecifier(packages, "react", environment), - }, - { - find: /^next\/dist\/compiled\/react-dom-experimental$/, - replacement: requireExperimentalReactSpecifier(packages, "react-dom", environment), - }, - { - find: /^next\/dist\/compiled\/react-server-dom-webpack-experimental$/, - replacement: requireExperimentalReactSpecifier( - packages, - "react-server-dom-webpack", - environment, - ), - }, - { - find: /^next\/dist\/compiled\/scheduler-experimental$/, - replacement: path.join(packages.scheduler, "index.js"), - }, - ]; -} - -export function createExperimentalReactEsbuildPlugin( - aliases: ExperimentalReactAliasEntry[], - environment: ExperimentalReactEnvironment, -): EsbuildPlugin { - return { - name: `vinext:experimental-react-dep-optimize:${environment}`, - setup(build) { - build.onResolve({ filter: /^(?:react|next\/dist\/compiled\/react)/ }, (args) => { - const replacement = aliases.find(({ find }) => find.test(args.path))?.replacement; - return replacement ? { path: replacement } : undefined; - }); - }, - }; -} - -function requireExperimentalReactSpecifier( - packages: ExperimentalReactAliases, - specifier: string, - environment: ExperimentalReactEnvironment, -): string { - const resolved = resolveExperimentalReactSpecifier(packages, specifier, environment); - if (!resolved) { - throw new Error( - `[vinext] Next.js's experimental React runtime does not export ${specifier} for the ${environment} environment.`, - ); - } - return resolved; -} - -function selectPackageExport( - packageExport: PackageExport, - conditions: readonly string[], -): string | null { - if (typeof packageExport === "string") return packageExport; - for (const condition of conditions) { - const target = packageExport[condition]; - if (target) return selectPackageExport(target, conditions); - } - return null; -} diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 6cbd4c52b6..b9e0b7e953 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -18,7 +18,6 @@ import { applyLocaleToRoutes, isExternalUrl } from "./config-matchers.js"; import { loadTsconfigResolutionForRoot } from "./tsconfig-paths.js"; import { getViteMajorVersion } from "../utils/vite-version.js"; import { loadCommonJsModule, shouldRetryAsCommonJs } from "../utils/commonjs-loader.js"; -import { needsExperimentalReact } from "./experimental-react.js"; /** * Parse a body size limit value (string or number) into bytes. @@ -357,8 +356,6 @@ export type ResolvedNextConfig = { * `useRouter().experimental_gesturePush()`. */ gestureTransition: boolean; - /** Whether App Router environments should use Next.js's vendored experimental React channel. */ - useExperimentalReact: boolean; /** * Whether `experimental.prefetchInlining` is configured. Next.js uses this * with the Segment Cache to fetch the route tree before the bundled inlined @@ -1338,7 +1335,6 @@ export async function resolveNextConfig( cacheComponents: false, appNavFailHandling: false, gestureTransition: false, - useExperimentalReact: false, prefetchInlining: false, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, @@ -1663,7 +1659,6 @@ export async function resolveNextConfig( cacheComponents: config.cacheComponents ?? false, appNavFailHandling: experimental?.appNavFailHandling === true, gestureTransition: experimental?.gestureTransition === true, - useExperimentalReact: needsExperimentalReact(experimental), prefetchInlining, redirects, rewrites, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a3d9c9a09d..5919b80804 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -76,16 +76,6 @@ import { type ResolvedNextConfig, } from "./config/next-config.js"; import { mergeServerExternalPackages } from "./config/server-external-packages.js"; -import { - createExperimentalReactEnvironmentAliases, - createExperimentalReactEsbuildPlugin, - EXPERIMENTAL_REACT_SPECIFIERS, - isExperimentalReactSpecifier, - resolveExperimentalReactAliases, - resolveExperimentalReactDevelopmentEntry, - type ExperimentalReactAliasEntry, - type ExperimentalReactEnvironment, -} from "./config/experimental-react.js"; import { findMiddlewareFile, isProxyFile, runMiddleware } from "./server/middleware.js"; import { isNextDataPathname, parseNextDataPathname } from "./server/pages-data-route.js"; @@ -892,12 +882,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let hasAppDir = false; let hasPagesDir = false; let nextConfig: ResolvedNextConfig; - let experimentalReactAliases: Record< - ExperimentalReactEnvironment, - ExperimentalReactAliasEntry[] - > | null = null; - const experimentalReactClientDevelopmentEntries = new Set(); - let experimentalReactFlightDir: string | null = null; let fileMatcher: ReturnType; let middlewarePath: string | null = null; let instrumentationPath: string | null = null; @@ -910,7 +894,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // initializer guards any unexpected hook ordering. let clientAssetsInlineLimit: NonNullable["assetsInlineLimit"] = 0; let hasCloudflarePlugin = false; - let isServeCommand = false; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; let rscCompatibilityId: string | undefined; @@ -1199,56 +1182,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ...(viteMajorVersion >= 8 ? [] : [loadVite7TsconfigPathsPlugin(earlyBaseDir)]), // React Fast Refresh + JSX transform for client components. reactPluginPromise, - { - name: "vinext:experimental-react-channel", - enforce: "pre", - resolveId(id) { - const environment = this.environment?.name; - if ( - !experimentalReactAliases || - (environment !== "rsc" && environment !== "ssr" && environment !== "client") - ) { - return null; - } - const alias = experimentalReactAliases[environment].find(({ find }) => find.test(id)); - if (alias && environment === "client" && isServeCommand) { - const developmentEntry = resolveExperimentalReactDevelopmentEntry(alias.replacement); - experimentalReactClientDevelopmentEntries.add(normalizePathSeparators(developmentEntry)); - return developmentEntry; - } - return alias?.replacement ?? null; - }, - transform: { - filter: { code: "globalThis.__next_require__" }, - handler(code, id) { - const cleanId = normalizePathSeparators(id.split("?", 1)[0]); - if ( - !experimentalReactFlightDir || - !cleanId.startsWith(`${experimentalReactFlightDir}/`) || - !code.includes("globalThis.__next_require__") - ) { - return null; - } - return { - code: code.replaceAll("globalThis.__next_require__", "__vite_rsc_require__"), - map: null, - }; - }, - }, - }, // Next.js ignores requests without any statically known path component // during graph analysis and leaves a deterministic runtime failure. createIgnoreDynamicRequestsPlugin(() => nextConfig?.turbopackTranspilePackages ?? []), // Transform CJS require()/module.exports to ESM before other plugins // analyze imports (RSC directive scanning, shim resolution, etc.) commonjs(), - commonjs({ - filter(id: string) { - return experimentalReactClientDevelopmentEntries.has( - normalizePathSeparators(stripViteModuleQuery(id)), - ); - }, - }), // Enable JSX in plain .js files. Next.js allows JSX in .js files // (Babel/SWC handle it transparently), but Vite 8's built-in `vite:oxc` // plugin excludes .js files by default (`exclude: /\.js$/`) AND infers @@ -1352,7 +1291,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { enforce: "pre", async config(config, env) { - isServeCommand = env.command === "serve"; root = normalizePathSeparators(config.root ?? process.cwd()); const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; const shouldEnableNativeTsconfigPaths = @@ -1473,31 +1411,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (sharedBuildId && sharedBuildId.length > 0) { nextConfig = { ...nextConfig, buildId: sharedBuildId }; } - - if (nextConfig.useExperimentalReact) { - const configuredSsrExternal = config.ssr?.external; - if ( - configuredSsrExternal === true || - (Array.isArray(configuredSsrExternal) && - configuredSsrExternal.some( - (entry) => typeof entry === "string" && isExperimentalReactSpecifier(entry), - )) || - nextConfig.serverExternalPackages.some(isExperimentalReactSpecifier) - ) { - throw new Error( - "[vinext] Externalizing React through `ssr.external` or `serverExternalPackages` is incompatible with Next.js's experimental React channel because it bypasses the vendored React runtime.", - ); - } - const packages = resolveExperimentalReactAliases(root); - experimentalReactAliases = { - rsc: createExperimentalReactEnvironmentAliases(packages, "rsc"), - ssr: createExperimentalReactEnvironmentAliases(packages, "ssr"), - client: createExperimentalReactEnvironmentAliases(packages, "client"), - }; - experimentalReactFlightDir = normalizePathSeparators( - packages["react-server-dom-webpack"], - ); - } } // RSC-compat ID coordination across plugin instances — same rationale as // the build ID above. createRscCompatibilityId() falls back to a random @@ -1551,9 +1464,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (key === "NODE_ENV") continue; defines[`process.env.${key}`] = JSON.stringify(value); } - if (experimentalReactAliases) { - defines["process.env.__NEXT_EXPERIMENTAL_REACT"] = JSON.stringify("true"); - } // Expose basePath to client-side code defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify(nextConfig.basePath); // Let shared client shims compile out Pages-only behavior in pure App @@ -2372,10 +2282,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? true : nextServerExternal; const externalizeSsrReactInDev = - env.command === "serve" && - !hasCloudflarePlugin && - !hasNitroPlugin && - !experimentalReactAliases; + env.command === "serve" && !hasCloudflarePlugin && !hasNitroPlugin; // Capture top-level optimizeDeps populated by earlier plugins // (e.g. @lingui/vite-plugin) so we merge rather than overwrite. @@ -4481,7 +4388,6 @@ export const loadServerActionClient = ${ (nextConfig?.rewrites.fallback.length ?? 0) > 0, nextConfig?.clientTraceMetadata, nextConfig?.htmlLimitedBots, - experimentalReactAliases !== null, ), }; } @@ -5788,69 +5694,6 @@ export const loadServerActionClient = ${ // Append auto-injected RSC plugins if applicable if (rscPluginPromise) { plugins.push(rscPluginPromise); - plugins.push({ - name: "vinext:experimental-react-dev-optimizer", - enforce: "post", - configEnvironment(name, config, env) { - if ( - env.command !== "serve" || - !experimentalReactAliases || - (name !== "rsc" && name !== "ssr" && name !== "client") - ) { - return null; - } - - const aliases = experimentalReactAliases[name]; - if (name === "rsc" || name === "ssr") { - if (Array.isArray(config.resolve?.external)) { - config.resolve.external = config.resolve.external.filter( - (entry) => - typeof entry !== "string" || - !EXPERIMENTAL_REACT_SPECIFIERS.some( - (specifier) => entry === specifier || entry.startsWith(`${specifier}/`), - ), - ); - } - } - config.optimizeDeps ??= {}; - config.optimizeDeps.include = config.optimizeDeps.include?.map((specifier) => { - const normalizedSpecifier = specifier.replace( - "@vitejs/plugin-rsc/vendor/react-server-dom/", - "react-server-dom-webpack/", - ); - return ( - aliases.find(({ find }) => find.test(normalizedSpecifier))?.replacement ?? specifier - ); - }); - const rolldownOptions = config.optimizeDeps.rolldownOptions ?? {}; - const existingPlugins = rolldownOptions.plugins - ? Array.isArray(rolldownOptions.plugins) - ? rolldownOptions.plugins - : [rolldownOptions.plugins] - : []; - config.optimizeDeps.rolldownOptions = { - ...rolldownOptions, - plugins: [ - { - name: `vinext:experimental-react-dep-optimize:${name}`, - resolveId(id: string) { - return aliases.find(({ find }) => find.test(id))?.replacement; - }, - }, - ...existingPlugins, - ], - }; - const esbuildOptions = config.optimizeDeps.esbuildOptions ?? {}; - config.optimizeDeps.esbuildOptions = { - ...esbuildOptions, - plugins: [ - createExperimentalReactEsbuildPlugin(aliases, name), - ...(esbuildOptions.plugins ?? []), - ], - }; - return null; - }, - }); plugins.push(createRscClientReferenceLoadersPlugin()); } diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index b7cffc419a..9c697ca468 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -41,7 +41,7 @@ import { getScriptNonceFromNodeHeaderSources } from "./csp.js"; import { mergeRouteParamsIntoQuery, parseQueryString as parseQuery } from "../utils/query.js"; import path from "node:path"; import React from "react"; -import { renderToReadableStream as nativeRenderToReadableStream } from "react-dom/server.edge"; +import { renderToReadableStream } from "react-dom/server.edge"; import { logRequest, now } from "./request-log.js"; import { createValidFileMatcher, @@ -85,39 +85,13 @@ import { isUnknownRecord } from "../utils/record.js"; * boundaries to resolve via stream.allReady before collecting output. * Used for _document rendering and error pages (small, non-streaming). */ -type RenderToReadableStream = ( - element: React.ReactElement, -) => Promise & { allReady: Promise }>; - -function createPagesRenderToReadableStream( - runner: ModuleImporter, - useExperimentalReact: boolean, -): RenderToReadableStream { - if (!useExperimentalReact) return nativeRenderToReadableStream; - - const reactDomServer = importModule(runner, "react-dom/server.edge"); - return async (element) => { - const renderer = await reactDomServer; - if (typeof renderer.renderToReadableStream !== "function") { - throw new Error("[vinext] react-dom/server.edge does not export renderToReadableStream"); - } - return renderer.renderToReadableStream(element); - }; -} - -async function renderToStringAsync( - renderToReadableStream: RenderToReadableStream, - element: React.ReactElement, -): Promise { +async function renderToStringAsync(element: React.ReactElement): Promise { const stream = await renderToReadableStream(element); await stream.allReady; return new Response(stream).text(); } -async function renderIsrPassToStringAsync( - renderToReadableStream: RenderToReadableStream, - element: React.ReactElement, -): Promise { +async function renderIsrPassToStringAsync(element: React.ReactElement): Promise { // The cache-fill render is a second render pass for the same request. // Reset render-scoped state so it cannot leak from the streamed response // render or affect async work that is still draining from that stream. @@ -126,9 +100,7 @@ async function renderIsrPassToStringAsync( return await runWithServerInsertedHTMLState(() => runWithHeadState(() => _runWithCacheState(() => - runWithPrivateCache(() => - runWithFetchCache(async () => renderToStringAsync(renderToReadableStream, element)), - ), + runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), ), ), ); @@ -240,7 +212,6 @@ async function streamPageToResponse( * into `getSSRHeadHTML()`'s output. Called before `getHeadHTML()`. */ setDocumentInitialHead?: (head: React.ReactNode[]) => void; - renderToReadableStream: RenderToReadableStream; /** Buffer the body before writing headers so error-page fallback remains safe. */ bufferBodyBeforeHeaders?: boolean; }, @@ -258,7 +229,6 @@ async function streamPageToResponse( scriptNonce, documentContext, setDocumentInitialHead, - renderToReadableStream, bufferBodyBeforeHeaders = false, } = options; @@ -273,7 +243,7 @@ async function streamPageToResponse( DocumentComponent, enhancePageElement, renderToReadableStream, - renderStylesToString: (element) => renderToStringAsync(renderToReadableStream, element), + renderStylesToString: renderToStringAsync, scriptNonce, context: documentContext, }); @@ -331,7 +301,7 @@ async function streamPageToResponse( const docElement = docProps ? React.createElement(DocumentComponent, docProps) : React.createElement(DocumentComponent); - let docHtml = await renderToStringAsync(renderToReadableStream, docElement); + let docHtml = await renderToStringAsync(docElement); // Replace __NEXT_MAIN__ with our stream marker docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER); // Inject head tags @@ -470,10 +440,8 @@ export function createSSRHandler( */ clientTraceMetadata?: readonly string[], htmlLimitedBots?: string, - useExperimentalReact = false, ) { const matcher = fileMatcher ?? createValidFileMatcher(); - const renderToReadableStream = createPagesRenderToReadableStream(runner, useExperimentalReact); // Page route patterns in Next.js bracket format, sorted by specificity // (sortRoutes via pagesRouter). Mirrors the production client entry's @@ -597,19 +565,7 @@ export function createSSRHandler( return; } // No route matched — try to render custom 404 page - await renderErrorPage( - server, - runner, - req, - res, - url, - pagesDir, - 404, - undefined, - matcher, - undefined, - renderToReadableStream, - ); + await renderErrorPage(server, runner, req, res, url, pagesDir, 404, undefined, matcher); return; } @@ -806,8 +762,6 @@ export function createSSRHandler( 404, routerShim.wrapWithRouterContext, matcher, - undefined, - renderToReadableStream, ); return; } @@ -955,9 +909,6 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, - undefined, - undefined, - renderToReadableStream, ); return; } @@ -1244,7 +1195,6 @@ export function createSSRHandler( el = routerShim.wrapWithRouterContext(el); } const freshBody = await renderIsrPassToStringAsync( - renderToReadableStream, withScriptNonce(el, scriptNonce), ); @@ -1395,9 +1345,6 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, - undefined, - undefined, - renderToReadableStream, ); return; } @@ -1835,7 +1782,6 @@ hydrate(); ? headShim.setDocumentInitialHead : undefined, bufferBodyBeforeHeaders: true, - renderToReadableStream, }); _renderEnd = now(); @@ -1859,7 +1805,6 @@ hydrate(); isrElement = wrapWithRouterContext(isrElement); } const isrBodyHtml = await renderIsrPassToStringAsync( - renderToReadableStream, withScriptNonce(isrElement, scriptNonce), ); const isrHtml = `
${isrBodyHtml}
${allScripts}`; @@ -1906,7 +1851,6 @@ hydrate(); undefined, matcher, e instanceof Error ? e : new Error(String(e)), - renderToReadableStream, ); } catch (fallbackErr) { // If error page itself fails, fall back to plain text. @@ -1941,7 +1885,6 @@ async function renderErrorPage( wrapWithRouterContext?: ((el: React.ReactElement) => React.ReactElement) | null, fileMatcher?: ValidFileMatcher, err?: Error, - renderToReadableStream: RenderToReadableStream = nativeRenderToReadableStream, ): Promise { const matcher = fileMatcher ?? createValidFileMatcher(); // Try specific status page first, then _error, then fallback @@ -2061,10 +2004,9 @@ async function renderErrorPage( typeof headShim.setDocumentInitialHead === "function" ? headShim.setDocumentInitialHead : undefined, - renderToReadableStream, }); } else { - const bodyHtml = await renderToStringAsync(renderToReadableStream, element); + const bodyHtml = await renderToStringAsync(element); const html = ` diff --git a/tests/app-router-dev-server.test.ts b/tests/app-router-dev-server.test.ts index 0a8e1af4ad..26b0fec500 100644 --- a/tests/app-router-dev-server.test.ts +++ b/tests/app-router-dev-server.test.ts @@ -2009,29 +2009,12 @@ describe("App Router integration", () => { const ssrInclude = server.config.environments.ssr?.optimizeDeps?.include; const clientInclude = server.config.environments.client?.optimizeDeps?.include; - // Experimental React rewrites plugin-rsc's framework include to Next.js's - // vendored renderer so the optimizer and runtime share one React channel. - expect(ssrInclude).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /(?:react-dom\/server\.edge|react-dom-experimental\/server\.edge\.js)$/, - ), - ]), - ); + // react-dom/server.edge should be present (added by @vitejs/plugin-rsc) + expect(ssrInclude).toContain("react-dom/server.edge"); - for (const [specifier, experimentalFile] of [ - ["react", "react-experimental/index.js"], - ["react-dom", "react-dom-experimental/index.js"], - ["react-dom/client", "react-dom-experimental/client.js"], - ]) { - expect(clientInclude).toEqual( - expect.arrayContaining([ - expect.stringMatching( - new RegExp(`(?:${specifier.replace("/", "\\/")}|${experimentalFile})$`), - ), - ]), - ); - } + expect(clientInclude).toContain("react"); + expect(clientInclude).toContain("react-dom"); + expect(clientInclude).toContain("react-dom/client"); }); it("drops unused NODE_ENV branches from optimized server dependencies", async () => { @@ -2067,13 +2050,7 @@ describe("App Router integration", () => { const optimizedDependencies = rscEnvironment.depsOptimizer?.metadata.depInfoList.map((dep) => dep.id) ?? []; - expect(optimizedDependencies).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /(?:react-server-dom-webpack\/static\.edge|react-server-dom-webpack-experimental\/static\.edge\.js)$/, - ), - ]), - ); + expect(optimizedDependencies).toContain("react-server-dom-webpack/static.edge"); }); // ── CSRF protection for server actions ─────────────────────────────── diff --git a/tests/app-router-experimental-react.test.ts b/tests/app-router-experimental-react.test.ts deleted file mode 100644 index b50da0cea4..0000000000 --- a/tests/app-router-experimental-react.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Ported from Next.js: -// test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts -// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts - -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { createBuilder, createServer, preview, resolveConfig, type ViteDevServer } from "vite"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; -import vinext from "../packages/vinext/src/index.js"; - -const FIXTURE_DIR = path.resolve(import.meta.dirname, "fixtures/app-experimental-react"); -const DIST_DIR = path.join(FIXTURE_DIR, "dist"); -const require = createRequire(import.meta.url); - -describe("App Router experimental React channel", () => { - let previewServer: Awaited>; - let devServer: ViteDevServer; - let baseUrl = ""; - let devBaseUrl = ""; - const fixtureNodeModules = path.join(FIXTURE_DIR, "node_modules"); - - beforeAll(async () => { - const nextPackageDir = process.env.VINEXT_TEST_NEXT_PACKAGE_DIR - ? path.resolve(process.env.VINEXT_TEST_NEXT_PACKAGE_DIR) - : path.dirname(require.resolve("next/package.json")); - try { - fs.mkdirSync(fixtureNodeModules, { recursive: true }); - fs.symlinkSync(nextPackageDir, path.join(fixtureNodeModules, "next"), "junction"); - const builder = await createBuilder({ - root: FIXTURE_DIR, - configFile: false, - plugins: [vinext({ appDir: FIXTURE_DIR })], - logLevel: "silent", - }); - await builder.buildApp(); - - previewServer = await preview({ - root: FIXTURE_DIR, - configFile: false, - plugins: [vinext({ appDir: FIXTURE_DIR })], - preview: { port: 0 }, - logLevel: "silent", - }); - devServer = await createServer({ - root: FIXTURE_DIR, - configFile: false, - plugins: [vinext({ appDir: FIXTURE_DIR })], - server: { port: 0 }, - logLevel: "silent", - }); - await devServer.listen(); - } catch (error) { - fs.rmSync(DIST_DIR, { recursive: true, force: true }); - fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); - throw error; - } - const address = previewServer.httpServer.address(); - baseUrl = address && typeof address === "object" ? `http://localhost:${address.port}` : ""; - const devAddress = devServer.httpServer?.address(); - devBaseUrl = - devAddress && typeof devAddress === "object" ? `http://localhost:${devAddress.port}` : ""; - }, 120_000); - - afterAll(async () => { - previewServer?.httpServer.close(); - await devServer?.close(); - fs.rmSync(DIST_DIR, { recursive: true, force: true }); - fs.rmSync(fixtureNodeModules, { recursive: true, force: true }); - }); - - it("uses Next.js's vendored experimental React in RSC, SSR, and client bundles", async () => { - const html = await fetch(baseUrl).then((response) => response.text()); - const versions = [ - ...html - .replaceAll("", "") - .matchAll(/(?:React|ReactDOM|ReactDOMServer)\.version=([^<]+)/g), - ] - .map((match) => match[1]) - .filter((version) => /^\d+\.\d+\.\d+/.test(version)); - - expect(versions.length).toBeGreaterThanOrEqual(5); - expect(versions.every((version) => version.includes("-experimental-"))).toBe(true); - - const clientJavaScript = fs - .readdirSync(path.join(DIST_DIR, "client", "_next", "static", "chunks")) - .filter((file) => file.endsWith(".js")) - .map((file) => - fs.readFileSync(path.join(DIST_DIR, "client", "_next", "static", "chunks", file), "utf8"), - ) - .join("\n"); - expect(clientJavaScript).toContain("-experimental-"); - }); - - it("uses the experimental React channel in the dev dependency optimizer", async () => { - const response = await fetch(devBaseUrl); - const html = await response.text(); - expect(response.status).toBe(200); - const versions = [ - ...html - .replaceAll("", "") - .matchAll(/(?:React|ReactDOM|ReactDOMServer)\.version=([^<]+)/g), - ] - .map((match) => match[1]) - .filter((version) => /^\d+\.\d+\.\d+/.test(version)); - - expect(versions.length).toBeGreaterThanOrEqual(5); - expect(versions).toEqual(versions.map(() => expect.stringContaining("-experimental-"))); - }); - - it("keeps the experimental React channel in router-disabled hybrid Pages builds", async () => { - const config = await resolveConfig( - { - root: FIXTURE_DIR, - configFile: false, - plugins: [vinext({ appDir: FIXTURE_DIR, disableAppRouter: true })], - logLevel: "silent", - }, - "build", - ); - - expect(config.define?.["process.env.__NEXT_EXPERIMENTAL_REACT"]).toBe('"true"'); - const channelPlugin = config.plugins.find( - (plugin) => plugin.name === "vinext:experimental-react-channel", - ); - const resolveId = - typeof channelPlugin?.resolveId === "function" - ? channelPlugin.resolveId - : channelPlugin?.resolveId?.handler; - const resolvedReact = await resolveId?.call( - { environment: { name: "ssr" } } as unknown as ThisParameterType< - NonNullable - >, - "react", - undefined, - { isEntry: false }, - ); - expect(resolvedReact).toContain("/next/dist/compiled/react-experimental/"); - }); - - async function expectExternalReactRejection(external: true | string[]) { - await expect( - createServer({ - root: FIXTURE_DIR, - configFile: false, - plugins: vinext({ appDir: FIXTURE_DIR }), - ssr: { external }, - logLevel: "silent", - }), - ).rejects.toThrow("Externalizing React through `ssr.external` or `serverExternalPackages`"); - } - - it("rejects ssr.external true because it bypasses experimental React", async () => { - await expectExternalReactRejection(true); - }); - - it("rejects React entries in ssr.external", async () => { - await expectExternalReactRejection(["react"]); - }); - - it("rejects React entries in serverExternalPackages", async () => { - await expect( - createServer({ - root: FIXTURE_DIR, - configFile: false, - plugins: vinext({ - appDir: FIXTURE_DIR, - nextConfig: { - experimental: { taint: true }, - serverExternalPackages: ["react-dom/server.edge"], - }, - }), - logLevel: "silent", - }), - ).rejects.toThrow("`serverExternalPackages` is incompatible"); - }); -}); diff --git a/tests/e2e/app-router/nextjs-compat/csp.spec.ts b/tests/e2e/app-router/nextjs-compat/csp.spec.ts index 555ec848d5..04fc98c3d7 100644 --- a/tests/e2e/app-router/nextjs-compat/csp.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/csp.spec.ts @@ -27,7 +27,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); await waitForAppRouterHydration(page); @@ -45,7 +45,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); await waitForAppRouterHydration(page); @@ -73,7 +73,7 @@ test.describe("Next.js compat: CSP nonce (browser)", () => { expect(response?.status()).toBe(200); expect(response?.headers()["content-security-policy"]).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); await waitForAppRouterHydration(page); diff --git a/tests/experimental-react.test.ts b/tests/experimental-react.test.ts deleted file mode 100644 index 2b053e96f3..0000000000 --- a/tests/experimental-react.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vite-plus/test"; -import { - createExperimentalReactEnvironmentAliases, - createExperimentalReactEsbuildPlugin, - needsExperimentalReact, - resolveExperimentalReactAliases, - resolveExperimentalReactDevelopmentEntry, - resolveExperimentalReactSpecifier, -} from "../packages/vinext/src/config/experimental-react.js"; - -const tempDirs: string[] = []; - -afterEach(() => { - for (const tempDir of tempDirs.splice(0)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -}); - -describe("experimental React channel", () => { - it.each([ - [undefined, false], - [{}, false], - [{ taint: true }, true], - [{ transitionIndicator: true }, true], - [{ gestureTransition: true }, true], - [{ useExperimentalReact: false, taint: true }, true], - ])("resolves the channel from Next.js experimental flags", (experimental, expected) => { - expect(needsExperimentalReact(experimental)).toBe(expected); - }); - - it("resolves vendored experimental packages from the project's Next.js installation", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-")); - tempDirs.push(root); - const nextDir = path.join(root, "node_modules", "next"); - fs.mkdirSync(path.join(nextDir, "dist", "compiled"), { recursive: true }); - fs.writeFileSync(path.join(root, "package.json"), "{}"); - fs.writeFileSync(path.join(nextDir, "package.json"), '{"name":"next"}'); - for (const packageName of [ - "react-experimental", - "react-dom-experimental", - "react-server-dom-webpack-experimental", - "scheduler-experimental", - ]) { - const packageDir = path.join(nextDir, "dist", "compiled", packageName); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync(path.join(packageDir, "package.json"), "{}"); - } - - const canonicalNextDir = fs.realpathSync(nextDir); - - expect(resolveExperimentalReactAliases(root)).toEqual({ - react: path.join(canonicalNextDir, "dist", "compiled", "react-experimental"), - "react-dom": path.join(canonicalNextDir, "dist", "compiled", "react-dom-experimental"), - "react-server-dom-webpack": path.join( - canonicalNextDir, - "dist", - "compiled", - "react-server-dom-webpack-experimental", - ), - scheduler: path.join(canonicalNextDir, "dist", "compiled", "scheduler-experimental"), - }); - }); - - it("falls back to the running workspace for isolated fixtures without Next.js", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-fixture-")); - tempDirs.push(root); - fs.writeFileSync(path.join(root, "package.json"), "{}"); - - const packages = resolveExperimentalReactAliases(root); - - expect(packages.react).toContain("/next/dist/compiled/react-experimental"); - }); - - it("resolves package exports using environment-specific React conditions", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-")); - tempDirs.push(root); - const reactDir = path.join(root, "react-experimental"); - const reactDomDir = path.join(root, "react-dom-experimental"); - const reactServerDomDir = path.join(root, "react-server-dom-webpack-experimental"); - const schedulerDir = path.join(root, "scheduler-experimental"); - fs.mkdirSync(reactDir, { recursive: true }); - fs.mkdirSync(reactDomDir, { recursive: true }); - fs.mkdirSync(reactServerDomDir, { recursive: true }); - fs.mkdirSync(schedulerDir, { recursive: true }); - fs.writeFileSync( - path.join(reactDir, "package.json"), - JSON.stringify({ - exports: { - ".": { "react-server": "./react-server.js", default: "./index.js" }, - "./jsx-runtime": { - "react-server": "./jsx-runtime.react-server.js", - default: "./jsx-runtime.js", - }, - }, - }), - ); - fs.writeFileSync( - path.join(reactDomDir, "package.json"), - JSON.stringify({ - exports: { ".": { "react-server": "./server.js", default: "./index.js" } }, - }), - ); - fs.writeFileSync( - path.join(reactServerDomDir, "package.json"), - JSON.stringify({ exports: { ".": "./index.js", "./server.edge": "./server.edge.js" } }), - ); - const packages = { - react: reactDir, - "react-dom": reactDomDir, - "react-server-dom-webpack": reactServerDomDir, - scheduler: schedulerDir, - }; - - expect(resolveExperimentalReactSpecifier(packages, "react", "rsc")).toBe( - path.join(reactDir, "react-server.js"), - ); - expect(resolveExperimentalReactSpecifier(packages, "react", "ssr")).toBe( - path.join(reactDir, "index.js"), - ); - expect(resolveExperimentalReactSpecifier(packages, "react/jsx-runtime", "client")).toBe( - path.join(reactDir, "jsx-runtime.js"), - ); - const clientAliases = createExperimentalReactEnvironmentAliases(packages, "client"); - expect(clientAliases.find(({ find }) => find.test("react"))?.replacement).toBe( - path.join(reactDir, "index.js"), - ); - expect( - createExperimentalReactEnvironmentAliases(packages, "rsc").find(({ find }) => - find.test("react-server-dom-webpack/server.edge"), - )?.replacement, - ).toBe(path.join(reactServerDomDir, "server.edge.js")); - expect( - clientAliases.find(({ find }) => find.test("next/dist/compiled/scheduler-experimental")), - ).toEqual({ - find: /^next\/dist\/compiled\/scheduler-experimental$/, - replacement: path.join(schedulerDir, "index.js"), - }); - }); - - it("unwraps vendored development channel selectors for client dev", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-experimental-react-entry-")); - tempDirs.push(root); - const entry = path.join(root, "index.js"); - fs.mkdirSync(path.join(root, "cjs")); - fs.writeFileSync( - entry, - `if (process.env.NODE_ENV === 'production') {\n module.exports = require('./cjs/runtime.production.js')\n} else {\n module.exports = require('./cjs/runtime.development.js')\n}`, - ); - - expect(resolveExperimentalReactDevelopmentEntry(entry)).toBe( - path.join(root, "cjs", "runtime.development.js"), - ); - }); - - it("creates a Vite 7 esbuild optimizer resolver", () => { - const aliases = [{ find: /^react$/, replacement: "/next/react-experimental/index.js" }]; - let resolver: ((args: { path: string }) => { path: string } | undefined) | undefined; - createExperimentalReactEsbuildPlugin(aliases, "client").setup({ - onResolve(_options, callback) { - resolver = callback; - }, - }); - - expect(resolver?.({ path: "react" })).toEqual({ - path: "/next/react-experimental/index.js", - }); - expect(resolver?.({ path: "react-dom" })).toBeUndefined(); - }); -}); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index db2778843b..4a7ca1b320 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -290,10 +290,9 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { pathname.startsWith("/use-client-page-pathname") && request.nextUrl.searchParams.has("csp-nonce") ) { - const devEvalSource = process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""; r.headers.set( "content-security-policy", - `script-src 'nonce-vinext-test-nonce' 'strict-dynamic'${devEvalSource};`, + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); } if ( @@ -328,10 +327,9 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) { pathname.startsWith("/nextjs-compat/dynamic") && request.nextUrl.searchParams.has("csp-nonce") ) { - const devEvalSource = process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""; r.headers.set( "content-security-policy", - `script-src 'nonce-vinext-test-nonce' 'strict-dynamic'${devEvalSource};`, + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); } r.headers.set("x-mw-pathname", pathname); diff --git a/tests/fixtures/app-experimental-react/app/client-react.tsx b/tests/fixtures/app-experimental-react/app/client-react.tsx deleted file mode 100644 index 3dee63ea4b..0000000000 --- a/tests/fixtures/app-experimental-react/app/client-react.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import React from "react"; -import ReactDOM from "react-dom"; -import ReactDOMServer from "react-dom/server.edge"; -import { renderToStaticMarkup } from "react-dom/server"; - -export default function ClientReact() { - const markup = renderToStaticMarkup( -
React Static Markup
, - ); - - return ( -
-

React.version={React.version}

-

ReactDOM.version={ReactDOM.version}

-

ReactDOMServer.version={ReactDOMServer.version}

-

{markup}

-
- ); -} diff --git a/tests/fixtures/app-experimental-react/app/layout.tsx b/tests/fixtures/app-experimental-react/app/layout.tsx deleted file mode 100644 index c8f9cee0b7..0000000000 --- a/tests/fixtures/app-experimental-react/app/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/tests/fixtures/app-experimental-react/app/page.tsx b/tests/fixtures/app-experimental-react/app/page.tsx deleted file mode 100644 index a9afdc904c..0000000000 --- a/tests/fixtures/app-experimental-react/app/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import ClientReact from "./client-react"; - -export default function Page() { - return ( -
-

React.version={React.version}

-

ReactDOM.version={ReactDOM.version}

- -
- ); -} diff --git a/tests/fixtures/app-experimental-react/next.config.ts b/tests/fixtures/app-experimental-react/next.config.ts deleted file mode 100644 index 30127e5e58..0000000000 --- a/tests/fixtures/app-experimental-react/next.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default { - experimental: { - taint: true, - }, -}; diff --git a/tests/fixtures/app-experimental-react/package.json b/tests/fixtures/app-experimental-react/package.json deleted file mode 100644 index e986b24bba..0000000000 --- a/tests/fixtures/app-experimental-react/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "private": true, - "type": "module" -} diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 2578f9ff4b..b468312856 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -1967,7 +1967,6 @@ describe("detectNextIntlConfig", () => { cacheComponents: false, appNavFailHandling: false, gestureTransition: false, - useExperimentalReact: false, prefetchInlining: false, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, diff --git a/tests/nextjs-compat/dynamic.test.ts b/tests/nextjs-compat/dynamic.test.ts index 62a16d3ce0..7eb211a877 100644 --- a/tests/nextjs-compat/dynamic.test.ts +++ b/tests/nextjs-compat/dynamic.test.ts @@ -144,7 +144,7 @@ describe("Next.js compat: next/dynamic", () => { it("SSR: adds nonce attributes to preload links when next/dynamic runs under CSP", async () => { const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/dynamic?csp-nonce=1"); expect(res.headers.get("content-security-policy")).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); const preloadLinks = [ diff --git a/tests/nextjs-compat/use-client-page-pathname.test.ts b/tests/nextjs-compat/use-client-page-pathname.test.ts index 048af890f0..0cbc8ba050 100644 --- a/tests/nextjs-compat/use-client-page-pathname.test.ts +++ b/tests/nextjs-compat/use-client-page-pathname.test.ts @@ -105,7 +105,7 @@ describe('"use client" page component: usePathname() SSR (issue #688)', () => { it("adds CSP nonce to inline hydration and bootstrap scripts", async () => { const res = await fetch(`${_baseUrl}${ROUTE}?csp-nonce=1`); expect(res.headers.get("content-security-policy")).toBe( - "script-src 'nonce-vinext-test-nonce' 'strict-dynamic' 'unsafe-eval';", + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", ); const html = await res.text();