Skip to content

Commit 21f2824

Browse files
authored
[compiler] Allow ref access in callbacks passed to event handler props (facebook#35062)
## Summary Fixes facebook#35040. The React compiler incorrectly flags ref access within event handlers as ref access at render time. For example, this code would fail to compile with error "Cannot access refs during render": ```tsx const onSubmit = async (data) => { const file = ref.current?.toFile(); // Incorrectly flagged as error }; <form onSubmit={handleSubmit(onSubmit)}> ``` This is a false positive because any built-in DOM event handler is guaranteed not to run at render time. This PR only supports built-in event handlers because there are no guarantees that user-made event handlers will not run at render time. ## How did you test this change? I created 4 test fixtures which validate this change: * allow-ref-access-in-event-handler-wrapper.tsx - Sync handler test input * allow-ref-access-in-event-handler-wrapper.expect.md - Sync handler expected output * allow-ref-access-in-async-event-handler-wrapper.tsx - Async handler test input * allow-ref-access-in-async-event-handler-wrapper.expect.md - Async handler expected output All linters and test suites also pass.
1 parent 257b033 commit 21f2824

13 files changed

+603
-22
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,15 @@ export const EnvironmentConfigSchema = z.object({
677677
* from refs need to be stored in state during mount.
678678
*/
679679
enableAllowSetStateFromRefsInEffects: z.boolean().default(true),
680+
681+
/**
682+
* Enables inference of event handler types for JSX props on built-in DOM elements.
683+
* When enabled, functions passed to event handler props (props starting with "on")
684+
* on primitive JSX tags are inferred to have the BuiltinEventHandlerId type, which
685+
* allows ref access within those functions since DOM event handlers are guaranteed
686+
* by React to only execute in response to events, not during render.
687+
*/
688+
enableInferEventHandlers: z.boolean().default(false),
680689
});
681690

682691
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;

compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
BuiltInUseTransitionId,
3030
BuiltInWeakMapId,
3131
BuiltInWeakSetId,
32-
BuiltinEffectEventId,
32+
BuiltInEffectEventId,
3333
ReanimatedSharedValueId,
3434
ShapeRegistry,
3535
addFunction,
@@ -863,7 +863,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
863863
returnType: {
864864
kind: 'Function',
865865
return: {kind: 'Poly'},
866-
shapeId: BuiltinEffectEventId,
866+
shapeId: BuiltInEffectEventId,
867867
isConstructor: false,
868868
},
869869
calleeEffect: Effect.Read,

compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,9 @@ export const BuiltInStartTransitionId = 'BuiltInStartTransition';
403403
export const BuiltInFireId = 'BuiltInFire';
404404
export const BuiltInFireFunctionId = 'BuiltInFireFunction';
405405
export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent';
406-
export const BuiltinEffectEventId = 'BuiltInEffectEventFunction';
406+
export const BuiltInEffectEventId = 'BuiltInEffectEventFunction';
407407
export const BuiltInAutodepsId = 'BuiltInAutoDepsId';
408+
export const BuiltInEventHandlerId = 'BuiltInEventHandlerId';
408409

409410
// See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types
410411
export const ReanimatedSharedValueId = 'ReanimatedSharedValueId';
@@ -1243,7 +1244,20 @@ addFunction(
12431244
calleeEffect: Effect.ConditionallyMutate,
12441245
returnValueKind: ValueKind.Mutable,
12451246
},
1246-
BuiltinEffectEventId,
1247+
BuiltInEffectEventId,
1248+
);
1249+
1250+
addFunction(
1251+
BUILTIN_SHAPES,
1252+
[],
1253+
{
1254+
positionalParams: [],
1255+
restParam: Effect.ConditionallyMutate,
1256+
returnType: {kind: 'Poly'},
1257+
calleeEffect: Effect.ConditionallyMutate,
1258+
returnValueKind: ValueKind.Mutable,
1259+
},
1260+
BuiltInEventHandlerId,
12471261
);
12481262

12491263
/**

compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '../HIR/HIR';
2626
import {
2727
BuiltInArrayId,
28+
BuiltInEventHandlerId,
2829
BuiltInFunctionId,
2930
BuiltInJsxId,
3031
BuiltInMixedReadonlyId,
@@ -471,6 +472,41 @@ function* generateInstructionTypes(
471472
}
472473
}
473474
}
475+
if (env.config.enableInferEventHandlers) {
476+
if (
477+
value.kind === 'JsxExpression' &&
478+
value.tag.kind === 'BuiltinTag' &&
479+
!value.tag.name.includes('-')
480+
) {
481+
/*
482+
* Infer event handler types for built-in DOM elements.
483+
* Props starting with "on" (e.g., onClick, onSubmit) on primitive tags
484+
* are inferred as event handlers. This allows functions with ref access
485+
* to be passed to these props, since DOM event handlers are guaranteed
486+
* by React to only execute in response to events, never during render.
487+
*
488+
* We exclude tags with hyphens to avoid web components (custom elements),
489+
* which are required by the HTML spec to contain a hyphen. Web components
490+
* may call event handler props during their lifecycle methods (e.g.,
491+
* connectedCallback), which would be unsafe for ref access.
492+
*/
493+
for (const prop of value.props) {
494+
if (
495+
prop.kind === 'JsxAttribute' &&
496+
prop.name.startsWith('on') &&
497+
prop.name.length > 2 &&
498+
prop.name[2] === prop.name[2].toUpperCase()
499+
) {
500+
yield equation(prop.place.identifier.type, {
501+
kind: 'Function',
502+
shapeId: BuiltInEventHandlerId,
503+
return: makeType(),
504+
isConstructor: false,
505+
});
506+
}
507+
}
508+
}
509+
}
474510
yield equation(left, {kind: 'Object', shapeId: BuiltInJsxId});
475511
break;
476512
}

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
BlockId,
1515
HIRFunction,
1616
IdentifierId,
17+
Identifier,
1718
Place,
1819
SourceLocation,
1920
getHookKindForType,
2021
isRefValueType,
2122
isUseRefType,
2223
} from '../HIR';
24+
import {BuiltInEventHandlerId} from '../HIR/ObjectShape';
2325
import {
2426
eachInstructionOperand,
2527
eachInstructionValueOperand,
@@ -183,6 +185,11 @@ function refTypeOfType(place: Place): RefAccessType {
183185
}
184186
}
185187

188+
function isEventHandlerType(identifier: Identifier): boolean {
189+
const type = identifier.type;
190+
return type.kind === 'Function' && type.shapeId === BuiltInEventHandlerId;
191+
}
192+
186193
function tyEqual(a: RefAccessType, b: RefAccessType): boolean {
187194
if (a.kind !== b.kind) {
188195
return false;
@@ -519,36 +526,26 @@ function validateNoRefAccessInRenderImpl(
519526
*/
520527
if (!didError) {
521528
const isRefLValue = isUseRefType(instr.lvalue.identifier);
529+
const isEventHandlerLValue = isEventHandlerType(
530+
instr.lvalue.identifier,
531+
);
522532
for (const operand of eachInstructionValueOperand(instr.value)) {
523533
/**
524534
* By default we check that function call operands are not refs,
525535
* ref values, or functions that can access refs.
526536
*/
527537
if (
528538
isRefLValue ||
539+
isEventHandlerLValue ||
529540
(hookKind != null &&
530541
hookKind !== 'useState' &&
531542
hookKind !== 'useReducer')
532543
) {
533544
/**
534-
* Special cases:
535-
*
536-
* 1. the lvalue is a ref
537-
* In general passing a ref to a function may access that ref
538-
* value during render, so we disallow it.
539-
*
540-
* The main exception is the "mergeRefs" pattern, ie a function
541-
* that accepts multiple refs as arguments (or an array of refs)
542-
* and returns a new, aggregated ref. If the lvalue is a ref,
543-
* we assume that the user is doing this pattern and allow passing
544-
* refs.
545-
*
546-
* Eg `const mergedRef = mergeRefs(ref1, ref2)`
547-
*
548-
* 2. calling hooks
549-
*
550-
* Hooks are independently checked to ensure they don't access refs
551-
* during render.
545+
* Allow passing refs or ref-accessing functions when:
546+
* 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`)
547+
* 2. lvalue is an event handler (DOM events execute outside render)
548+
* 3. calling hooks (independently validated for ref safety)
552549
*/
553550
validateNoDirectRefValueAccess(errors, operand, env);
554551
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
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+
// @enableInferEventHandlers
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"; // @enableInferEventHandlers
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+
// @enableInferEventHandlers
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+
};

0 commit comments

Comments
 (0)