[compiler] Improved ref validation for non-mutating functions (#35893)

If a function is known to freeze its inputs, and captures refs, then we
can safely assume those refs are not mutated during render.

An example is React Native's PanResponder, which is designed for use in
interaction handling. Calling `PanResponder.create()` creates an object
that shouldn't be interacted with at render time, so we can treat it as
freezing its arguments, returning a frozen value, and not accessing any
refs in the callbacks passed to it. ValidateNoRefAccessInRender is
updated accordingly - if we see a Freeze <place> and ImmutableCapture
<place> for the same place in the same instruction, we know that it's
not being mutated.

Note that this is a pretty targeted fix. One weakness is that we may not
always emit a Freeze effect if a value is already frozen, which could
cause this optimization not to kick in. The worst case there is that
you'd just get a ref access in render error though, not miscompilation.
And we could always choose to always emit Freeze effects, even for
frozen values, just to retain the information for validations like this.
This commit is contained in:
Joseph Savona
2026-02-24 12:36:32 -08:00
committed by GitHub
parent c0060cf2a6
commit e33071c614
8 changed files with 276 additions and 23 deletions

View File

@@ -35,6 +35,20 @@ yarn snap -p <file-basename> -d
yarn snap -u yarn snap -u
``` ```
## Linting
```bash
# Run lint on the compiler source
yarn workspace babel-plugin-react-compiler lint
```
## Formatting
```bash
# Run prettier on all files (from the react root directory, not compiler/)
yarn prettier-all
```
## Compiling Arbitrary Files ## Compiling Arbitrary Files
Use `yarn snap compile` to compile any file (not just fixtures) with the React Compiler: Use `yarn snap compile` to compile any file (not just fixtures) with the React Compiler:

View File

@@ -487,24 +487,26 @@ function validateNoRefAccessInRenderImpl(
*/ */
if (!didError) { if (!didError) {
const isRefLValue = isUseRefType(instr.lvalue.identifier); const isRefLValue = isUseRefType(instr.lvalue.identifier);
for (const operand of eachInstructionValueOperand(instr.value)) { if (
/** isRefLValue ||
* By default we check that function call operands are not refs, (hookKind != null &&
* ref values, or functions that can access refs. hookKind !== 'useState' &&
*/ hookKind !== 'useReducer')
if ( ) {
isRefLValue || for (const operand of eachInstructionValueOperand(
(hookKind != null && instr.value,
hookKind !== 'useState' && )) {
hookKind !== 'useReducer')
) {
/** /**
* Allow passing refs or ref-accessing functions when: * Allow passing refs or ref-accessing functions when:
* 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`) * 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`)
* 2. calling hooks (independently validated for ref safety) * 2. calling hooks (independently validated for ref safety)
*/ */
validateNoDirectRefValueAccess(errors, operand, env); validateNoDirectRefValueAccess(errors, operand, env);
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) { }
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
for (const operand of eachInstructionValueOperand(
instr.value,
)) {
/** /**
* Special case: the lvalue is passed as a jsx child * Special case: the lvalue is passed as a jsx child
* *
@@ -513,7 +515,98 @@ function validateNoRefAccessInRenderImpl(
* render function which attempts to obey the rules. * render function which attempts to obey the rules.
*/ */
validateNoRefValueAccess(errors, env, operand); validateNoRefValueAccess(errors, env, operand);
} else { }
} else if (hookKind == null && instr.effects != null) {
/**
* For non-hook functions with known aliasing effects, use the
* effects to determine what validation to apply for each place.
* Track visited id:kind pairs to avoid duplicate errors.
*/
const visitedEffects: Set<string> = new Set();
for (const effect of instr.effects) {
let place: Place | null = null;
let validation: 'ref-passed' | 'direct-ref' | 'none' = 'none';
switch (effect.kind) {
case 'Freeze': {
place = effect.value;
validation = 'direct-ref';
break;
}
case 'Mutate':
case 'MutateTransitive':
case 'MutateConditionally':
case 'MutateTransitiveConditionally': {
place = effect.value;
validation = 'ref-passed';
break;
}
case 'Render': {
place = effect.place;
validation = 'ref-passed';
break;
}
case 'Capture':
case 'Alias':
case 'MaybeAlias':
case 'Assign':
case 'CreateFrom': {
place = effect.from;
validation = 'ref-passed';
break;
}
case 'ImmutableCapture': {
/**
* ImmutableCapture can come from two sources:
* 1. A known signature that explicitly freezes the operand
* (e.g. PanResponder.create) — safe, the function doesn't
* call callbacks during render.
* 2. Downgraded defaults when the operand is already frozen
* (e.g. foo(propRef)) — the function is unknown and may
* access the ref.
*
* We distinguish these by checking whether the same operand
* also has a Freeze effect on this instruction, which only
* comes from known signatures.
*/
place = effect.from;
const isFrozen = instr.effects.some(
e =>
e.kind === 'Freeze' &&
e.value.identifier.id === effect.from.identifier.id,
);
validation = isFrozen ? 'direct-ref' : 'ref-passed';
break;
}
case 'Create':
case 'CreateFunction':
case 'Apply':
case 'Impure':
case 'MutateFrozen':
case 'MutateGlobal': {
break;
}
}
if (place !== null && validation !== 'none') {
const key = `${place.identifier.id}:${validation}`;
if (!visitedEffects.has(key)) {
visitedEffects.add(key);
if (validation === 'direct-ref') {
validateNoDirectRefValueAccess(errors, place, env);
} else {
validateNoRefPassedToFunction(
errors,
env,
place,
place.loc,
);
}
}
}
}
} else {
for (const operand of eachInstructionValueOperand(
instr.value,
)) {
validateNoRefPassedToFunction( validateNoRefPassedToFunction(
errors, errors,
env, env,

View File

@@ -3,8 +3,10 @@
```javascript ```javascript
// @validateRefAccessDuringRender:true // @validateRefAccessDuringRender:true
import {mutate} from 'shared-runtime';
function Foo(props, ref) { function Foo(props, ref) {
console.log(ref.current); mutate(ref.current);
return <div>{props.bar}</div>; return <div>{props.bar}</div>;
} }
@@ -26,14 +28,14 @@ Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.validate-mutate-ref-arg-in-render.ts:3:14 error.validate-mutate-ref-arg-in-render.ts:5:9
1 | // @validateRefAccessDuringRender:true 3 |
2 | function Foo(props, ref) { 4 | function Foo(props, ref) {
> 3 | console.log(ref.current); > 5 | mutate(ref.current);
| ^^^^^^^^^^^ Passing a ref to a function may read its value during render | ^^^^^^^^^^^ Passing a ref to a function may read its value during render
4 | return <div>{props.bar}</div>; 6 | return <div>{props.bar}</div>;
5 | } 7 | }
6 | 8 |
``` ```

View File

@@ -1,6 +1,8 @@
// @validateRefAccessDuringRender:true // @validateRefAccessDuringRender:true
import {mutate} from 'shared-runtime';
function Foo(props, ref) { function Foo(props, ref) {
console.log(ref.current); mutate(ref.current);
return <div>{props.bar}</div>; return <div>{props.bar}</div>;
} }

View File

@@ -0,0 +1,77 @@
## Input
```javascript
// @flow
import {PanResponder, Stringify} from 'shared-runtime';
export default component Playground() {
const onDragEndRef = useRef(() => {});
useEffect(() => {
onDragEndRef.current = () => {
console.log('drag ended');
};
});
const panResponder = useMemo(
() =>
PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
}),
[]
);
return <Stringify responder={panResponder} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { PanResponder, Stringify } from "shared-runtime";
export default function Playground() {
const $ = _c(3);
const onDragEndRef = useRef(_temp);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
onDragEndRef.current = _temp2;
};
$[0] = t0;
} else {
t0 = $[0];
}
useEffect(t0);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
});
$[1] = t1;
} else {
t1 = $[1];
}
const panResponder = t1;
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = <Stringify responder={panResponder} />;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
}
function _temp2() {
console.log("drag ended");
}
function _temp() {}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,21 @@
// @flow
import {PanResponder, Stringify} from 'shared-runtime';
export default component Playground() {
const onDragEndRef = useRef(() => {});
useEffect(() => {
onDragEndRef.current = () => {
console.log('drag ended');
};
});
const panResponder = useMemo(
() =>
PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
}),
[]
);
return <Stringify responder={panResponder} />;
}

View File

@@ -196,6 +196,44 @@ export function makeSharedRuntimeTypeProvider({
], ],
}, },
}, },
PanResponder: {
kind: 'object',
properties: {
create: {
kind: 'function',
positionalParams: [EffectEnum.Freeze],
restParam: null,
calleeEffect: EffectEnum.Read,
returnType: {kind: 'type', name: 'Any'},
returnValueKind: ValueKindEnum.Frozen,
aliasing: {
receiver: '@receiver',
params: ['@config'],
rest: null,
returns: '@returns',
temporaries: [],
effects: [
{
kind: 'Freeze',
value: '@config',
reason: ValueReasonEnum.KnownReturnSignature,
},
{
kind: 'Create',
into: '@returns',
value: ValueKindEnum.Frozen,
reason: ValueReasonEnum.KnownReturnSignature,
},
{
kind: 'ImmutableCapture',
from: '@config',
into: '@returns',
},
],
},
},
},
},
}, },
}; };
} else if (moduleName === 'ReactCompilerKnownIncompatibleTest') { } else if (moduleName === 'ReactCompilerKnownIncompatibleTest') {

View File

@@ -421,4 +421,10 @@ export function typedMutate(x: any, v: any = null): void {
x.property = v; x.property = v;
} }
export const PanResponder = {
create(obj: any): any {
return obj;
},
};
export default typedLog; export default typedLog;