vercel/next.js · #98589

Add React Compiler memoization preservation option

wbinnssmith · merged Oct 2, 202610 files · 197 + / 37 −
crates/next-core/src/next_config.rs23 + / 4 −
@@ -32,7 +32,8 @@ use turbopack_core::{     resolve::ResolveAliasMap, }; use turbopack_ecmascript::transform::{-    OptionReactCompilerCompilationMode, ReactCompilerCompilationMode, ReactCompilerTarget,+    OptionReactCompilerTransformOptions, ReactCompilerCompilationMode, ReactCompilerTarget,+    ReactCompilerTransformOptions, }; use turbopack_ecmascript_plugins::transform::{     emotion::EmotionTransformConfig, relay::RelayConfig,@@ -766,12 +767,22 @@ pub enum ReactCompilerPanicThreshold { pub struct ReactCompilerOptions {     #[serde(default)]     pub compilation_mode: ReactCompilerCompilationMode,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub environment: Option<ReactCompilerEnvironmentOptions>,     #[serde(default)]     pub panic_threshold: ReactCompilerPanicThreshold,     #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]     pub target: Option<ReactCompilerTarget>, } +#[turbo_tasks::value(shared, operation)]+#[derive(Clone, Debug, Default, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub struct ReactCompilerEnvironmentOptions {+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub enable_preserve_existing_memoization_guarantees: Option<bool>,+}+ #[derive(Clone, Debug, PartialEq, Deserialize, NonLocalValue, OperationValue, Encode, Decode)] #[serde(untagged)] pub enum ReactCompilerOptionsOrBoolean {@@ -2082,17 +2093,25 @@ impl NextConfig {     /// Returns compilation mode when both `reactCompiler` and `turbopackRustReactCompiler` are set;     /// `None` otherwise.     #[turbo_tasks::function]-    pub fn rust_react_compiler(&self) -> Vc<OptionReactCompilerCompilationMode> {+    pub fn rust_react_compiler(&self) -> Vc<OptionReactCompilerTransformOptions> {         let use_rust = self             .experimental             .turbopack_rust_react_compiler             .unwrap_or(false);         let mode = match (use_rust, &self.react_compiler) {             (true, Some(ReactCompilerOptionsOrBoolean::Boolean(true))) => {-                Some(ReactCompilerCompilationMode::Infer)+                Some(ReactCompilerTransformOptions::default())             }             (true, Some(ReactCompilerOptionsOrBoolean::Option(opts))) => {-                Some(opts.compilation_mode)+                Some(ReactCompilerTransformOptions {+                    compilation_mode: opts.compilation_mode,+                    enable_preserve_existing_memoization_guarantees: opts+                        .environment+                        .as_ref()+                        .and_then(|environment| {+                            environment.enable_preserve_existing_memoization_guarantees+                        }),+                })             }             _ => None,         };
crates/next-core/src/next_shared/webpack_rules/babel.rs30 + / 3 −
@@ -19,7 +19,7 @@ use turbopack_ecmascript::transform::{ReactCompilerCompilationMode, ReactCompile use turbopack_node::transforms::webpack::WebpackLoaderItem;  use crate::{-    next_config::{NextConfig, ReactCompilerOptions},+    next_config::{NextConfig, ReactCompilerOptions, ReactCompilerPanicThreshold},     next_import_map::try_get_next_package,     next_shared::webpack_rules::{         ManuallyConfiguredBuiltinLoaderIssue, WebpackLoaderBuiltinCondition,@@ -171,20 +171,47 @@ pub async fn get_babel_loader_rules(         #[serde(rename_all = "camelCase")]         struct EnvironmentOptions {             enable_name_anonymous_functions: bool,+            #[serde(skip_serializing_if = "Option::is_none")]+            enable_preserve_existing_memoization_guarantees: Option<bool>,         }          #[derive(Serialize)]         struct ResolvedOptions<'a> {             #[serde(flatten)]-            base: &'a ReactCompilerOptions,+            base: ReactCompilerOptionsWithoutEnvironment<'a>,             environment: EnvironmentOptions,         } +        #[derive(Serialize)]+        #[serde(rename_all = "camelCase")]+        struct ReactCompilerOptionsWithoutEnvironment<'a> {+            compilation_mode: ReactCompilerCompilationMode,+            panic_threshold: &'a ReactCompilerPanicThreshold,+            #[serde(skip_serializing_if = "Option::is_none")]+            target: Option<ReactCompilerTarget>,+        }++        impl<'a> From<&'a ReactCompilerOptions> for ReactCompilerOptionsWithoutEnvironment<'a> {+            fn from(options: &'a ReactCompilerOptions) -> Self {+                Self {+                    compilation_mode: options.compilation_mode,+                    panic_threshold: &options.panic_threshold,+                    target: options.target,+                }+            }+        }+         let resolved_options = ResolvedOptions {-            base: &react_compiler_options_with_target,+            base: (&react_compiler_options_with_target).into(),             environment: EnvironmentOptions {                 enable_name_anonymous_functions: builtin_conditions                     .contains(&WebpackLoaderBuiltinCondition::Development),+                enable_preserve_existing_memoization_guarantees: react_compiler_options_with_target+                    .environment+                    .as_ref()+                    .and_then(|environment| {+                        environment.enable_preserve_existing_memoization_guarantees+                    }),             },         };         let react_compiler_plugins =
docs/01-app/03-api-reference/05-config/01-next-config-js/reactCompiler.mdx33 + / 0 −
@@ -97,6 +97,39 @@ export default function Page() {  > **Note:** You can also use the `"use no memo"` directive from React for the opposite effect, to opt-out a component or hook. +## Existing memoization guarantees++You can configure whether the compiler preserves existing memoization guarantees from `useMemo`, `useCallback`, and `React.memo`:++```ts filename="next.config.ts" switcher+import type { NextConfig } from 'next'++const nextConfig: NextConfig = {+  reactCompiler: {+    environment: {+      enablePreserveExistingMemoizationGuarantees: false,+    },+  },+}++export default nextConfig+```++```js filename="next.config.js" switcher+/** @type {import('next').NextConfig} */+const nextConfig = {+  reactCompiler: {+    environment: {+      enablePreserveExistingMemoizationGuarantees: false,+    },+  },+}++module.exports = nextConfig+```++When omitted, Next.js uses the default from the active React Compiler implementation. Because defaults may differ between compiler implementations and versions, set this option explicitly to ensure consistent behavior.+ ## Version History  | Version   | Changes                                                                                                         |
packages/next/src/build/get-babel-loader-config.ts14 + / 6 −
@@ -39,17 +39,25 @@ const getReactCompilerPlugins = (     // react/package.json not resolvable — skip target detection   } -  const environment: Pick<EnvironmentConfig, 'enableNameAnonymousFunctions'> = {-    enableNameAnonymousFunctions: isDev,-  }   const options: ReactCompilerOptions =     typeof maybeOptions === 'boolean' ? {} : maybeOptions-  const compilerOptions: JSONValue = {-    ...options,+  const { environment: userEnvironment, ...compilerOptions } = options+  const environment = {+    enableNameAnonymousFunctions: isDev,+    ...(userEnvironment?.enablePreserveExistingMemoizationGuarantees !==+    undefined+      ? {+          enablePreserveExistingMemoizationGuarantees:+            userEnvironment.enablePreserveExistingMemoizationGuarantees,+        }+      : {}),+  } satisfies Partial<EnvironmentConfig>+  const resolvedCompilerOptions: JSONValue = {+    ...compilerOptions,     ...(target ? { target } : {}),     environment,   }-  return [[getReactCompiler(), compilerOptions]]+  return [[getReactCompiler(), resolvedCompilerOptions]] }  const getBabelLoader = (
packages/next/src/server/config-schema.ts7 + / 0 −
@@ -859,6 +859,13 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>       z         .object({           compilationMode: z.enum(['infer', 'annotation', 'all']).optional(),+          environment: z+            .object({+              enablePreserveExistingMemoizationGuarantees: z+                .boolean()+                .optional(),+            })+            .optional(),           panicThreshold: z             .enum(['none', 'critical_errors', 'all_errors'])             .optional(),
packages/next/src/server/config-shared.ts9 + / 0 −
@@ -359,6 +359,15 @@ export interface ReactCompilerOptions {    * @see https://react.dev/reference/react-compiler/compilationMode    */   compilationMode?: 'infer' | 'annotation' | 'all'+  environment?: {+    /**+     * Controls whether the React Compiler preserves existing memoization+     * guarantees from `useMemo`, `useCallback`, and `React.memo`.+     *+     * When omitted, the installed React Compiler's default is used.+     */+    enablePreserveExistingMemoizationGuarantees?: boolean+  }   /**    * Controls how the React Compiler handles errors during compilation.    *
turbopack/crates/turbopack-ecmascript/src/transform/mod.rs69 + / 16 −
@@ -92,11 +92,21 @@ pub enum EcmascriptInputTransform {         use_define_for_class_fields: bool,     },     ReactCompilerRust {-        compilation_mode: ReactCompilerCompilationMode,+        options: ReactCompilerTransformOptions,         target: ReactCompilerTarget,     }, } +#[turbo_tasks::value(shared, operation)]+#[derive(Default, Debug, Clone, Copy, Hash, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub struct ReactCompilerTransformOptions {+    #[serde(default)]+    pub compilation_mode: ReactCompilerCompilationMode,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub enable_preserve_existing_memoization_guarantees: Option<bool>,+}+ #[turbo_tasks::value(shared, operation)] #[derive(Default, Debug, Clone, Copy, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")]@@ -118,7 +128,7 @@ impl ReactCompilerCompilationMode { }  #[turbo_tasks::value(transparent)]-pub struct OptionReactCompilerCompilationMode(Option<ReactCompilerCompilationMode>);+pub struct OptionReactCompilerTransformOptions(Option<ReactCompilerTransformOptions>);  #[turbo_tasks::value(shared, operation)] #[derive(Default, Debug, Clone, Copy, Hash, Serialize, Deserialize)]@@ -401,11 +411,8 @@ impl EcmascriptInputTransform {                  apply_transform(program, helpers, decorators(config))             }-            EcmascriptInputTransform::ReactCompilerRust {-                compilation_mode,-                target,-            } => {-                apply_rust_react_compiler(program, ctx, helpers, *compilation_mode, *target).await?+            EcmascriptInputTransform::ReactCompilerRust { options, target } => {+                apply_rust_react_compiler(program, ctx, helpers, *options, *target).await?             }             EcmascriptInputTransform::Plugin(transform) => {                 // We cannot pass helpers to plugins, so we return them as is@@ -538,7 +545,7 @@ async fn apply_rust_react_compiler(     program: &mut Program,     ctx: &TransformContext<'_>,     helpers: HelperData,-    compilation_mode: ReactCompilerCompilationMode,+    options: ReactCompilerTransformOptions,     target: ReactCompilerTarget, ) -> Result<HelperData> {     let Program::Module(_) = program else {@@ -548,7 +555,7 @@ async fn apply_rust_react_compiler(     // Avoid invoking the compiler when the selected mode cannot change this module. These checks     // run on the SWC AST we already parsed, before converting it to the compiler AST. `All` mode     // remains unconditional because every function is eligible.-    if !should_run_rust_react_compiler(program, compilation_mode) {+    if !should_run_rust_react_compiler(program, options.compilation_mode) {         return Ok(helpers);     } @@ -559,7 +566,7 @@ async fn apply_rust_react_compiler(         swc_ecma_react_compiler::SourceType::from_program(program),         ctx.source_text,         Some(&single_threaded_comments),-        react_compiler_options(ctx, compilation_mode, target),+        react_compiler_options(ctx.node_env.as_str(), ctx.file_name_str, options, target),     );      // TODO: Emit these diagnostics with an Info level once there's a way of adjusting log levels in@@ -585,18 +592,19 @@ async fn apply_rust_react_compiler( }  fn react_compiler_options(-    ctx: &TransformContext<'_>,-    compilation_mode: ReactCompilerCompilationMode,+    node_env: &str,+    file_name: &str,+    options: ReactCompilerTransformOptions,     target: ReactCompilerTarget, ) -> react_compiler::entrypoint::plugin_options::PluginOptions {     use react_compiler::entrypoint::plugin_options::{CompilerTarget, PluginOptions}; -    PluginOptions {+    let mut plugin_options = PluginOptions {         should_compile: true,         enable_reanimated: false,-        is_dev: ctx.node_env != "production",-        filename: Some(ctx.file_name_str.to_string()),-        compilation_mode: compilation_mode.as_str().to_string(),+        is_dev: node_env != "production",+        filename: Some(file_name.to_string()),+        compilation_mode: options.compilation_mode.as_str().to_string(),         panic_threshold: "none".to_string(),         target: CompilerTarget::Version(target.as_str().to_string()),         gating: None,@@ -611,7 +619,17 @@ fn react_compiler_options(         source_code: None,         profiling: false,         debug: false,+    };+    if let Some(enable_preserve_existing_memoization_guarantees) =+        options.enable_preserve_existing_memoization_guarantees+    {+        plugin_options+            .environment+            .enable_preserve_existing_memoization_guarantees =+            enable_preserve_existing_memoization_guarantees;     }++    plugin_options }  fn apply_transform(program: &mut Program, helpers: HelperData, op: impl Pass) -> HelperData {@@ -690,6 +708,41 @@ mod react_compiler_tests {      use super::*; +    #[test]+    fn preserves_default_environment_without_explicit_option() {+        let options = react_compiler_options(+            "production",+            "input.js",+            ReactCompilerTransformOptions::default(),+            ReactCompilerTarget::React19,+        );++        assert!(+            options+                .environment+                .enable_preserve_existing_memoization_guarantees+        );+    }++    #[test]+    fn forwards_explicit_memoization_preservation_option() {+        let options = react_compiler_options(+            "production",+            "input.js",+            ReactCompilerTransformOptions {+                compilation_mode: ReactCompilerCompilationMode::Infer,+                enable_preserve_existing_memoization_guarantees: Some(false),+            },+            ReactCompilerTarget::React19,+        );++        assert!(+            !options+                .environment+                .enable_preserve_existing_memoization_guarantees+        );+    }+     fn parse_program(source: &str) -> Program {         GLOBALS.set(&Default::default(), || {             let cm = SourceMap::default();
turbopack/crates/turbopack-tests/tests/snapshot.rs8 + / 4 −
@@ -56,7 +56,8 @@ use turbopack_core::{ }; use turbopack_ecmascript::{     AnalyzeMode, CustomTransformer, EcmascriptInputTransform, TransformPlugin,-    chunk::EcmascriptChunkType, transform::ReactCompilerCompilationMode,+    chunk::EcmascriptChunkType,+    transform::{ReactCompilerCompilationMode, ReactCompilerTransformOptions}, }; use turbopack_ecmascript_plugins::transform::{     emotion::{EmotionTransformConfig, EmotionTransformer},@@ -429,9 +430,12 @@ async fn run_test_operation(resource: RcStr) -> Result<Vc<FileSystemPath>> {                 cjs_scope_hoisting: options.cjs_scope_hoisting,                 cross_module_constants: options.cross_module_constants,                 enable_exports_info_inlining: true,-                enable_rust_react_compiler: options-                    .enable_rust_react_compiler-                    .then_some(ReactCompilerCompilationMode::Infer),+                enable_rust_react_compiler: options.enable_rust_react_compiler.then_some(+                    ReactCompilerTransformOptions {+                        compilation_mode: ReactCompilerCompilationMode::Infer,+                        ..Default::default()+                    },+                ),                 ..Default::default()             },             environment: Some(env),
turbopack/crates/turbopack/src/module_options/mod.rs2 + / 2 −
@@ -309,9 +309,9 @@ impl ModuleOptions {         let mut ecma_preprocess = vec![];         let mut postprocess = vec![]; -        if let Some(compilation_mode) = enable_rust_react_compiler {+        if let Some(options) = enable_rust_react_compiler {             ecma_preprocess.push(EcmascriptInputTransform::ReactCompilerRust {-                compilation_mode,+                options,                 target: rust_react_compiler_target,             });         }
turbopack/crates/turbopack/src/module_options/module_options_context.rs2 + / 2 −
@@ -16,7 +16,7 @@ use turbopack_core::{ use turbopack_ecmascript::{     AnalyzeMode, TypeofWindow,     references::esm::UrlRewriteBehavior,-    transform::{PresetEnvConfig, ReactCompilerCompilationMode, ReactCompilerTarget},+    transform::{PresetEnvConfig, ReactCompilerTarget, ReactCompilerTransformOptions}, }; pub use turbopack_mdx::MdxTransformOptions; use turbopack_node::{@@ -254,7 +254,7 @@ pub struct EcmascriptOptionsContext {     // node_modules.     pub enable_typeof_window_inlining: Option<TypeofWindow>,     pub enable_jsx: Option<ResolvedVc<JsxTransformOptions>>,-    pub enable_rust_react_compiler: Option<ReactCompilerCompilationMode>,+    pub enable_rust_react_compiler: Option<ReactCompilerTransformOptions>,     pub rust_react_compiler_target: ReactCompilerTarget,     /// Follow type references and resolve declaration files in additional to     /// normal resolution.