@samitouri / QOS-React / commits / ad32811b64

[RFC] Improve validation against ref access in render

The goal of this PR is to improve ValidateNoRefAccessInRender to find function expressions which a) access refs and b) may be called during render. Currently we always allow ref access in any function expression, but that's obviously optimistic. For the approach, the observation is that we already have a system that tells us whether a function may get called — mutable range inference. So long as we consider a function "mutable", we'll infer a range for it, but the problem is that we don't currently view functions which depend on refs to be mutable. So here I'm doing ~~sort of~~ a hack to force function deps on refs to be treated as Effect.Capture. This is enough for the function to be considered mutable, for a mutable range to be assigned, and for us to detect that during ref validation. I don't love the hack, i'm open to other ideas!

Joe Savona committed Jun 5, 2023 at 14:08 UTC ad32811b649985322528483596a2cbb88451b080
11 files changed +64 -11
compiler/forget/packages/babel-plugin-react-forget/src/Entrypoint/Pipeline.ts
+4 -3
@@ -95,9 +95,6 @@ export function* run(
95 inferTypes(hir);
96 yield log({ kind: "hir", name: "InferTypes", value: hir });
97
98 - if (env.validateRefAccessDuringRender) {
99 - validateNoRefAccessInRender(hir);
100 - }
98 if (env.validateHooksUsage) {
99 validateHooksUsage(hir);
100 const conditionalHooksResult = validateUnconditionalHooks(hir).unwrap();
@@ -128,6 +125,10 @@ export function* run(
125 inferMutableRanges(hir);
126 yield log({ kind: "hir", name: "InferMutableRanges", value: hir });
127
128 + if (env.validateRefAccessDuringRender) {
129 + validateNoRefAccessInRender(hir);
130 + }
131 +
132 leaveSSA(hir);
133 yield log({ kind: "hir", name: "LeaveSSA", value: hir });
134
compiler/forget/packages/babel-plugin-react-forget/src/HIR/ValidateNoRefAccesInRender.ts
+7
@@ -53,6 +53,13 @@ export function validateNoRefAccessInRender(fn: HIRFunction): void {
53 // For now we assume *all* function expressions are safe, eventually we can
54 // be more precise and disallow ref access in functions that may be called
55 // during render
56 + const mutableRange = instr.lvalue.identifier.mutableRange;
57 + if (mutableRange.end > mutableRange.start + 1) {
58 + for (const operand of eachInstructionValueOperand(instr.value)) {
59 + validateNonRefValue(error, operand);
60 + validateNonRefObject(error, operand);
61 + }
62 + }
63 break;
64 }
65 case "CallExpression":
compiler/forget/packages/babel-plugin-react-forget/src/Inference/AnalyseFunctions.ts
+9
@@ -11,6 +11,8 @@ import {
11 FunctionExpression,
12 HIRFunction,
13 Identifier,
14 + isRefValueType,
15 + isUseRefType,
16 mergeConsecutiveBlocks,
17 Place,
18 ReactiveScopeDependency,
@@ -123,6 +125,13 @@ function infer(
125
126 if (name !== null && mutations.has(name)) {
127 dep.effect = Effect.Capture;
128 + } else if (isUseRefType(dep.identifier) || isRefValueType(dep.identifier)) {
129 + // TODO: this is a hack to ensure we treat functions which reference refs
130 + // as having a capture and therefore being considered mutable. this ensures
131 + // the function gets a mutable range which accounts for anywhere that it
132 + // could be called, and allows us to help ensure it isn't called during
133 + // render
134 + dep.effect = Effect.Capture;
135 }
136 }
137
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-access-ref-during-render.expect.md
+1 -1
@@ -15,7 +15,7 @@ function Component(props) {
15 ## Error
16
17 ```
18 -[ReactForget] InvalidInput: Ref values (the `current` property) may not be accessed during render. Cannot access ref value at <unknown> $23:TObject<BuiltInRefValue> (5:5)
18 +[ReactForget] InvalidInput: Ref values (the `current` property) may not be accessed during render. Cannot access ref value at freeze $23:TObject<BuiltInRefValue> (5:5)
19 ```
20
21
\ No newline at end of file
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-pass-ref-to-function.expect.md
+1 -1
@@ -14,7 +14,7 @@ function Component(props) {
14 ## Error
15
16 ```
17 -[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at <unknown> $22:TObject<BuiltInUseRefId> (3:3)
17 +[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at mutate $22[6:8]:TObject<BuiltInUseRefId> (3:3)
18 ```
19
20
\ No newline at end of file
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-set-and-read-ref-during-render.expect.md
+2 -2
@@ -14,9 +14,9 @@ function Component(props) {
14 ## Error
15
16 ```
17 -[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at <unknown> $22:TObject<BuiltInUseRefId> (3:3)
17 +[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at store $22[7:9]:TObject<BuiltInUseRefId> (3:3)
18
19 -[ReactForget] InvalidInput: Ref values (the `current` property) may not be accessed during render. Cannot access ref value at <unknown> $25:TObject<BuiltInRefValue> (4:4)
19 +[ReactForget] InvalidInput: Ref values (the `current` property) may not be accessed during render. Cannot access ref value at freeze $25:TObject<BuiltInRefValue> (4:4)
20 ```
21
22
\ No newline at end of file
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.invalid-use-ref-added-to-dep-without-type-info.expect.md
+1 -1
@@ -21,7 +21,7 @@ function Foo({ a }) {
21 ## Error
22
23 ```
24 -[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at <unknown> $30:TObject<BuiltInUseRefId> (4:4)
24 +[ReactForget] InvalidInput: Ref values may not be passed to functions because they could read the ref value (`current` property) during render. Cannot access ref object at capture $30:TObject<BuiltInUseRefId> (4:4)
25 ```
26
27
\ No newline at end of file
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/ref-in-effect.expect.md
+4 -2
@@ -5,7 +5,8 @@
5 function Component(props) {
6 const ref = useRef(null);
7 const onChange = (e) => {
8 - ref.current = e.target.value;
8 + const newValue = e.target.value ?? ref.current;
9 + ref.current = newValue;
10 };
11 useEffect(() => {
12 console.log(ref.current);
@@ -25,7 +26,8 @@ function Component(props) {
26 let t0;
27 if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
28 t0 = (e) => {
28 - ref.current = e.target.value;
29 + const newValue = e.target.value ?? ref.current;
30 + ref.current = newValue;
31 };
32 $[0] = t0;
33 } else {
compiler/forget/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/ref-in-effect.js
+2 -1
@@ -1,7 +1,8 @@
1 function Component(props) {
2 const ref = useRef(null);
3 const onChange = (e) => {
4 - ref.current = e.target.value;
4 + const newValue = e.target.value ?? ref.current;
5 + ref.current = newValue;
6 };
7 useEffect(() => {
8 console.log(ref.current);
compiler/forget/src/__tests__/fixtures/compiler/error.invalid-ref-in-callback-invoked-during-render.expect.md new
+24
@@ -0,0 +1,24 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @debug
6 +function Component(props) {
7 + const ref = useRef(null);
8 + const renderItem = (item) => {
9 + const current = ref.current;
10 + return <Foo item={item} current={current} />;
11 + };
12 + return <Items>{props.items.map((item) => renderItem(item))}</Items>;
13 +}
14 +
15 +```
16 +
17 +
18 +## Error
19 +
20 +```
21 +[ReactForget] InvalidInput: Ref values (the `current` property) may not be accessed during render. Cannot access ref value at capture $43[6:16]:TObject<BuiltInRefValue> (5:5)
22 +```
23 +
24 +
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/error.invalid-ref-in-callback-invoked-during-render.js new
+9
@@ -0,0 +1,9 @@
1 +// @debug
2 +function Component(props) {
3 + const ref = useRef(null);
4 + const renderItem = (item) => {
5 + const current = ref.current;
6 + return <Foo item={item} current={current} />;
7 + };
8 + return <Items>{props.items.map((item) => renderItem(item))}</Items>;
9 +}