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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ export type NextConfig = {
* `useRouter().experimental_gesturePush()`.
*/
gestureTransition?: boolean;
/**
* Opt into React's experimental release channel for the App Router without
* enabling a specific experimental API (such as `taint`).
*
* Opt-in only: setting it to `false` does not disable the experimental
* channel when another feature (`taint`, `transitionIndicator`,
* `gestureTransition`) requires it.
*
* Accepted for Next.js config compatibility. vinext does not ship React's
* experimental build, so this flag has no functional effect today.
* @see https://github.com/vercel/next.js/pull/94861
*/
useExperimentalReact?: boolean;
[key: string]: unknown;
};
/**
Expand Down Expand Up @@ -1511,6 +1524,26 @@ export async function resolveNextConfig(
);
}

// Mirror Next.js assignDefaults: `useExperimentalReact: false` cannot disable
// the experimental channel when taint/transitionIndicator/gestureTransition
// require it (those APIs only exist in the experimental React build). Next.js
// warns; vinext warns too — the flag is accepted for config compatibility but
// has no functional effect because vinext does not ship React's experimental build.
// https://github.com/vercel/next.js/pull/94861 (packages/next/src/server/config.ts)
if (experimental?.useExperimentalReact === false) {
const dependents: string[] = [];
if (experimental?.taint) dependents.push("`experimental.taint`");
if (experimental?.transitionIndicator) dependents.push("`experimental.transitionIndicator`");
if (experimental?.gestureTransition) dependents.push("`experimental.gestureTransition`");
if (dependents.length > 0) {
console.warn(
`[vinext] \`experimental.useExperimentalReact\` is set to \`false\`, but ${dependents.join(", ")} ` +
"require React's experimental channel. Note: vinext does not ship React's experimental build, " +
"so `useExperimentalReact` is accepted for config compatibility but has no functional effect.",
);
}
}

// Warn about unsupported webpack usage. We preserve alias injection,
// resolve.extensions, and MDX settings, but other customization is ignored.
if (config.webpack !== undefined) {
Expand Down
95 changes: 95 additions & 0 deletions tests/next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,101 @@ describe("resolveNextConfig gestureTransition", () => {
});
});

// Ported from Next.js: test/e2e/app-dir/rsc-basic/rsc-basic-use-experimental-react.test.ts
// https://github.com/vercel/next.js/pull/94861
// vinext does not ship React's experimental build, so the flag is accepted for
// config compatibility only; we replicate the assignDefaults contradiction warning.
describe("resolveNextConfig useExperimentalReact", () => {
it("accepts experimental.useExperimentalReact: true without throwing", async () => {
await expect(
resolveNextConfig({ experimental: { useExperimentalReact: true } }),
).resolves.toBeDefined();
});

it("accepts experimental.useExperimentalReact: false without throwing", async () => {
await expect(
resolveNextConfig({ experimental: { useExperimentalReact: false } }),
).resolves.toBeDefined();
});

it("does not warn when useExperimentalReact is false and no dependent feature is set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await resolveNextConfig({ experimental: { useExperimentalReact: false } });

const warning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("useExperimentalReact"),
);
expect(warning).toBeUndefined();
warn.mockRestore();
});

it("warns when useExperimentalReact is false but taint requires the experimental channel", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await resolveNextConfig({
experimental: { useExperimentalReact: false, taint: true },
});

const warning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("useExperimentalReact"),
);
expect(warning).toBeDefined();
expect(warning![0]).toContain("`experimental.useExperimentalReact`");
expect(warning![0]).toContain("`experimental.taint`");
warn.mockRestore();
});

it("warns when useExperimentalReact is false but gestureTransition requires the experimental channel", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await resolveNextConfig({
experimental: { useExperimentalReact: false, gestureTransition: true },
});

const warning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("useExperimentalReact"),
);
expect(warning).toBeDefined();
expect(warning![0]).toContain("`experimental.gestureTransition`");
warn.mockRestore();
});

it("lists every dependent feature in the warning", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await resolveNextConfig({
experimental: {
useExperimentalReact: false,
taint: true,
transitionIndicator: true,
},
});

const warning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("useExperimentalReact"),
);
expect(warning).toBeDefined();
expect(warning![0]).toContain("`experimental.taint`");
expect(warning![0]).toContain("`experimental.transitionIndicator`");
warn.mockRestore();
});

it("does not warn when useExperimentalReact is true alongside taint", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await resolveNextConfig({
experimental: { useExperimentalReact: true, taint: true },
});

const warning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("useExperimentalReact"),
);
expect(warning).toBeUndefined();
warn.mockRestore();
});
});

describe("resolveNextConfig appNavFailHandling", () => {
it("defaults experimental.appNavFailHandling to false", async () => {
const resolved = await resolveNextConfig({});
Expand Down
Loading