Add experimental.useExperimentalReact to opt into React's experimental channel (#94861)

Opting into React's experimental channel — which emits `<link
rel="expect">` to hold first paint until the streamed shell is coherent,
avoiding flicker from partially-streamed HTML — currently requires
enabling an unrelated feature like `experimental.taint` as a side
effect, which is confusing. This adds
`experimental.useExperimentalReact` as a direct opt-in.

It feeds the existing `needsExperimentalReact` aggregation and the
matching Turbopack `react_channel` switch, selecting the
`react@experimental` build the same way `taint`, `transitionIndicator`,
and `gestureTransition` do. It's opt-in only: an explicit `false` can't
disable the channel when one of those still requires it (the taint APIs
only exist in the experimental build), so `assignDefaults` warns on that
contradiction. Covered by a webpack e2e test mirroring the existing
`taint` channel test.

<!-- NEXT_JS_LLM_PR -->
This commit is contained in:
Jude Gao
2026-06-16 15:08:47 -04:00
committed by GitHub
parent fc05e6cbac
commit 71b70e01b8
8 changed files with 147 additions and 13 deletions
+6
View File
@@ -1269,6 +1269,7 @@ pub struct ExperimentalConfig {
swc_trace_profiling: Option<bool>,
transition_indicator: Option<bool>,
gesture_transition: Option<bool>,
use_experimental_react: Option<bool>,
/// @internal Used by the Next.js internals only.
trust_host_header: Option<bool>,
@@ -2223,6 +2224,11 @@ impl NextConfig {
Vc::cell(self.experimental.gesture_transition.unwrap_or(false))
}
#[turbo_tasks::function]
pub fn enable_use_experimental_react(&self) -> Vc<bool> {
Vc::cell(self.experimental.use_experimental_react.unwrap_or(false))
}
#[turbo_tasks::function]
pub fn enable_cache_components(&self) -> Vc<bool> {
Vc::cell(self.cache_components.unwrap_or(false))
+14 -10
View File
@@ -86,14 +86,16 @@ pub async fn get_next_client_import_map(
}
ClientContextType::App { app_dir } => {
// Keep in sync with file:///./../../../packages/next/src/lib/needs-experimental-react.ts
let use_experimental_react = *next_config.enable_use_experimental_react().await?;
let taint = *next_config.enable_taint().await?;
let transition_indicator = *next_config.enable_transition_indicator().await?;
let gesture_transition = *next_config.enable_gesture_transition().await?;
let react_channel = if taint || transition_indicator || gesture_transition {
"-experimental"
} else {
""
};
let react_channel =
if use_experimental_react || taint || transition_indicator || gesture_transition {
"-experimental"
} else {
""
};
import_map.insert_exact_alias(
rcstr!("react"),
@@ -782,14 +784,16 @@ async fn apply_vendored_react_aliases_server(
runtime: NextRuntime,
next_config: Vc<NextConfig>,
) -> Result<()> {
let use_experimental_react = *next_config.enable_use_experimental_react().await?;
let taint = *next_config.enable_taint().await?;
let transition_indicator = *next_config.enable_transition_indicator().await?;
let gesture_transition = *next_config.enable_gesture_transition().await?;
let react_channel = if taint || transition_indicator || gesture_transition {
"-experimental"
} else {
""
};
let react_channel =
if use_experimental_react || taint || transition_indicator || gesture_transition {
"-experimental"
} else {
""
};
let react_condition = if ty.should_use_react_server_condition() {
"server"
} else {
@@ -2,7 +2,13 @@ import type { NextConfig } from '../server/config-shared'
// Keep in sync with Turbopack's experimental React switch: file://./../../../../crates/next-core/src/next_import_map.rs
export function needsExperimentalReact(config: NextConfig) {
const { taint, transitionIndicator, gestureTransition } =
config.experimental || {}
return Boolean(taint || transitionIndicator || gestureTransition)
const {
useExperimentalReact,
taint,
transitionIndicator,
gestureTransition,
} = config.experimental || {}
return Boolean(
useExperimentalReact || taint || transitionIndicator || gestureTransition
)
}
@@ -299,6 +299,7 @@ export const experimentalSchema = {
.readonly()
.optional(),
taint: z.boolean().optional(),
useExperimentalReact: z.boolean().optional(),
prerenderEarlyExit: z.boolean().optional(),
proxyTimeout: z.number().gte(0).optional(),
rootParams: z.boolean().optional(),
+16
View File
@@ -974,6 +974,22 @@ export interface ExperimentalConfig {
*/
taint?: boolean
/**
* Opt into React's experimental release channel for the `app` directory
* without enabling a specific experimental API (such as `taint`).
*
* The experimental React build emits a `<link rel="expect">` tag that holds
* the browser's first paint until the streamed shell is coherent, avoiding
* the layout shift / flicker that can occur while a partially-streamed HTML
* document is painted. Note that `rel="expect"` is currently only
* implemented by Chromium-based browsers.
*
* This is an opt-in only. Setting it to `false` does not disable the
* experimental channel when another feature (such as `taint`,
* `transitionIndicator`, or `gestureTransition`) requires it.
*/
useExperimentalReact?: boolean
/**
* Uninstalls all "unhandledRejection" and "uncaughtException" listeners from
* the global process so that we can override the behavior, which in some
+24
View File
@@ -546,6 +546,30 @@ function assignDefaultsAndValidate(
)
}
// `useExperimentalReact` only opts *into* React's experimental channel; it
// cannot opt out of it when another feature (e.g. `taint`) structurally
// requires it — those APIs only exist in the experimental React build. Warn
// so an explicit `false` here doesn't look like it was silently ignored.
if (result.experimental.useExperimentalReact === false) {
const dependents: string[] = []
if (result.experimental.taint) {
dependents.push('`experimental.taint`')
}
if (result.experimental.transitionIndicator) {
dependents.push('`experimental.transitionIndicator`')
}
if (result.experimental.gestureTransition) {
dependents.push('`experimental.gestureTransition`')
}
if (dependents.length > 0) {
Log.warn(
`\`experimental.useExperimentalReact\` is set to \`false\`, but ${dependents.join(
', '
)} require React's experimental channel, so it remains enabled.`
)
}
}
if (result.experimental.appShells) {
// App Shells is tested in combination with the experimental flags it
// expects to ship alongside. All of these are on track to become
@@ -247,6 +247,7 @@
"test/e2e/app-dir/root-layout/root-layout.test.ts",
"test/e2e/app-dir/router-stuck-dynamic-static-segment/router-stuck-dynamic-static-segment.test.ts",
"test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts",
"test/e2e/app-dir/rsc-basic/rsc-basic-use-experimental-react.test.ts",
"test/e2e/app-dir/rsc-basic/rsc-basic.test.ts",
"test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts",
"test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts",
@@ -0,0 +1,76 @@
import { nextTestSetup } from 'e2e-utils'
describe('react@experimental - useExperimentalReact', () => {
const { next } = nextTestSetup({
files: __dirname,
overrideFiles: {
'next.config.js': `
module.exports = {
experimental: {
useExperimentalReact: true,
}
}
`,
},
})
it('should opt into the react@experimental channel when useExperimentalReact is enabled', async () => {
const resPages$ = await next.render$('/app-react')
const [
ssrReact,
ssrReactDOM,
ssrClientReact,
ssrClientReactDOM,
ssrClientReactDOMServer,
] = [
resPages$('#react').text(),
resPages$('#react-dom').text(),
resPages$('#client-react').text(),
resPages$('#client-react-dom').text(),
resPages$('#client-react-dom-server').text(),
]
expect({
ssrReact,
ssrReactDOM,
ssrClientReact,
ssrClientReactDOM,
ssrClientReactDOMServer,
}).toEqual({
ssrReact: expect.stringMatching('-experimental-'),
ssrReactDOM: expect.stringMatching('-experimental-'),
ssrClientReact: expect.stringMatching('-experimental-'),
ssrClientReactDOM: expect.stringMatching('-experimental-'),
ssrClientReactDOMServer: expect.stringMatching('-experimental-'),
})
const browser = await next.browser('/app-react')
const [
browserReact,
browserReactDOM,
browserClientReact,
browserClientReactDOM,
browserClientReactDOMServer,
] = await browser.eval(`
[
document.querySelector('#react').innerText,
document.querySelector('#react-dom').innerText,
document.querySelector('#client-react').innerText,
document.querySelector('#client-react-dom').innerText,
document.querySelector('#client-react-dom-server').innerText,
]
`)
expect({
browserReact,
browserReactDOM,
browserClientReact,
browserClientReactDOM,
browserClientReactDOMServer,
}).toEqual({
browserReact: expect.stringMatching('-experimental-'),
browserReactDOM: expect.stringMatching('-experimental-'),
browserClientReact: expect.stringMatching('-experimental-'),
browserClientReactDOM: expect.stringMatching('-experimental-'),
browserClientReactDOMServer: expect.stringMatching('-experimental-'),
})
})
})