Compare commits

..

3 Commits

Author SHA1 Message Date
Joe Savona
165cf8e429 [compiler] Improve snap usability
A whole bunch of changes to snap aimed at making it more usable for humans and agents. Here's the new CLI interface:

```
node dist/main.js --help
Options:
      --version         Show version number                            [boolean]
      --sync            Run compiler in main thread (instead of using worker
                        threads or subprocesses). Defaults to false.
                                                      [boolean] [default: false]
      --worker-threads  Run compiler in worker threads (instead of
                        subprocesses). Defaults to true.
                                                       [boolean] [default: true]
      --help            Show help                                      [boolean]
  -w, --watch           Run compiler in watch mode, re-running after changes
                                                                       [boolean]
  -u, --update          Update fixtures                                [boolean]
  -p, --pattern         Optional glob pattern to filter fixtures (e.g.,
                        "error.*", "use-memo")                          [string]
  -d, --debug           Enable debug logging to print HIR for each pass[boolean]
```

Key changes:
* Added abbreviations for common arguments
* No more testfilter.txt! Filtering/debugging works more like Jest, see below.
* The `--debug` flag (`-d`) controls whether to emit debug information. In watch mode, this flag sets the initial debug value, and it can be toggled by pressing the 'd' key while watching.
* The `--pattern` flag (`-p`) sets a filter pattern. In watch mode, this flag sets the initial filter. It can be changed by pressing 'p' and typing a new pattern, or pressing 'a' to switch to running all tests.
* As before, we only actually enable debugging if debug mode is enabled _and_ there is only one test selected.
2026-01-16 10:36:12 -08:00
Joe Savona
f85772c9ef [compiler] Claude file/settings
Initializes CLAUDE.md and a settings file for the compiler/ directory to help use claude with the compiler. Note that some of the commands here depend on changes to snap from the next PR.
2026-01-16 10:36:10 -08:00
Joe Savona
91a6cc3288 [compiler] Improve impurity/ref validation
# Summary

note: This implements the idea discussed in https://github.com/reactwg/react/discussions/389#discussioncomment-14252280

This is a large PR that significantly changes our impurity and ref validation to address multiple issues. The goal is to reduce false positives and make the errors we do report more actionable.

## Validating Against Impure Values In Render

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 new effects system: rather than look at instruction types, we use effects like `Capture a -> b`, `Impure a`, and `Render b` to determine how impure values are introduced and where they flow through the program. We're intentionally conservative, and do _not_ propagate impurity through MaybeCapture effects, which is used for functions we don't have signatures for. This means that things like `identity(performance.now())` drop the impurity. This feels like a good compromise since it means we have very high confidence in the errors that we report and can always add increased strictness later as our confidence increases.

An example error:

```
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-value-in-render-helper.ts:5:17
  3 |   const now = () => Date.now();
  4 |   const render = () => {
> 5 |     return <div>{now()}</div>;
    |                  ^^^^^ Cannot access impure value during render
  6 |   };
  7 |   return <div>{render()}</div>;
  8 | }

error.invalid-impure-value-in-render-helper.ts:3:20
  1 | // @validateNoImpureFunctionsInRender
  2 | function Component() {
> 3 |   const now = () => Date.now();
    |                     ^^^^^^^^^^ `Date.now` is an impure function.
  4 |   const render = () => {
  5 |     return <div>{now()}</div>;
  6 |   };
```

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.

## Refs Now Treated As Impure Values in Render

Reading a ref now produces an `Impure` effect, and reading refs in render is validated using the above validation against impure values in render. The error category and message is customized for refs, we're just reusing the validation implementation. This means you get consistent results for `performance.now()` as for `ref.current`. A nice consistency win.

## Simplified writing-ref validation

Now that _reading_ a ref in render is validated using the impure values infra, I also dramatically simplified ValidateNoRefAccessInRender to focus solely on validation against _writing_ refs during render. It was harder to use the new effects infra for this since we intentionally do not record ref mutations as a `Mutate` effect. So for now, the pass switches on InstructionValue variants. We continue to support the `if (ref.current == null) { ref.current = <init> }` pattern and reasonable variants of it. We're conservative about what we consider to be a write of a ref - `foo(ref)` now assumes you're not mutating rather than the inverse.

# Takeaways

* Impure-values-in-render logic is more conservative about what it considers an error (ie to users it appears more persmissive). We follow clearly identifiable (and if we wanted traceable/explainable) paths from impure sources through to where they are rendered. We allow many more cases than before, notably `x = foo(ref)` optimistically assumes you don't read the ref. Concretely, this follows from not tracking impure values across MaybeCapture effects.
* No-writing-refs-in-render is also more conservative about what it counts as a write, appearing to users as allowing more cases. We look for very direct evidence of writing to refs, ie `ref.current = <value>` or `ref.current.property = <value>`, and allow potential writes through eg `writeToRef(ref, value)`.
2026-01-16 10:36:08 -08:00
10 changed files with 70 additions and 62 deletions

View File

@@ -1,3 +1,7 @@
## 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,10 +19,16 @@ This document contains knowledge about the React Compiler gathered during develo
# Run all tests
yarn snap
# Run specific test by pattern
# Run tests matching a pattern
# Example: yarn snap -p 'error.*'
yarn snap -p <pattern>
# Update fixture outputs
# 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)
yarn snap -u
```

View File

@@ -132,12 +132,6 @@ 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 RenderHookAliasing: (
const createRenderHookAliasing: (
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: RenderHookAliasing(ValueReason.Context),
aliasing: createRenderHookAliasing(ValueReason.Context),
},
BuiltInUseContextHookId,
),
@@ -784,7 +784,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.State),
aliasing: createRenderHookAliasing(ValueReason.State),
}),
],
[
@@ -797,7 +797,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useActionState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -810,7 +810,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useReducer',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.ReducerState,
aliasing: RenderHookAliasing(ValueReason.ReducerState),
aliasing: createRenderHookAliasing(ValueReason.ReducerState),
}),
],
[
@@ -860,7 +860,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useMemo',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -877,7 +877,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useCallback',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -937,7 +937,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useTransition',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -950,7 +950,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useOptimistic',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -964,7 +964,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnType: {kind: 'Poly'},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
},
BuiltInUseOperatorId,
),

View File

@@ -983,14 +983,6 @@ 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,7 +29,6 @@ import {
isArrayType,
isJsxOrJsxUnionType,
isMapType,
isMutableEffect,
isPrimitiveType,
isRefOrRefValue,
isSetType,

View File

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

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {CompilerDiagnostic, CompilerError} from '..';
import {
areEqualSourceLocations,
HIRFunction,
@@ -19,7 +19,6 @@ 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'}>;
@@ -95,9 +94,6 @@ 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;
}
@@ -111,9 +107,6 @@ 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;
}
@@ -129,15 +122,15 @@ function processEffects(
if (
functionEffect != null &&
!impureFunctions.has(effect.into.identifier.id)
// ||
// !areEqualFunctionSignatures(
// impureFunctions.get(effect.into.identifier.id)!.effects,
// functionEffect.effects,
// )
/*
* TODO: check if the function signature has changed (should be rare)
* ||
* !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;
}
@@ -146,7 +139,6 @@ 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;
}
@@ -170,7 +162,6 @@ 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;
}
@@ -233,10 +224,6 @@ 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
@@ -268,8 +255,30 @@ 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,16 +177,20 @@ 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: BlockId[] = [safeBlock];
/*
* Propagate safety through control flow using a queue
* Stop when we reach the fallthrough (end of the guarded region)
*/
const queue: Array<BlockId> = [safeBlock];
const visited = new Set<BlockId>();
while (queue.length > 0) {
const blockId = queue.shift()!;
@@ -388,8 +392,10 @@ 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 refs during render')],
errors: [makeTestCaseError('Cannot access ref value during render')],
},
],
},