Compare commits

..

11 Commits

Author SHA1 Message Date
Joe Savona
89535e5d18 [compiler] More claude config 2026-01-15 17:13:04 -08:00
Joe Savona
997df89565 [compiler] Overhaul impurity/ref validation 2026-01-15 17:12:39 -08:00
Joe Savona
1022b06eb7 [compiler] Improve snap, no more testfilter.txt file 2026-01-15 17:06:02 -08:00
Joe Savona
cca2c4beda [compiler] Simplify validation against writing refs during render
Uses a simple algorithm for detecting writes of refs during render, since the ref _read_ during render is now handled as part of ValidateNoImpureValuesInRender.
2026-01-14 17:21:24 -08:00
Joe Savona
13f6508c21 [compiler] Update snap to support --nodebug
Passing `--nodebug` will disable debug mode, even if only a single fixture is filtered. This can help when running tests one by one and checking if their output is correct - debug mode causes tests to fail because there is extra/different error output.
2026-01-14 13:40:15 -08:00
Joe Savona
ae3c5a707f [compiler] Revert error description 2026-01-14 13:18:15 -08:00
Joe Savona
cb021861a5 [compiler] Simplify ref mutation validation to single forward pass
Rewrites ValidateNoRefAccessInRender to use a simpler single-pass algorithm
that only validates ref mutations (reads are now handled separately by
ValidateNoImpureValuesInRender).

The new approach:
- Tracks refs and ref values through the function
- Identifies functions that mutate refs (directly or transitively)
- Only errors when ref-mutating functions are called at the top level
- Supports null-guard exception: mutations inside `if (ref.current == null)`
  are allowed for the initialization pattern

This reduces ~700 lines of complex fixpoint iteration to ~400 lines of
straightforward forward data-flow analysis.
2026-01-14 13:11:17 -08:00
Joe Savona
7505808cd4 [compiler] Claude file/settings 2026-01-13 18:57:40 -08:00
Joe Savona
a974753ddf [compiler] Use aliasing effects for impurity inference 2026-01-13 18:56:35 -08:00
Joe Savona
2c4a3b9587 [compiler] Validate ref reads in render via improved impure value validation
Updates to guard against *reading* refs during render via the improved validateNoImpureValuesInRender() pass. InferMutationAliasingEffects generates `Impure` effects for ref reads, and then this pass validates that those accesses don't flow into `Render` effects. We now call the impure value validation first so that it takes priority over validateNoRefAccessInRender - the latter still has all the existing logic for now, but we can dramatically simplify it in a follow-up PR so that it only has to validate against ref writes.
2026-01-12 16:38:51 -08:00
Joe Savona
8428a7ec5f [compiler] Improve impurity/ref tracking
note: This implements the idea discussed in https://github.com/reactwg/react/discussions/389#discussioncomment-14252280

Currently we create `Impure` effects for impure functions like `Date.now()` or `Math.random()`, and then throw if the effect is reachable during render. However, impurity is a property of the resulting value: if the value isn't accessed during render then it's okay: maybe you're console-logging the time while debugging (fine), or storing the impure value into a ref and only accessing it in an effect or event handler (totally ok).

This PR updates to validate that impure values are not transitively consumed during render, building on the `Render` effect. The validation currently uses an algorithm very similar to that of InferReactivePlaces - building a set of known impure values, starting with `Impure` effects as the sources and then flowing outward via data flow and mutations. If a transitively impure value reaches a `Render` effect, it's an error. We record both the source of the impure value and where it gets rendered to make it easier to understand and fix errors:

```
Error: Cannot access impure value during render

Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).

error.invalid-impure-functions-in-render-via-render-helper.ts:10:25
   8 |     const array = makeArray(now);
   9 |     const hasDate = identity(array);
> 10 |     return <Bar hasDate={hasDate} />;
     |                          ^^^^^^^ Cannot access impure value during render
  11 |   };
  12 |   return <Foo renderItem={renderItem} />;
  13 | }

error.invalid-impure-functions-in-render-via-render-helper.ts:6:14
  4 |
  5 | function Component() {
> 6 |   const now = Date.now();
    |               ^^^^^^^^^^ `Date.now` is an impure function.
  7 |   const renderItem = () => {
  8 |     const array = makeArray(now);
  9 |     const hasDate = identity(array);
```

Impure values are allowed to flow into refs, meaning that we now allow `useRef(Date.now())` or `useRef(localFunctionThatReturnsMathDotRandom())` which would have errored previously. The next PR reuses this improved impurity tracking to validate ref access in render as well.
2026-01-12 16:38:51 -08:00
10 changed files with 62 additions and 70 deletions

View File

@@ -1,7 +1,3 @@
## Pending
* Improve impurity and ref validation, reducing false positives [#35298](https://github.com/facebook/react/pull/35298) by [@josephsavona](https://github.com/josephsavona)
## 19.1.0-rc.2 (May 14, 2025)
## babel-plugin-react-compiler

View File

@@ -19,16 +19,10 @@ This document contains knowledge about the React Compiler gathered during develo
# Run all tests
yarn snap
# Run tests matching a pattern
# Example: yarn snap -p 'error.*'
# Run specific test by pattern
yarn snap -p <pattern>
# Run a single fixture in debug mode. Use the path relative to the __tests__/fixtures/compiler directory
# For each step of compilation, outputs the step name and state of the compiled program
# Example: yarn snap -p simple.js -d
yarn snap -p <file-basename> -d
# Update fixture outputs (also works with -p)
# Update fixture outputs
yarn snap -u
```

View File

@@ -132,6 +132,12 @@ export class CompilerDiagnostic {
return new CompilerDiagnostic({...options, details: []});
}
clone(): CompilerDiagnostic {
const cloned = CompilerDiagnostic.create({...this.options});
cloned.options.details = [...this.options.details];
return cloned;
}
get reason(): CompilerDiagnosticOptions['reason'] {
return this.options.reason;
}

View File

@@ -626,7 +626,7 @@ const TYPED_GLOBALS: Array<[string, BuiltInType]> = [
// TODO: rest of Global objects
];
const createRenderHookAliasing: (
const RenderHookAliasing: (
reason: ValueReason,
) => AliasingSignatureConfig = reason => ({
receiver: '@receiver',
@@ -745,12 +745,12 @@ const useEffectEvent = addHook(
returns: '@return',
temporaries: [],
effects: [
{kind: 'Assign', from: '@value', into: '@return'},
{
kind: 'Freeze',
value: '@value',
reason: ValueReason.HookCaptured,
},
{kind: 'Assign', from: '@value', into: '@return'},
],
},
},
@@ -769,7 +769,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useContext',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.Context,
aliasing: createRenderHookAliasing(ValueReason.Context),
aliasing: RenderHookAliasing(ValueReason.Context),
},
BuiltInUseContextHookId,
),
@@ -784,7 +784,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.State),
aliasing: RenderHookAliasing(ValueReason.State),
}),
],
[
@@ -797,7 +797,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useActionState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -810,7 +810,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useReducer',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.ReducerState,
aliasing: createRenderHookAliasing(ValueReason.ReducerState),
aliasing: RenderHookAliasing(ValueReason.ReducerState),
}),
],
[
@@ -860,7 +860,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useMemo',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -877,7 +877,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useCallback',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -937,7 +937,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useTransition',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -950,7 +950,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useOptimistic',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -964,7 +964,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnType: {kind: 'Poly'},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
},
BuiltInUseOperatorId,
),

View File

@@ -983,6 +983,14 @@ export function printAliasingEffect(effect: AliasingEffect): string {
return `...${printPlaceForAliasEffect(arg.place)}`;
})
.join(', ');
// let signature = '';
// if (effect.signature != null) {
// if (effect.signature.aliasing != null) {
// signature = printAliasingSignature(effect.signature.aliasing);
// } else {
// signature = JSON.stringify(effect.signature, null, 2);
// }
// }
return `Apply ${printPlaceForAliasEffect(effect.into)} = ${receiverCallee}(${args})`;
}
case 'Freeze': {

View File

@@ -29,6 +29,7 @@ import {
isArrayType,
isJsxOrJsxUnionType,
isMapType,
isMutableEffect,
isPrimitiveType,
isRefOrRefValue,
isSetType,

View File

@@ -581,7 +581,9 @@ export function inferMutationAliasingRanges(
if (
errors.hasAnyErrors() &&
(!isFunctionExpression || isJsxOrJsxUnionType(fn.returns.identifier.type))
(fn.fnType === 'Component' ||
isJsxOrJsxUnionType(fn.returns.identifier.type) ||
!isFunctionExpression)
) {
return Err(errors);
}

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerDiagnostic, CompilerError} from '..';
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {
areEqualSourceLocations,
HIRFunction,
@@ -19,6 +19,7 @@ import {createControlDominators} from '../Inference/ControlDominators';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {Err, Ok, Result} from '../Utils/Result';
import {getOrInsertWith} from '../Utils/utils';
import {printFunction} from '../HIR/PrintHIR';
type ImpureEffect = Extract<AliasingEffect, {kind: 'Impure'}>;
type RenderEffect = Extract<AliasingEffect, {kind: 'Render'}>;
@@ -94,6 +95,9 @@ function processEffects(
!isUseRefType(effect.into.identifier) &&
!isJsxType(effect.into.identifier.type)
) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} <= $${effect.from.identifier.id} ($${sourceEffect.into.identifier.id} forward)`,
// );
impure.set(effect.into.identifier.id, sourceEffect);
hasChanges = true;
}
@@ -107,6 +111,9 @@ function processEffects(
) {
const destinationEffect = impure.get(effect.into.identifier.id);
if (destinationEffect != null) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} => $${effect.from.identifier.id} ($${destinationEffect.into.identifier.id} backward)`,
// );
impure.set(effect.from.identifier.id, destinationEffect);
hasChanges = true;
}
@@ -122,15 +129,15 @@ function processEffects(
if (
functionEffect != null &&
!impureFunctions.has(effect.into.identifier.id)
/*
* TODO: check if the function signature has changed (should be rare)
* ||
* !areEqualFunctionSignatures(
* impureFunctions.get(effect.into.identifier.id)!.effects,
* functionEffect.effects,
* )
*/
// ||
// !areEqualFunctionSignatures(
// impureFunctions.get(effect.into.identifier.id)!.effects,
// functionEffect.effects,
// )
) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} <= $${effect.from.identifier.id} (function)`,
// );
impureFunctions.set(effect.into.identifier.id, functionEffect);
hasChanges = true;
}
@@ -139,6 +146,7 @@ function processEffects(
}
case 'Impure': {
if (!impure.has(effect.into.identifier.id)) {
// console.log(`Impure $${effect.into.identifier.id}`);
impure.set(effect.into.identifier.id, effect);
hasChanges = true;
}
@@ -162,6 +170,7 @@ function processEffects(
previousResult == null ||
!areEqualFunctionSignatures(result.effects, previousResult.effects)
) {
// console.log(`Function $${effect.into.identifier.id}`);
impureFunctions.set(effect.into.identifier.id, result);
hasChanges = true;
}
@@ -224,6 +233,10 @@ function inferImpureValues(
}
for (const block of fn.body.blocks.values()) {
const controlPlace = getBlockControl(block.id);
const controlImpureEffect =
controlPlace != null ? impure.get(controlPlace.identifier.id) : null;
for (const phi of block.phis) {
if (impure.has(phi.place.identifier.id)) {
// Already marked impure on a previous pass
@@ -255,30 +268,8 @@ function inferImpureValues(
}
}
/**
* TODO: consider propagating impurity for assignments/mutations that
* are controlled by an impure value.
*
* ```
* const controlPlace = getBlockControl(block.id);
* const controlImpureEffect =
* controlPlace != null ? impure.get(controlPlace.identifier.id) : null;
* ```
*
* Example
*
* This should error since we know the semantics of array.push, it's a definite
* Mutate and definite Capture, not maybemutate+maybecapture:
*
* ```
* let x = [];
* if (Date.now() < START_DATE) {
* x.push(1);
* }
* return <Foo x={x} />
* ```
*/
for (const instr of block.instructions) {
const _impure = new Set(impure.keys());
hasChanges =
processEffects(
instr.id,

View File

@@ -177,20 +177,16 @@ function validateFunction(
if (block.terminal.kind === 'if') {
const guard = guards.get(block.terminal.test.identifier.id);
if (guard != null) {
/*
* For equality checks (==, ===), consequent is safe (condition true = ref is null)
* For inequality checks (!=, !==), alternate is safe (condition false = ref is null)
*/
// For equality checks (==, ===), consequent is safe (condition true = ref is null)
// For inequality checks (!=, !==), alternate is safe (condition false = ref is null)
const safeBlock = guard.isEquality
? block.terminal.consequent
: block.terminal.alternate;
const fallthrough = block.terminal.fallthrough;
/*
* Propagate safety through control flow using a queue
* Stop when we reach the fallthrough (end of the guarded region)
*/
const queue: Array<BlockId> = [safeBlock];
// Propagate safety through control flow using a queue
// Stop when we reach the fallthrough (end of the guarded region)
const queue: BlockId[] = [safeBlock];
const visited = new Set<BlockId>();
while (queue.length > 0) {
const blockId = queue.shift()!;
@@ -392,10 +388,8 @@ function processInstruction(
case 'FunctionExpression':
case 'ObjectMethod': {
/*
* Recursively validate function expressions
* Pass isTopLevel=false since these are nested functions
*/
// Recursively validate function expressions
// Pass isTopLevel=false since these are nested functions
const mutation = validateFunction(
value.loweredFunc.func,
refs,

View File

@@ -27,7 +27,7 @@ testRule(
return value;
}
`,
errors: [makeTestCaseError('Cannot access ref value during render')],
errors: [makeTestCaseError('Cannot access refs during render')],
},
],
},