diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
index 8dfdc76978c..831d1ca3805 100644
--- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
@@ -103,6 +103,7 @@ import {transformFire} from '../Transform';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {CompilerError} from '..';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
+import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -274,6 +275,10 @@ function runWithEnvironment(
if (env.config.validateNoImpureFunctionsInRender) {
validateNoImpureFunctionsInRender(hir).unwrap();
}
+
+ if (env.config.validateNoFreezingKnownMutableFunctions) {
+ validateNoFreezingKnownMutableFunctions(hir).unwrap();
+ }
}
inferReactivePlaces(hir);
diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
index 276e4f7b404..a487b5086c0 100644
--- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
@@ -367,6 +367,11 @@ const EnvironmentConfigSchema = z.object({
*/
validateNoImpureFunctionsInRender: z.boolean().default(false),
+ /**
+ * Validate against passing mutable functions to hooks
+ */
+ validateNoFreezingKnownMutableFunctions: z.boolean().default(false),
+
/*
* When enabled, the compiler assumes that hooks follow the Rules of React:
* - Hooks may memoize computation based on any of their parameters, thus
diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts
new file mode 100644
index 00000000000..81612a74417
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts
@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {CompilerError, Effect, ErrorSeverity} from '..';
+import {
+ FunctionEffect,
+ HIRFunction,
+ IdentifierId,
+ isMutableEffect,
+ isRefOrRefLikeMutableType,
+ Place,
+} from '../HIR';
+import {
+ eachInstructionValueOperand,
+ eachTerminalOperand,
+} from '../HIR/visitors';
+import {Result} from '../Utils/Result';
+import {Iterable_some} from '../Utils/utils';
+
+/**
+ * Validates that functions with known mutations (ie due to types) cannot be passed
+ * where a frozen value is expected. Example:
+ *
+ * ```
+ * function Component() {
+ * const cache = new Map();
+ * const onClick = () => {
+ * cache.set(...);
+ * }
+ * useHook(onClick); // ERROR: cannot pass a mutable value
+ * return // ERROR: cannot pass a mutable value
+ * }
+ * ```
+ *
+ * Because `onClick` function mutates `cache` when called, `onClick` is equivalent to a mutable
+ * variables. But unlike other mutables values like an array, the receiver of the function has
+ * no way to avoid mutation — for example, a function can receive an array and choose not to mutate
+ * it, but there's no way to know that a function is mutable and avoid calling it.
+ *
+ * This pass detects functions with *known* mutations (Store or Mutate, not ConditionallyMutate)
+ * that are passed where a frozen value is expected and rejects them.
+ */
+export function validateNoFreezingKnownMutableFunctions(
+ fn: HIRFunction,
+): Result {
+ const errors = new CompilerError();
+ const contextMutationEffects: Map<
+ IdentifierId,
+ Extract
+ > = new Map();
+
+ function visitOperand(operand: Place): void {
+ if (operand.effect === Effect.Freeze) {
+ const effect = contextMutationEffects.get(operand.identifier.id);
+ if (effect != null) {
+ errors.push({
+ reason: `This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update`,
+ description: `Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables`,
+ loc: operand.loc,
+ severity: ErrorSeverity.InvalidReact,
+ });
+ errors.push({
+ reason: `The function modifies a local variable here`,
+ loc: effect.loc,
+ severity: ErrorSeverity.InvalidReact,
+ });
+ }
+ }
+ }
+
+ for (const block of fn.body.blocks.values()) {
+ for (const instr of block.instructions) {
+ const {lvalue, value} = instr;
+ switch (value.kind) {
+ case 'LoadLocal': {
+ const effect = contextMutationEffects.get(value.place.identifier.id);
+ if (effect != null) {
+ contextMutationEffects.set(lvalue.identifier.id, effect);
+ }
+ break;
+ }
+ case 'StoreLocal': {
+ const effect = contextMutationEffects.get(value.value.identifier.id);
+ if (effect != null) {
+ contextMutationEffects.set(lvalue.identifier.id, effect);
+ contextMutationEffects.set(
+ value.lvalue.place.identifier.id,
+ effect,
+ );
+ }
+ break;
+ }
+ case 'FunctionExpression': {
+ const knownMutation = (value.loweredFunc.func.effects ?? []).find(
+ effect => {
+ return (
+ effect.kind === 'ContextMutation' &&
+ (effect.effect === Effect.Store ||
+ effect.effect === Effect.Mutate) &&
+ Iterable_some(effect.places, place => {
+ return (
+ isMutableEffect(place.effect, place.loc) &&
+ !isRefOrRefLikeMutableType(place.identifier.type)
+ );
+ })
+ );
+ },
+ );
+ if (knownMutation && knownMutation.kind === 'ContextMutation') {
+ contextMutationEffects.set(lvalue.identifier.id, knownMutation);
+ }
+ break;
+ }
+ default: {
+ for (const operand of eachInstructionValueOperand(value)) {
+ visitOperand(operand);
+ }
+ }
+ }
+ }
+ for (const operand of eachTerminalOperand(block.terminal)) {
+ visitOperand(operand);
+ }
+ }
+ return errors.asResult();
+}
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md
new file mode 100644
index 00000000000..86a9e14d80e
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md
@@ -0,0 +1,34 @@
+
+## Input
+
+```javascript
+// @validateNoFreezingKnownMutableFunctions
+
+function useFoo() {
+ const cache = new Map();
+ useHook(() => {
+ cache.set('key', 'value');
+ });
+}
+
+```
+
+
+## Error
+
+```
+ 3 | function useFoo() {
+ 4 | const cache = new Map();
+> 5 | useHook(() => {
+ | ^^^^^^^
+> 6 | cache.set('key', 'value');
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+> 7 | });
+ | ^^^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (5:7)
+
+InvalidReact: The function modifies a local variable here (6:6)
+ 8 | }
+ 9 |
+```
+
+
\ No newline at end of file
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.js
new file mode 100644
index 00000000000..323d35700a7
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.js
@@ -0,0 +1,8 @@
+// @validateNoFreezingKnownMutableFunctions
+
+function useFoo() {
+ const cache = new Map();
+ useHook(() => {
+ cache.set('key', 'value');
+ });
+}
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md
new file mode 100644
index 00000000000..0d4742f26c6
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md
@@ -0,0 +1,30 @@
+
+## Input
+
+```javascript
+// @validateNoFreezingKnownMutableFunctions
+function Component() {
+ const cache = new Map();
+ const fn = () => {
+ cache.set('key', 'value');
+ };
+ return ;
+}
+
+```
+
+
+## Error
+
+```
+ 5 | cache.set('key', 'value');
+ 6 | };
+> 7 | return ;
+ | ^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (7:7)
+
+InvalidReact: The function modifies a local variable here (5:5)
+ 8 | }
+ 9 |
+```
+
+
\ No newline at end of file
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.js
new file mode 100644
index 00000000000..11793dfac5d
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.js
@@ -0,0 +1,8 @@
+// @validateNoFreezingKnownMutableFunctions
+function Component() {
+ const cache = new Map();
+ const fn = () => {
+ cache.set('key', 'value');
+ };
+ return ;
+}
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md
new file mode 100644
index 00000000000..63a09bedaa0
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md
@@ -0,0 +1,36 @@
+
+## Input
+
+```javascript
+// @validateNoFreezingKnownMutableFunctions
+import {useHook} from 'shared-runtime';
+
+function useFoo() {
+ useHook(); // for inference to kick in
+ const cache = new Map();
+ return () => {
+ cache.set('key', 'value');
+ };
+}
+
+```
+
+
+## Error
+
+```
+ 5 | useHook(); // for inference to kick in
+ 6 | const cache = new Map();
+> 7 | return () => {
+ | ^^^^^^^
+> 8 | cache.set('key', 'value');
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+> 9 | };
+ | ^^^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (7:9)
+
+InvalidReact: The function modifies a local variable here (8:8)
+ 10 | }
+ 11 |
+```
+
+
\ No newline at end of file
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.js
new file mode 100644
index 00000000000..3df37783dc1
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.js
@@ -0,0 +1,10 @@
+// @validateNoFreezingKnownMutableFunctions
+import {useHook} from 'shared-runtime';
+
+function useFoo() {
+ useHook(); // for inference to kick in
+ const cache = new Map();
+ return () => {
+ cache.set('key', 'value');
+ };
+}