Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions packages/vinext/src/config/experimental-react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import path from "node:path";
import { createRequire } from "node:module";
import fs from "node:fs";

export type ExperimentalReactAliases = Record<string, string>;
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<string, unknown> | 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"),
};

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<string, PackageExport> };
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;
}
5 changes: 5 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1335,6 +1338,7 @@ export async function resolveNextConfig(
cacheComponents: false,
appNavFailHandling: false,
gestureTransition: false,
useExperimentalReact: false,
prefetchInlining: false,
redirects: [],
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof createValidFileMatcher>;
let middlewarePath: string | null = null;
let instrumentationPath: string | null = null;
Expand Down Expand Up @@ -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 ?? []),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/app-router-experimental-react.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof preview>>;
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-");
});
});
Loading
Loading