Skip to content

Commit afef137

Browse files
committed
[compiler] Allow ref access in callbacks passed to DOM event handlers
Fixes a false positive where the compiler incorrectly flags ref access in callbacks passed to event handler wrappers on built-in DOM elements. For example, with react-hook-form: <form onSubmit={handleSubmit(onSubmit)}> Where onSubmit accesses ref.current, this was incorrectly flagged as "Cannot access refs during render". This is a false positive because built-in DOM event handlers are guaranteed by React to only execute in response to actual events, never during render. This fix only relaxes validation for built-in DOM elements (not custom components) to maintain safety. Custom components could call their onFoo props during render, which would violate ref access rules. Adds four test fixtures demonstrating allowed and disallowed patterns. Issue: #35040
1 parent c9ddee7 commit afef137

10 files changed

+573
-0
lines changed

compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ function validateNoRefAccessInRenderImpl(
354354
}
355355

356356
const interpolatedAsJsx = new Set<IdentifierId>();
357+
const usedAsEventHandlerProp = new Set<IdentifierId>();
357358
for (const block of fn.body.blocks.values()) {
358359
for (const instr of block.instructions) {
359360
const {value} = instr;
@@ -363,6 +364,27 @@ function validateNoRefAccessInRenderImpl(
363364
interpolatedAsJsx.add(child.identifier.id);
364365
}
365366
}
367+
if (value.kind === 'JsxExpression') {
368+
/*
369+
* Track identifiers used as event handler props on built-in DOM elements.
370+
* We only allow this optimization for native DOM elements because their
371+
* event handlers are guaranteed by React to only execute in response to
372+
* events, never during render. Custom components could technically call
373+
* their onFoo props during render, which would violate ref access rules.
374+
*/
375+
if (value.tag.kind === 'BuiltinTag') {
376+
for (const prop of value.props) {
377+
if (
378+
prop.kind === 'JsxAttribute' &&
379+
prop.name.startsWith('on') &&
380+
prop.name.length > 2 &&
381+
prop.name[2] === prop.name[2].toUpperCase()
382+
) {
383+
usedAsEventHandlerProp.add(prop.place.identifier.id);
384+
}
385+
}
386+
}
387+
}
366388
}
367389
}
368390
}
@@ -519,6 +541,9 @@ function validateNoRefAccessInRenderImpl(
519541
*/
520542
if (!didError) {
521543
const isRefLValue = isUseRefType(instr.lvalue.identifier);
544+
const isUsedAsEventHandler = usedAsEventHandlerProp.has(
545+
instr.lvalue.identifier.id,
546+
);
522547
for (const operand of eachInstructionValueOperand(instr.value)) {
523548
/**
524549
* By default we check that function call operands are not refs,
@@ -560,6 +585,25 @@ function validateNoRefAccessInRenderImpl(
560585
* render function which attempts to obey the rules.
561586
*/
562587
validateNoRefValueAccess(errors, env, operand);
588+
} else if (isUsedAsEventHandler) {
589+
/**
590+
* Special case: the lvalue is used as an event handler prop
591+
* on a built-in DOM element
592+
*
593+
* For example `<form onSubmit={handleSubmit(onSubmit)}>`. Here
594+
* handleSubmit is wrapping onSubmit to create an event handler.
595+
* Functions passed to event handler wrappers can safely access
596+
* refs because built-in DOM event handlers are guaranteed by React
597+
* to only execute in response to actual events, never during render.
598+
*
599+
* We only allow this for built-in DOM elements (not custom components)
600+
* because custom components could technically call their onFoo props
601+
* during render, which would violate ref access rules.
602+
*
603+
* We allow passing functions with ref access to these wrappers,
604+
* but still validate that direct ref values aren't passed.
605+
*/
606+
validateNoDirectRefValueAccess(errors, operand, env);
563607
} else {
564608
validateNoRefPassedToFunction(
565609
errors,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateRefAccessDuringRender
6+
import {useRef} from 'react';
7+
8+
// Simulates react-hook-form's handleSubmit
9+
function handleSubmit<T>(callback: (data: T) => void | Promise<void>) {
10+
return (event: any) => {
11+
event.preventDefault();
12+
callback({} as T);
13+
};
14+
}
15+
16+
// Simulates an upload function
17+
async function upload(file: any): Promise<{blob: {url: string}}> {
18+
return {blob: {url: 'https://example.com/file.jpg'}};
19+
}
20+
21+
interface SignatureRef {
22+
toFile(): any;
23+
}
24+
25+
function Component() {
26+
const ref = useRef<SignatureRef>(null);
27+
28+
const onSubmit = async (value: any) => {
29+
// This should be allowed: accessing ref.current in an async event handler
30+
// that's wrapped and passed to onSubmit prop
31+
let sigUrl: string;
32+
if (value.hasSignature) {
33+
const {blob} = await upload(ref.current?.toFile());
34+
sigUrl = blob?.url || '';
35+
} else {
36+
sigUrl = value.signature;
37+
}
38+
console.log('Signature URL:', sigUrl);
39+
};
40+
41+
return (
42+
<form onSubmit={handleSubmit(onSubmit)}>
43+
<input type="text" name="signature" />
44+
<button type="submit">Submit</button>
45+
</form>
46+
);
47+
}
48+
49+
export const FIXTURE_ENTRYPOINT = {
50+
fn: Component,
51+
params: [{}],
52+
};
53+
54+
```
55+
56+
## Code
57+
58+
```javascript
59+
import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender
60+
import { useRef } from "react";
61+
62+
// Simulates react-hook-form's handleSubmit
63+
function handleSubmit(callback) {
64+
const $ = _c(2);
65+
let t0;
66+
if ($[0] !== callback) {
67+
t0 = (event) => {
68+
event.preventDefault();
69+
callback({} as T);
70+
};
71+
$[0] = callback;
72+
$[1] = t0;
73+
} else {
74+
t0 = $[1];
75+
}
76+
return t0;
77+
}
78+
79+
// Simulates an upload function
80+
async function upload(file) {
81+
const $ = _c(1);
82+
let t0;
83+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
84+
t0 = { blob: { url: "https://example.com/file.jpg" } };
85+
$[0] = t0;
86+
} else {
87+
t0 = $[0];
88+
}
89+
return t0;
90+
}
91+
92+
interface SignatureRef {
93+
toFile(): any;
94+
}
95+
96+
function Component() {
97+
const $ = _c(4);
98+
const ref = useRef(null);
99+
100+
const onSubmit = async (value) => {
101+
let sigUrl;
102+
if (value.hasSignature) {
103+
const { blob } = await upload(ref.current?.toFile());
104+
sigUrl = blob?.url || "";
105+
} else {
106+
sigUrl = value.signature;
107+
}
108+
109+
console.log("Signature URL:", sigUrl);
110+
};
111+
112+
const t0 = handleSubmit(onSubmit);
113+
let t1;
114+
let t2;
115+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
116+
t1 = <input type="text" name="signature" />;
117+
t2 = <button type="submit">Submit</button>;
118+
$[0] = t1;
119+
$[1] = t2;
120+
} else {
121+
t1 = $[0];
122+
t2 = $[1];
123+
}
124+
let t3;
125+
if ($[2] !== t0) {
126+
t3 = (
127+
<form onSubmit={t0}>
128+
{t1}
129+
{t2}
130+
</form>
131+
);
132+
$[2] = t0;
133+
$[3] = t3;
134+
} else {
135+
t3 = $[3];
136+
}
137+
return t3;
138+
}
139+
140+
export const FIXTURE_ENTRYPOINT = {
141+
fn: Component,
142+
params: [{}],
143+
};
144+
145+
```
146+
147+
### Eval output
148+
(kind: ok) <form><input type="text" name="signature"><button type="submit">Submit</button></form>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// @validateRefAccessDuringRender
2+
import {useRef} from 'react';
3+
4+
// Simulates react-hook-form's handleSubmit
5+
function handleSubmit<T>(callback: (data: T) => void | Promise<void>) {
6+
return (event: any) => {
7+
event.preventDefault();
8+
callback({} as T);
9+
};
10+
}
11+
12+
// Simulates an upload function
13+
async function upload(file: any): Promise<{blob: {url: string}}> {
14+
return {blob: {url: 'https://example.com/file.jpg'}};
15+
}
16+
17+
interface SignatureRef {
18+
toFile(): any;
19+
}
20+
21+
function Component() {
22+
const ref = useRef<SignatureRef>(null);
23+
24+
const onSubmit = async (value: any) => {
25+
// This should be allowed: accessing ref.current in an async event handler
26+
// that's wrapped and passed to onSubmit prop
27+
let sigUrl: string;
28+
if (value.hasSignature) {
29+
const {blob} = await upload(ref.current?.toFile());
30+
sigUrl = blob?.url || '';
31+
} else {
32+
sigUrl = value.signature;
33+
}
34+
console.log('Signature URL:', sigUrl);
35+
};
36+
37+
return (
38+
<form onSubmit={handleSubmit(onSubmit)}>
39+
<input type="text" name="signature" />
40+
<button type="submit">Submit</button>
41+
</form>
42+
);
43+
}
44+
45+
export const FIXTURE_ENTRYPOINT = {
46+
fn: Component,
47+
params: [{}],
48+
};
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateRefAccessDuringRender
6+
import {useRef} from 'react';
7+
8+
// Simulates react-hook-form's handleSubmit or similar event handler wrappers
9+
function handleSubmit<T>(callback: (data: T) => void) {
10+
return (event: any) => {
11+
event.preventDefault();
12+
callback({} as T);
13+
};
14+
}
15+
16+
function Component() {
17+
const ref = useRef<HTMLInputElement>(null);
18+
19+
const onSubmit = (data: any) => {
20+
// This should be allowed: accessing ref.current in an event handler
21+
// that's wrapped by handleSubmit and passed to onSubmit prop
22+
if (ref.current !== null) {
23+
console.log(ref.current.value);
24+
}
25+
};
26+
27+
return (
28+
<>
29+
<input ref={ref} />
30+
<form onSubmit={handleSubmit(onSubmit)}>
31+
<button type="submit">Submit</button>
32+
</form>
33+
</>
34+
);
35+
}
36+
37+
export const FIXTURE_ENTRYPOINT = {
38+
fn: Component,
39+
params: [{}],
40+
};
41+
42+
```
43+
44+
## Code
45+
46+
```javascript
47+
import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender
48+
import { useRef } from "react";
49+
50+
// Simulates react-hook-form's handleSubmit or similar event handler wrappers
51+
function handleSubmit(callback) {
52+
const $ = _c(2);
53+
let t0;
54+
if ($[0] !== callback) {
55+
t0 = (event) => {
56+
event.preventDefault();
57+
callback({} as T);
58+
};
59+
$[0] = callback;
60+
$[1] = t0;
61+
} else {
62+
t0 = $[1];
63+
}
64+
return t0;
65+
}
66+
67+
function Component() {
68+
const $ = _c(1);
69+
const ref = useRef(null);
70+
let t0;
71+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
72+
const onSubmit = (data) => {
73+
if (ref.current !== null) {
74+
console.log(ref.current.value);
75+
}
76+
};
77+
78+
t0 = (
79+
<>
80+
<input ref={ref} />
81+
<form onSubmit={handleSubmit(onSubmit)}>
82+
<button type="submit">Submit</button>
83+
</form>
84+
</>
85+
);
86+
$[0] = t0;
87+
} else {
88+
t0 = $[0];
89+
}
90+
return t0;
91+
}
92+
93+
export const FIXTURE_ENTRYPOINT = {
94+
fn: Component,
95+
params: [{}],
96+
};
97+
98+
```
99+
100+
### Eval output
101+
(kind: ok) <input><form><button type="submit">Submit</button></form>

0 commit comments

Comments
 (0)