Skip to content

[compiler] Don't report errors for functions reached by forward mutation edges - #37779

Open
Aryan007-dev wants to merge 1 commit into
react:mainfrom
Aryan007-dev:compiler-purity-forward-edges
Open

Aryan007-dev wants to merge 1 commit into
react:mainfrom
Aryan007-dev:compiler-purity-forward-edges

Conversation

@Aryan007-dev

Copy link
Copy Markdown

Summary

Partially addresses #37761. This fixes repros 2, 3 and 4 there (forward and self-referencing closures). Repro 1 (.map()) has a different cause, described below.

InferMutationAliasingRanges treats mutating a function value as calling it. When mutate() reaches a Function node it records the function's errors (Impure, MutateFrozen, MutateGlobal). However, the traversal also reaches function nodes by forward edges (Capture x -> fn). That only means a value the function captured was mutated; the function itself was not called.

This happens with hoisted context variables. StoreContext later = ... emits Mutate later, and the mutation flows forward into every closure that captured later. Those closures are then reported even if they only ever run from an event handler or timer:

const arm = () => {
  setState(String(Date.now())); // reported: "Cannot call impure function during render"
  later();
};
const later = () => setState('done');
return <button onClick={arm} />;

Self-referencing closures (const tick = () => { Date.now(); setTimeout(tick) }) hit the same path.

Changes

  • mutate() only appends function errors when the node is reached by backward traversal: the call target itself, or something it aliases or captures.
  • Previously node.transitive == null && node.local == null doubled as the "report once" guard. A forward visit now marks the node as mutated without reporting it. Without a fix, that would suppress the report on a later real call, e.g. const fact = k => ... fact(k - 1) ...; fact(n) during render, which goes LoadContext → CreateFrom → captured function. So reported functions are now tracked in their own set. render() uses the same set.
  • The same change is mirrored in react_compiler_inference/src/infer_mutation_aliasing_ranges.rs.

Not addressed: repro 1 from #37761

With items.map(item => <button onClick={() => open(item)} />), the receiver type is unknown, so the call is modelled as MutateTransitiveConditionally on the callback. The transitive walk then follows the callback's capture of open backwards. That is the same path that correctly reports items.map(item => open(item)). Telling the two apart needs the callback's own aliasing signature, which is a bigger design question, so I left it out of this PR.

How did you test this change?

New fixtures:

  • impure-call-in-handler-calling-later-declared-function: issue repro 2. Previously errored, now compiles.
  • impure-call-in-later-declared-function-called-from-handler: issue repro 3. Previously errored, now compiles.
  • impure-call-in-self-referencing-function: issue repro 4. Previously errored, now compiles.
  • error.invalid-impure-call-via-later-declared-function-in-render: calling through a later-declared function during render is still reported.
  • error.invalid-impure-call-in-self-referencing-function-in-render: a self-referencing function called during render is still reported. An earlier version of this change, without the separate reported set, failed this fixture.

Checks run in compiler/:

  • yarn snap: 1832/1832 passed (no existing fixture output changed).
  • yarn snap --rust: 1832/1832 passed.
  • bash scripts/test-rust-port.sh: 1831/1831 passed.
  • bash scripts/test-babel-ast.sh: ok.
  • yarn workspace babel-plugin-react-compiler lint: passes. cargo fmt --check: clean. Prettier (repo options): clean.

…ion edges

InferMutationAliasingRanges treats mutating a function value as calling it and
reports the function's errors (eg impure calls). A function reached through a
forward edge only had one of its captured values mutated, eg when a hoisted
context variable it references is initialized, so this reported false purity
errors for event handlers that reference a later-declared or self-referencing
function. Only report on backward traversal, and track reported functions
separately so a later real call through the context variable is still caught.
@meta-cla

meta-cla Bot commented Oct 7, 2026

Copy link
Copy Markdown

Hi @Aryan007-dev!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at [email protected]. Thanks!

@meta-cla

meta-cla Bot commented Oct 7, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-cla meta-cla Bot added the CLA Signed label Oct 7, 2026
@meta-cla

meta-cla Bot commented Oct 7, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@UditDewan

Copy link
Copy Markdown
Contributor

Nice writeup, the forward-edge explanation matches what I found when I was poking at this.

I took a look at repro 1 (the .map() one) since this PR leaves it out. The cause is that items.map(cb) does a transitive mutation of cb, which walks back through cb's captures and hits open. But cb never calls open, it just builds the onClick arrow. You can tell from cb's own aliasingEffects, which have no Mutate* of open. So the fix is to only treat captured functions as called when the capturing function's effects actually mutate them.

I put that up separately in #37799 so it doesn't hold this one up. It touches the same condition in mutate(), so there'll be a small conflict whichever lands second. Feel free to pull the approach in here instead if you'd rather have it all in one PR, and I'll close mine.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants