[compiler] Allow all hooks to take callbacks which access refs, but ban hooks from taking direct ref value arguments
Summary: This brings the behavior of ref mutation within hook callbacks into alignment with the behavior of global mutations--that is, we allow all hooks to take callbacks that may mutate a ref. This is potentially unsafe if the hook eagerly calls its callback, but the alternative is excessively limiting (and inconsistent with other enforcement). This also bans *directly* passing a ref.current value to a hook, which was previously allowed. ghstack-source-id: e66ce7123ecf4a905adab957970d0ee5d41245e0 Pull Request resolved: https://github.com/facebook/react/pull/30917
Mike Vitousek committed
Sep 16, 2024 at 10:53 UTC
e78c9362c014dccaed5ff193106e44d7d072dc32
9 files changed
+238
-51
compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts
+11
@@ -364,6 +364,17 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
364
returnValueKind: ValueKind.Mutable,
365
}),
366
],
367
+ [
368
+ 'useImperativeHandle',
369
+ addHook(DEFAULT_SHAPES, {
370
+ positionalParams: [],
371
+ restParam: Effect.Freeze,
372
+ returnType: {kind: 'Primitive'},
373
+ calleeEffect: Effect.Read,
374
+ hookKind: 'useImperativeHandle',
375
+ returnValueKind: ValueKind.Frozen,
376
+ }),
377
+ ],
378
[
379
'useMemo',
380
addHook(DEFAULT_SHAPES, {
compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts
+1
@@ -127,6 +127,7 @@ export type HookKind =
127
| 'useMemo'
128
| 'useCallback'
129
| 'useTransition'
130
+ | 'useImperativeHandle'
131
| 'Custom';
132
133
/*
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts
+27
-51
@@ -22,7 +22,6 @@ import {
22
eachTerminalOperand,
23
} from '../HIR/visitors';
24
import {Err, Ok, Result} from '../Utils/Result';
25
-import {isEffectHook} from './ValidateMemoizedEffectDependencies';
25
26
/**
27
* Validates that a function does not access a ref value during render. This includes a partial check
@@ -310,60 +309,37 @@ function validateNoRefAccessInRenderImpl(
309
});
310
break;
311
}
313
- case 'MethodCall': {
314
- if (!isEffectHook(instr.value.property.identifier)) {
315
- for (const operand of eachInstructionValueOperand(instr.value)) {
316
- const hookKind = getHookKindForType(
317
- fn.env,
318
- instr.value.property.identifier.type,
319
- );
320
- if (hookKind != null) {
321
- validateNoRefValueAccess(errors, env, operand);
322
- } else {
323
- validateNoRefAccess(errors, env, operand, operand.loc);
324
- }
325
- }
326
- }
327
- validateNoRefValueAccess(errors, env, instr.value.receiver);
328
- const methType = env.get(instr.value.property.identifier.id);
329
- let returnType: RefAccessType = {kind: 'None'};
330
- if (methType?.kind === 'Structure' && methType.fn !== null) {
331
- returnType = methType.fn.returnType;
332
- }
333
- env.set(instr.lvalue.identifier.id, returnType);
334
- break;
335
- }
312
+ case 'MethodCall':
313
case 'CallExpression': {
337
- const callee = instr.value.callee;
314
+ const callee =
315
+ instr.value.kind === 'CallExpression'
316
+ ? instr.value.callee
317
+ : instr.value.property;
318
const hookKind = getHookKindForType(fn.env, callee.identifier.type);
339
- const isUseEffect = isEffectHook(callee.identifier);
319
let returnType: RefAccessType = {kind: 'None'};
341
- if (!isUseEffect) {
342
- // Report a more precise error when calling a local function that accesses a ref
343
- const fnType = env.get(instr.value.callee.identifier.id);
344
- if (fnType?.kind === 'Structure' && fnType.fn !== null) {
345
- returnType = fnType.fn.returnType;
346
- if (fnType.fn.readRefEffect) {
347
- errors.push({
348
- severity: ErrorSeverity.InvalidReact,
349
- reason:
350
- 'This function accesses a ref value (the `current` property), which may not be accessed during render. (https://react.dev/reference/react/useRef)',
351
- loc: callee.loc,
352
- description:
353
- callee.identifier.name !== null &&
354
- callee.identifier.name.kind === 'named'
355
- ? `Function \`${callee.identifier.name.value}\` accesses a ref`
356
- : null,
357
- suggestions: null,
358
- });
359
- }
320
+ const fnType = env.get(callee.identifier.id);
321
+ if (fnType?.kind === 'Structure' && fnType.fn !== null) {
322
+ returnType = fnType.fn.returnType;
323
+ if (fnType.fn.readRefEffect) {
324
+ errors.push({
325
+ severity: ErrorSeverity.InvalidReact,
326
+ reason:
327
+ 'This function accesses a ref value (the `current` property), which may not be accessed during render. (https://react.dev/reference/react/useRef)',
328
+ loc: callee.loc,
329
+ description:
330
+ callee.identifier.name !== null &&
331
+ callee.identifier.name.kind === 'named'
332
+ ? `Function \`${callee.identifier.name.value}\` accesses a ref`
333
+ : null,
334
+ suggestions: null,
335
+ });
336
}
361
- for (const operand of eachInstructionValueOperand(instr.value)) {
362
- if (hookKind != null) {
363
- validateNoRefValueAccess(errors, env, operand);
364
- } else {
365
- validateNoRefAccess(errors, env, operand, operand.loc);
366
- }
337
+ }
338
+ for (const operand of eachInstructionValueOperand(instr.value)) {
339
+ if (hookKind != null) {
340
+ validateNoDirectRefValueAccess(errors, operand, env);
341
+ } else {
342
+ validateNoRefAccess(errors, env, operand, operand.loc);
343
}
344
}
345
env.set(instr.lvalue.identifier.id, returnType);
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-ref-value.expect.md
new
+34
@@ -0,0 +1,34 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import {useEffect, useRef} from 'react';
6
+
7
+function Component(props) {
8
+ const ref = useRef();
9
+ useEffect(() => {}, [ref.current]);
10
+}
11
+
12
+export const FIXTURE_ENTRYPOINT = {
13
+ fn: Component,
14
+ params: [],
15
+};
16
+
17
+```
18
+
19
+
20
+## Error
21
+
22
+```
23
+ 3 | function Component(props) {
24
+ 4 | const ref = useRef();
25
+> 5 | useEffect(() => {}, [ref.current]);
26
+ | ^^^^^^^^^^^ InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef) (5:5)
27
+
28
+InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef) (5:5)
29
+ 6 | }
30
+ 7 |
31
+ 8 | export const FIXTURE_ENTRYPOINT = {
32
+```
33
+
34
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-ref-value.js
new
+11
@@ -0,0 +1,11 @@
1
+import {useEffect, useRef} from 'react';
2
+
3
+function Component(props) {
4
+ const ref = useRef();
5
+ useEffect(() => {}, [ref.current]);
6
+}
7
+
8
+export const FIXTURE_ENTRYPOINT = {
9
+ fn: Component,
10
+ params: [],
11
+};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hook-ref-callback.expect.md
new
+54
@@ -0,0 +1,54 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import {useEffect, useRef} from 'react';
6
+
7
+function Component(props) {
8
+ const ref = useRef();
9
+ useFoo(() => {
10
+ ref.current = 42;
11
+ });
12
+}
13
+
14
+function useFoo(x) {}
15
+
16
+export const FIXTURE_ENTRYPOINT = {
17
+ fn: Component,
18
+ params: [],
19
+};
20
+
21
+```
22
+
23
+## Code
24
+
25
+```javascript
26
+import { c as _c } from "react/compiler-runtime";
27
+import { useEffect, useRef } from "react";
28
+
29
+function Component(props) {
30
+ const $ = _c(1);
31
+ const ref = useRef();
32
+ let t0;
33
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
34
+ t0 = () => {
35
+ ref.current = 42;
36
+ };
37
+ $[0] = t0;
38
+ } else {
39
+ t0 = $[0];
40
+ }
41
+ useFoo(t0);
42
+}
43
+
44
+function useFoo(x) {}
45
+
46
+export const FIXTURE_ENTRYPOINT = {
47
+ fn: Component,
48
+ params: [],
49
+};
50
+
51
+```
52
+
53
+### Eval output
54
+(kind: ok)
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hook-ref-callback.js
new
+15
@@ -0,0 +1,15 @@
1
+import {useEffect, useRef} from 'react';
2
+
3
+function Component(props) {
4
+ const ref = useRef();
5
+ useFoo(() => {
6
+ ref.current = 42;
7
+ });
8
+}
9
+
10
+function useFoo(x) {}
11
+
12
+export const FIXTURE_ENTRYPOINT = {
13
+ fn: Component,
14
+ params: [],
15
+};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useImperativeHandle-ref-mutate.expect.md
new
+66
@@ -0,0 +1,66 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @flow
6
+
7
+import {useImperativeHandle, useRef} from 'react';
8
+
9
+component Component(prop: number) {
10
+ const ref1 = useRef(null);
11
+ const ref2 = useRef(1);
12
+ useImperativeHandle(ref1, () => {
13
+ const precomputed = prop + ref2.current;
14
+ return {
15
+ foo: () => prop + ref2.current + precomputed,
16
+ };
17
+ }, [prop]);
18
+}
19
+
20
+export const FIXTURE_ENTRYPOINT = {
21
+ fn: Component,
22
+ params: [{prop: 1}],
23
+};
24
+
25
+```
26
+
27
+## Code
28
+
29
+```javascript
30
+import { c as _c } from "react/compiler-runtime";
31
+
32
+import { useImperativeHandle, useRef } from "react";
33
+
34
+function Component(t0) {
35
+ const $ = _c(3);
36
+ const { prop } = t0;
37
+ const ref1 = useRef(null);
38
+ const ref2 = useRef(1);
39
+ let t1;
40
+ let t2;
41
+ if ($[0] !== prop) {
42
+ t1 = () => {
43
+ const precomputed = prop + ref2.current;
44
+ return { foo: () => prop + ref2.current + precomputed };
45
+ };
46
+
47
+ t2 = [prop];
48
+ $[0] = prop;
49
+ $[1] = t1;
50
+ $[2] = t2;
51
+ } else {
52
+ t1 = $[1];
53
+ t2 = $[2];
54
+ }
55
+ useImperativeHandle(ref1, t1, t2);
56
+}
57
+
58
+export const FIXTURE_ENTRYPOINT = {
59
+ fn: Component,
60
+ params: [{ prop: 1 }],
61
+};
62
+
63
+```
64
+
65
+### Eval output
66
+(kind: ok)
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useImperativeHandle-ref-mutate.js
new
+19
@@ -0,0 +1,19 @@
1
+// @flow
2
+
3
+import {useImperativeHandle, useRef} from 'react';
4
+
5
+component Component(prop: number) {
6
+ const ref1 = useRef(null);
7
+ const ref2 = useRef(1);
8
+ useImperativeHandle(ref1, () => {
9
+ const precomputed = prop + ref2.current;
10
+ return {
11
+ foo: () => prop + ref2.current + precomputed,
12
+ };
13
+ }, [prop]);
14
+}
15
+
16
+export const FIXTURE_ENTRYPOINT = {
17
+ fn: Component,
18
+ params: [{prop: 1}],
19
+};