@samitouri / QOS-React / commits / 5f1b8fd57f

Improve validateNoRefAccessInRender

Rewrites the validation to not rely on the mutable range of functions to determine whether they are called or not, since the range can be extended for other reasons (they happen to reference a mutable value that is mutated later, even though the function isn't called during render). Instead we use the same approach as validateNoSetStateInRender, explicitly tracking references to function expressions that access refs, and checking if those function expressions appear to be called. This can have false negatives, as with the setState validation, but catches lots of obviously incorrect code without false positives.

Joe Savona committed Feb 13, 2024 at 16:45 UTC 5f1b8fd57f672601fc11a44f537aaeec2b793c03
8 files changed +173 -92
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Pipeline.ts
+1 -1
@@ -180,7 +180,7 @@ function* runWithEnvironment(
180 }
181
182 if (env.config.validateRefAccessDuringRender) {
183 - validateNoRefAccessInRender(hir);
183 + validateNoRefAccessInRender(hir).unwrap();
184 }
185
186 if (env.config.validateNoSetStateInRender) {
compiler/packages/babel-plugin-react-forget/src/Validation/ValidateNoRefAccesInRender.ts
+134 -86
@@ -5,135 +5,183 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 +import { CompilerError, ErrorSeverity } from "../CompilerError";
9 import {
9 - CompilerError,
10 - CompilerErrorDetail,
11 - ErrorSeverity,
12 -} from "../CompilerError";
13 -import { HIRFunction, Place, isRefValueType, isUseRefType } from "../HIR/HIR";
10 + HIRFunction,
11 + IdentifierId,
12 + Place,
13 + isRefValueType,
14 + isUseRefType,
15 +} from "../HIR";
16 import { printPlace } from "../HIR/PrintHIR";
17 import {
18 eachInstructionValueOperand,
19 eachTerminalOperand,
20 } from "../HIR/visitors";
21 +import { Err, Ok, Result } from "../Utils/Result";
22
20 -/*
21 - * Validates that ref values (the `current` property) are not accessed during render.
22 - * This validation is conservative and only rejects accesses of known ref values:
23 - *
24 - * ```javascript
25 - * // ERROR
26 - * const ref = useRef();
27 - * ref.current;
28 - *
29 - * const ref = useRef();
30 - * foo(ref); // may access .current
31 - *
32 - * // ALLOWED
33 - * const ref = useHookThatReturnsRef();
34 - * ref.current;
35 - * ```
36 - *
37 - * In the future we may reject more cases, based on either object names (`fooRef.current` is likely a ref)
38 - * or based on property name alone (`foo.current` might be a ref).
23 +/**
24 + * Validates that a function does not access a ref value during render. This includes a partial check
25 + * for ref values which are accessed indirectly via function expressions.
26 */
40 -export function validateNoRefAccessInRender(fn: HIRFunction): void {
41 - const error = new CompilerError();
27 +export function validateNoRefAccessInRender(
28 + fn: HIRFunction
29 +): Result<void, CompilerError> {
30 + const refAccessingFunctions: Set<IdentifierId> = new Set();
31 + return validateNoRefAccessInRenderImpl(fn, refAccessingFunctions);
32 +}
33
34 +function validateNoRefAccessInRenderImpl(
35 + fn: HIRFunction,
36 + refAccessingFunctions: Set<IdentifierId>
37 +): Result<void, CompilerError> {
38 + const errors = new CompilerError();
39 for (const [, block] of fn.body.blocks) {
40 for (const instr of block.instructions) {
41 switch (instr.value.kind) {
46 - case "PropertyLoad":
47 - case "LoadLocal":
48 - case "StoreLocal":
49 - case "Destructure": {
50 - /*
51 - * These instructions are necessary for storing the results of a useRef into
52 - * a variable and referencing them in functions. We can propagate type info
53 - * for these instructions so they ensure we have a complete analysis.
54 - */
42 + case "JsxExpression":
43 + case "JsxFragment": {
44 + for (const operand of eachInstructionValueOperand(instr.value)) {
45 + if (isRefValueType(operand.identifier)) {
46 + errors.push({
47 + severity: ErrorSeverity.InvalidReact,
48 + reason:
49 + "Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)",
50 + loc: operand.loc,
51 + description: `Cannot access ref value at ${printPlace(
52 + operand
53 + )}`,
54 + suggestions: null,
55 + });
56 + }
57 + }
58 break;
59 }
57 - case "JsxExpression": {
58 - // It's okay to pass refs to JSX, but not ref *values*
59 - for (const operand of eachInstructionValueOperand(instr.value)) {
60 - validateNonRefValue(error, operand);
60 + case "PropertyLoad": {
61 + break;
62 + }
63 + case "LoadLocal": {
64 + if (refAccessingFunctions.has(instr.value.place.identifier.id)) {
65 + refAccessingFunctions.add(instr.lvalue.identifier.id);
66 + }
67 + break;
68 + }
69 + case "StoreLocal": {
70 + if (refAccessingFunctions.has(instr.value.value.identifier.id)) {
71 + refAccessingFunctions.add(instr.value.lvalue.place.identifier.id);
72 + refAccessingFunctions.add(instr.lvalue.identifier.id);
73 }
74 break;
75 }
76 case "ObjectMethod":
77 case "FunctionExpression": {
66 - if (fn.env.config.validateRefAccessDuringRenderFunctionExpressions) {
78 + if (
79 /*
68 - * functions are allowed to capture refs, so long as the function is not called
69 - * during render. see AnalyzeFunctions for how we ensure that functions which
70 - * capture refs get assigned a mutable range so we know here whether the function
71 - * is called or not
80 + * check if the function expression accesses a ref *or* some other
81 + * function which accesses a ref
82 */
73 - const mutableRange = instr.lvalue.identifier.mutableRange;
74 - if (mutableRange.end > mutableRange.start + 1) {
75 - for (const operand of eachInstructionValueOperand(instr.value)) {
76 - validateNonRefValue(error, operand);
77 - validateNonRefObject(error, operand);
78 - }
79 - }
83 + [...eachInstructionValueOperand(instr.value)].some(
84 + (operand) =>
85 + isRefValueType(operand.identifier) ||
86 + refAccessingFunctions.has(operand.identifier.id)
87 + ) ||
88 + // check for cases where .current is accessed through an aliased ref
89 + ([...eachInstructionValueOperand(instr.value)].some((operand) =>
90 + isUseRefType(operand.identifier)
91 + ) &&
92 + validateNoRefAccessInRenderImpl(
93 + instr.value.loweredFunc.func,
94 + refAccessingFunctions
95 + ).isErr())
96 + ) {
97 + // This function expression unconditionally accesses a ref
98 + refAccessingFunctions.add(instr.lvalue.identifier.id);
99 }
100 break;
101 }
83 - case "CallExpression":
84 - case "NewExpression": {
102 + case "CallExpression": {
103 + const callee = instr.value.callee;
104 + // Report a more precise error when calling a local function that accesses a ref
105 + if (refAccessingFunctions.has(callee.identifier.id)) {
106 + errors.push({
107 + severity: ErrorSeverity.InvalidReact,
108 + reason:
109 + "This function accesses a ref, which not be accessed during render. (https://react.dev/reference/react/useRef)",
110 + loc: callee.loc,
111 + description: `Function ${printPlace(callee)} accesses a ref`,
112 + suggestions: null,
113 + });
114 + }
115 for (const operand of eachInstructionValueOperand(instr.value)) {
86 - validateNonRefValue(error, operand);
87 - validateNonRefObject(error, operand);
116 + validateNoRefAccess(errors, refAccessingFunctions, operand);
117 + }
118 + break;
119 + }
120 + case "ObjectExpression":
121 + case "ArrayExpression":
122 + case "MethodCall": {
123 + for (const operand of eachInstructionValueOperand(instr.value)) {
124 + validateNoRefAccess(errors, refAccessingFunctions, operand);
125 }
126 break;
127 }
128 default: {
129 for (const operand of eachInstructionValueOperand(instr.value)) {
93 - validateNonRefValue(error, operand);
94 - validateNonRefObject(error, operand);
130 + validateNoRefValueAccess(errors, refAccessingFunctions, operand);
131 }
132 + break;
133 }
134 }
135 }
136 for (const operand of eachTerminalOperand(block.terminal)) {
100 - validateNonRefValue(error, operand);
137 + validateNoRefValueAccess(errors, refAccessingFunctions, operand);
138 }
139 }
140
104 - if (error.hasErrors()) {
105 - throw error;
141 + if (errors.hasErrors()) {
142 + return Err(errors);
143 + } else {
144 + return Ok(undefined);
145 }
146 }
147
109 -// Check that the operand's type is not that of useRef().current (the ref's current value)
110 -function validateNonRefValue(error: CompilerError, operand: Place): void {
111 - if (isRefValueType(operand.identifier)) {
112 - error.pushErrorDetail(
113 - new CompilerErrorDetail({
114 - description: `Cannot access ref value at ${printPlace(operand)}`,
115 - loc: typeof operand.loc !== "symbol" ? operand.loc : null,
116 - reason:
117 - "Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)",
118 - severity: ErrorSeverity.InvalidReact,
119 - suggestions: null,
120 - })
121 - );
148 +function validateNoRefValueAccess(
149 + errors: CompilerError,
150 + unconditionalSetStateFunctions: Set<IdentifierId>,
151 + operand: Place
152 +): void {
153 + if (
154 + isRefValueType(operand.identifier) ||
155 + unconditionalSetStateFunctions.has(operand.identifier.id)
156 + ) {
157 + errors.push({
158 + severity: ErrorSeverity.InvalidReact,
159 + reason:
160 + "Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)",
161 + loc: operand.loc,
162 + description: `Cannot access ref value at ${printPlace(operand)}`,
163 + suggestions: null,
164 + });
165 }
166 }
167
125 -// Check that the operand's type is not that of useRef() return value (the ref container)
126 -function validateNonRefObject(error: CompilerError, operand: Place): void {
127 - if (isUseRefType(operand.identifier)) {
128 - error.pushErrorDetail(
129 - new CompilerErrorDetail({
130 - description: `Cannot access ref object at ${printPlace(operand)}`,
131 - loc: typeof operand.loc !== "symbol" ? operand.loc : null,
132 - reason:
133 - "Ref values may not be passed to functions because they could read the ref value (`current` property) during render. (https://react.dev/reference/react/useRef)",
134 - severity: ErrorSeverity.InvalidReact,
135 - suggestions: null,
136 - })
137 - );
168 +function validateNoRefAccess(
169 + errors: CompilerError,
170 + unconditionalSetStateFunctions: Set<IdentifierId>,
171 + operand: Place
172 +): void {
173 + if (
174 + isRefValueType(operand.identifier) ||
175 + isUseRefType(operand.identifier) ||
176 + unconditionalSetStateFunctions.has(operand.identifier.id)
177 + ) {
178 + errors.push({
179 + severity: ErrorSeverity.InvalidReact,
180 + reason:
181 + "Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)",
182 + loc: operand.loc,
183 + description: `Cannot access ref value at ${printPlace(operand)}`,
184 + suggestions: null,
185 + });
186 }
187 }
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-aliased-ref-in-callback-invoked-during-render-.expect.md new
+25
@@ -0,0 +1,25 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateRefAccessDuringRender @validateRefAccessDuringRenderFunctionExpressions
6 +function Component(props) {
7 + const ref = useRef(null);
8 + const renderItem = (item) => {
9 + const aliasedRef = ref;
10 + const current = aliasedRef.current;
11 + return <Foo item={item} current={current} />;
12 + };
13 + return <Items>{props.items.map((item) => renderItem(item))}</Items>;
14 +}
15 +
16 +```
17 +
18 +
19 +## Error
20 +
21 +```
22 +[ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at mutate? $64[13:15] (9:9)
23 +```
24 +
25 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-aliased-ref-in-callback-invoked-during-render-.js new
+10
@@ -0,0 +1,10 @@
1 +// @validateRefAccessDuringRender @validateRefAccessDuringRenderFunctionExpressions
2 +function Component(props) {
3 + const ref = useRef(null);
4 + const renderItem = (item) => {
5 + const aliasedRef = ref;
6 + const current = aliasedRef.current;
7 + return <Foo item={item} current={current} />;
8 + };
9 + return <Items>{props.items.map((item) => renderItem(item))}</Items>;
10 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-pass-ref-to-function.expect.md
+1 -1
@@ -15,7 +15,7 @@ function Component(props) {
15 ## Error
16
17 ```
18 -[ReactForget] InvalidReact: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. (https://react.dev/reference/react/useRef). Cannot access ref object at mutate? $21[6:8]:TObject<BuiltInUseRefId> (4:4)
18 +[ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at mutate? $21[6:8]:TObject<BuiltInUseRefId> (4:4)
19 ```
20
21
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-ref-in-callback-invoked-during-render.expect.md
+1 -1
@@ -18,7 +18,7 @@ function Component(props) {
18 ## Error
19
20 ```
21 -[ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at capture $42[6:16]:TObject<BuiltInRefValue> (5:5)
21 +[ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at mutate? $60[14:16] (8:8)
22 ```
23
24
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-set-and-read-ref-during-render.expect.md
-2
@@ -15,8 +15,6 @@ function Component(props) {
15 ## Error
16
17 ```
18 -[ReactForget] InvalidReact: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. (https://react.dev/reference/react/useRef). Cannot access ref object at store $21[7:9]:TObject<BuiltInUseRefId> (4:4)
19 -
18 [ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at freeze $24:TObject<BuiltInRefValue> (5:5)
19 ```
20
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-use-ref-added-to-dep-without-type-info.expect.md
+1 -1
@@ -22,7 +22,7 @@ function Foo({ a }) {
22 ## Error
23
24 ```
25 -[ReactForget] InvalidReact: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. (https://react.dev/reference/react/useRef). Cannot access ref object at capture $29:TObject<BuiltInUseRefId> (5:5)
25 +[ReactForget] InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef). Cannot access ref value at capture $29:TObject<BuiltInUseRefId> (5:5)
26 ```
27
28
\ No newline at end of file