@samitouri / QOS-React / commits / 83d538e0d0

[compiler] Treat ref-like named objects as refs (#29916)

If a component uses the `useRef` hook directly then we type it's return value as a ref. But if it's wrapped in a custom hook then we lose out on this type information as the compiler doesn't look at the hook definition. This has resulted in some false positives in our analysis like the ones reported in #29160 and #29196. This PR will treat objects named as `ref` or if their names end with the substring `Ref`, and contain a property named `current`, as React refs. ``` const ref = useMyRef(); const myRef = useMyRef2(); useEffect(() => { ref.current = ...; myRef.current = ...; }) ``` In the above example, `ref` and `myRef` will be treated as React refs.

Sathya Gunasekaran committed Jun 18, 2024 at 18:02 UTC 83d538e0d04aac2a32d335e6b3963273729a9fe6
13 files changed +525 -10
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+17
@@ -407,6 +407,23 @@ const EnvironmentConfigSchema = z.object({
407 * and identifiers have been changed.
408 */
409 hookPattern: z.string().nullable().default(null),
410 +
411 + /**
412 + * If enabled, this will treat objects named as `ref` or if their names end with the substring `Ref`,
413 + * and contain a property named `current`, as React refs.
414 + *
415 + * ```
416 + * const ref = useMyRef();
417 + * const myRef = useMyRef2();
418 + * useEffect(() => {
419 + * ref.current = ...;
420 + * myRef.current = ...;
421 + * })
422 + * ```
423 + *
424 + * Here the variables `ref` and `myRef` will be typed as Refs.
425 + */
426 + enableTreatRefLikeIdentifiersAsRefs: z.boolean().nullable().default(false),
427 });
428
429 export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
compiler/packages/babel-plugin-react-compiler/src/HIR/Types.ts
+8 -4
@@ -57,7 +57,8 @@ export type PhiType = {
57 };
58 export type PropType = {
59 kind: "Property";
60 - object: Type;
60 + objectType: Type;
61 + objectName: string;
62 propertyName: string;
63 };
64
@@ -124,7 +125,8 @@ export function duplicateType(type: Type): Type {
125 case "Property": {
126 return {
127 kind: "Property",
127 - object: duplicateType(type.object),
128 + objectType: duplicateType(type.objectType),
129 + objectName: type.objectName,
130 propertyName: type.propertyName,
131 };
132 }
@@ -165,11 +167,13 @@ function objectMethodTypeEquals(tA: Type, tB: Type): boolean {
167
168 function propTypeEquals(tA: Type, tB: Type): boolean {
169 if (tA.kind === "Property" && tB.kind === "Property") {
168 - if (!typeEquals(tA.object, tB.object)) {
170 + if (!typeEquals(tA.objectType, tB.objectType)) {
171 return false;
172 }
173
172 - return tA.propertyName === tB.propertyName;
174 + return (
175 + tA.propertyName === tB.propertyName && tA.objectName === tB.objectName
176 + );
177 }
178
179 return false;
compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts
+50 -6
@@ -11,8 +11,11 @@ import { Environment } from "../HIR";
11 import { lowerType } from "../HIR/BuildHIR";
12 import {
13 HIRFunction,
14 + Identifier,
15 + IdentifierId,
16 Instruction,
17 makeType,
18 + PropType,
19 Type,
20 typeEquals,
21 TypeId,
@@ -24,6 +27,7 @@ import {
27 BuiltInJsxId,
28 BuiltInObjectId,
29 BuiltInPropsId,
30 + BuiltInRefValueId,
31 BuiltInUseRefId,
32 } from "../HIR/ObjectShape";
33 import { eachInstructionLValue, eachInstructionOperand } from "../HIR/visitors";
@@ -117,6 +121,7 @@ function* generate(
121 }
122 }
123
124 + const names = new Map();
125 for (const [_, block] of func.body.blocks) {
126 for (const phi of block.phis) {
127 yield equation(phi.type, {
@@ -126,13 +131,28 @@ function* generate(
131 }
132
133 for (const instr of block.instructions) {
129 - yield* generateInstructionTypes(func.env, instr);
134 + yield* generateInstructionTypes(func.env, names, instr);
135 }
136 }
137 }
138
139 +function setName(
140 + names: Map<IdentifierId, string>,
141 + id: IdentifierId,
142 + name: Identifier
143 +): void {
144 + if (name.name?.kind === "named") {
145 + names.set(id, name.name.value);
146 + }
147 +}
148 +
149 +function getName(names: Map<IdentifierId, string>, id: IdentifierId): string {
150 + return names.get(id) ?? "";
151 +}
152 +
153 function* generateInstructionTypes(
154 env: Environment,
155 + names: Map<IdentifierId, string>,
156 instr: Instruction
157 ): Generator<TypeEquation, void, undefined> {
158 const { lvalue, value } = instr;
@@ -152,6 +172,7 @@ function* generateInstructionTypes(
172 }
173
174 case "LoadLocal": {
175 + setName(names, lvalue.identifier.id, value.place.identifier);
176 yield equation(left, value.place.identifier.type);
177 break;
178 }
@@ -250,7 +271,8 @@ function* generateInstructionTypes(
271 case "PropertyLoad": {
272 yield equation(left, {
273 kind: "Property",
253 - object: value.object.identifier.type,
274 + objectType: value.object.identifier.type,
275 + objectName: getName(names, value.object.identifier.id),
276 propertyName: value.property,
277 });
278 break;
@@ -278,7 +300,8 @@ function* generateInstructionTypes(
300 const propertyName = String(i);
301 yield equation(item.identifier.type, {
302 kind: "Property",
281 - object: value.value.identifier.type,
303 + objectType: value.value.identifier.type,
304 + objectName: getName(names, value.value.identifier.id),
305 propertyName,
306 });
307 } else {
@@ -294,7 +317,8 @@ function* generateInstructionTypes(
317 ) {
318 yield equation(property.place.identifier.type, {
319 kind: "Property",
297 - object: value.value.identifier.type,
320 + objectType: value.value.identifier.type,
321 + objectName: getName(names, value.value.identifier.id),
322 propertyName: property.key.name,
323 });
324 }
@@ -342,11 +366,11 @@ function* generateInstructionTypes(
366 yield equation(left, { kind: "Object", shapeId: BuiltInJsxId });
367 break;
368 }
369 + case "PropertyStore":
370 case "DeclareLocal":
371 case "NewExpression":
372 case "RegExpLiteral":
373 case "MetaProperty":
349 - case "PropertyStore":
374 case "ComputedStore":
375 case "ComputedLoad":
376 case "TaggedTemplateExpression":
@@ -375,7 +399,21 @@ class Unifier {
399
400 unify(tA: Type, tB: Type): void {
401 if (tB.kind === "Property") {
378 - const objectType = this.get(tB.object);
402 + if (
403 + this.env.config.enableTreatRefLikeIdentifiersAsRefs &&
404 + isRefLikeName(tB)
405 + ) {
406 + this.unify(tB.objectType, {
407 + kind: "Object",
408 + shapeId: BuiltInUseRefId,
409 + });
410 + this.unify(tA, {
411 + kind: "Object",
412 + shapeId: BuiltInRefValueId,
413 + });
414 + return;
415 + }
416 + const objectType = this.get(tB.objectType);
417 const propertyType = this.env.getPropertyType(
418 objectType,
419 tB.propertyName
@@ -483,3 +521,9 @@ class Unifier {
521 return type;
522 }
523 }
524 +
525 +const RefLikeNameRE = /^(?:[a-zA-Z$_][a-zA-Z$_0-9]*)Ref$|^ref$/;
526 +
527 +function isRefLikeName(t: PropType): boolean {
528 + return RefLikeNameRE.test(t.objectName) && t.propertyName === "current";
529 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-like-name-not-Ref.expect.md new
+47
@@ -0,0 +1,47 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validatePreserveExistingMemoizationGuarantees
6 +import { useCallback, useRef } from "react";
7 +
8 +function useCustomRef() {
9 + return useRef({ click: () => {} });
10 +}
11 +
12 +function Foo() {
13 + const Ref = useCustomRef();
14 +
15 + const onClick = useCallback(() => {
16 + Ref.current?.click();
17 + }, []);
18 +
19 + return <button onClick={onClick} />;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Foo,
24 + params: [],
25 + isComponent: true,
26 +};
27 +
28 +```
29 +
30 +
31 +## Error
32 +
33 +```
34 + 9 | const Ref = useCustomRef();
35 + 10 |
36 +> 11 | const onClick = useCallback(() => {
37 + | ^^^^^^^
38 +> 12 | Ref.current?.click();
39 + | ^^^^^^^^^^^^^^^^^^^^^^^^^
40 +> 13 | }, []);
41 + | ^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected (11:13)
42 + 14 |
43 + 15 | return <button onClick={onClick} />;
44 + 16 | }
45 +```
46 +
47 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-like-name-not-Ref.js new
+22
@@ -0,0 +1,22 @@
1 +// @validatePreserveExistingMemoizationGuarantees
2 +import { useCallback, useRef } from "react";
3 +
4 +function useCustomRef() {
5 + return useRef({ click: () => {} });
6 +}
7 +
8 +function Foo() {
9 + const Ref = useCustomRef();
10 +
11 + const onClick = useCallback(() => {
12 + Ref.current?.click();
13 + }, []);
14 +
15 + return <button onClick={onClick} />;
16 +}
17 +
18 +export const FIXTURE_ENTRYPOINT = {
19 + fn: Foo,
20 + params: [],
21 + isComponent: true,
22 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-like-name-not-a-ref.expect.md new
+47
@@ -0,0 +1,47 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validatePreserveExistingMemoizationGuarantees
6 +import { useCallback, useRef } from "react";
7 +
8 +function useCustomRef() {
9 + return useRef({ click: () => {} });
10 +}
11 +
12 +function Foo() {
13 + const notaref = useCustomRef();
14 +
15 + const onClick = useCallback(() => {
16 + notaref.current?.click();
17 + }, []);
18 +
19 + return <button onClick={onClick} />;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Foo,
24 + params: [],
25 + isComponent: true,
26 +};
27 +
28 +```
29 +
30 +
31 +## Error
32 +
33 +```
34 + 9 | const notaref = useCustomRef();
35 + 10 |
36 +> 11 | const onClick = useCallback(() => {
37 + | ^^^^^^^
38 +> 12 | notaref.current?.click();
39 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
40 +> 13 | }, []);
41 + | ^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected (11:13)
42 + 14 |
43 + 15 | return <button onClick={onClick} />;
44 + 16 | }
45 +```
46 +
47 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-like-name-not-a-ref.js new
+22
@@ -0,0 +1,22 @@
1 +// @validatePreserveExistingMemoizationGuarantees
2 +import { useCallback, useRef } from "react";
3 +
4 +function useCustomRef() {
5 + return useRef({ click: () => {} });
6 +}
7 +
8 +function Foo() {
9 + const notaref = useCustomRef();
10 +
11 + const onClick = useCallback(() => {
12 + notaref.current?.click();
13 + }, []);
14 +
15 + return <button onClick={onClick} />;
16 +}
17 +
18 +export const FIXTURE_ENTRYPOINT = {
19 + fn: Foo,
20 + params: [],
21 + isComponent: true,
22 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-effect.expect.md new
+84
@@ -0,0 +1,84 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
6 +import { useRef, useEffect } from "react";
7 +
8 +function useCustomRef() {
9 + return useRef({ click: () => {} });
10 +}
11 +
12 +function Foo() {
13 + const ref = useCustomRef();
14 +
15 + useEffect(() => {
16 + ref.current?.click();
17 + }, []);
18 +
19 + return <div>foo</div>;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Foo,
24 + params: [],
25 + isComponent: true,
26 +};
27 +
28 +```
29 +
30 +## Code
31 +
32 +```javascript
33 +import { c as _c } from "react/compiler-runtime"; // @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
34 +import { useRef, useEffect } from "react";
35 +
36 +function useCustomRef() {
37 + const $ = _c(1);
38 + let t0;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = { click: () => {} };
41 + $[0] = t0;
42 + } else {
43 + t0 = $[0];
44 + }
45 + return useRef(t0);
46 +}
47 +
48 +function Foo() {
49 + const $ = _c(3);
50 + const ref = useCustomRef();
51 + let t0;
52 + let t1;
53 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
54 + t0 = () => {
55 + ref.current?.click();
56 + };
57 + t1 = [];
58 + $[0] = t0;
59 + $[1] = t1;
60 + } else {
61 + t0 = $[0];
62 + t1 = $[1];
63 + }
64 + useEffect(t0, t1);
65 + let t2;
66 + if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
67 + t2 = <div>foo</div>;
68 + $[2] = t2;
69 + } else {
70 + t2 = $[2];
71 + }
72 + return t2;
73 +}
74 +
75 +export const FIXTURE_ENTRYPOINT = {
76 + fn: Foo,
77 + params: [],
78 + isComponent: true,
79 +};
80 +
81 +```
82 +
83 +### Eval output
84 +(kind: ok) <div>foo</div>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-effect.js new
+22
@@ -0,0 +1,22 @@
1 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
2 +import { useRef, useEffect } from "react";
3 +
4 +function useCustomRef() {
5 + return useRef({ click: () => {} });
6 +}
7 +
8 +function Foo() {
9 + const ref = useCustomRef();
10 +
11 + useEffect(() => {
12 + ref.current?.click();
13 + }, []);
14 +
15 + return <div>foo</div>;
16 +}
17 +
18 +export const FIXTURE_ENTRYPOINT = {
19 + fn: Foo,
20 + params: [],
21 + isComponent: true,
22 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-useCallback-2.expect.md new
+81
@@ -0,0 +1,81 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
6 +import { useRef, useCallback } from "react";
7 +
8 +function useCustomRef() {
9 + return useRef({ click: () => {} });
10 +}
11 +
12 +function Foo() {
13 + const ref = useCustomRef();
14 +
15 + const onClick = useCallback(() => {
16 + ref.current?.click();
17 + }, []);
18 +
19 + return <button onClick={onClick} />;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Foo,
24 + params: [],
25 + isComponent: true,
26 +};
27 +
28 +```
29 +
30 +## Code
31 +
32 +```javascript
33 +import { c as _c } from "react/compiler-runtime"; // @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
34 +import { useRef, useCallback } from "react";
35 +
36 +function useCustomRef() {
37 + const $ = _c(1);
38 + let t0;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = { click: () => {} };
41 + $[0] = t0;
42 + } else {
43 + t0 = $[0];
44 + }
45 + return useRef(t0);
46 +}
47 +
48 +function Foo() {
49 + const $ = _c(3);
50 + const ref = useCustomRef();
51 + let t0;
52 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
53 + t0 = () => {
54 + ref.current?.click();
55 + };
56 + $[0] = t0;
57 + } else {
58 + t0 = $[0];
59 + }
60 + const onClick = t0;
61 + let t1;
62 + if ($[1] !== onClick) {
63 + t1 = <button onClick={onClick} />;
64 + $[1] = onClick;
65 + $[2] = t1;
66 + } else {
67 + t1 = $[2];
68 + }
69 + return t1;
70 +}
71 +
72 +export const FIXTURE_ENTRYPOINT = {
73 + fn: Foo,
74 + params: [],
75 + isComponent: true,
76 +};
77 +
78 +```
79 +
80 +### Eval output
81 +(kind: ok) <button></button>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-useCallback-2.js new
+22
@@ -0,0 +1,22 @@
1 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
2 +import { useRef, useCallback } from "react";
3 +
4 +function useCustomRef() {
5 + return useRef({ click: () => {} });
6 +}
7 +
8 +function Foo() {
9 + const ref = useCustomRef();
10 +
11 + const onClick = useCallback(() => {
12 + ref.current?.click();
13 + }, []);
14 +
15 + return <button onClick={onClick} />;
16 +}
17 +
18 +export const FIXTURE_ENTRYPOINT = {
19 + fn: Foo,
20 + params: [],
21 + isComponent: true,
22 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-useCallback.expect.md new
+81
@@ -0,0 +1,81 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
6 +import { useRef, useCallback } from "react";
7 +
8 +function useCustomRef() {
9 + return useRef({ click: () => {} });
10 +}
11 +
12 +function Foo() {
13 + const customRef = useCustomRef();
14 +
15 + const onClick = useCallback(() => {
16 + customRef.current?.click();
17 + }, []);
18 +
19 + return <button onClick={onClick} />;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Foo,
24 + params: [],
25 + isComponent: true,
26 +};
27 +
28 +```
29 +
30 +## Code
31 +
32 +```javascript
33 +import { c as _c } from "react/compiler-runtime"; // @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
34 +import { useRef, useCallback } from "react";
35 +
36 +function useCustomRef() {
37 + const $ = _c(1);
38 + let t0;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = { click: () => {} };
41 + $[0] = t0;
42 + } else {
43 + t0 = $[0];
44 + }
45 + return useRef(t0);
46 +}
47 +
48 +function Foo() {
49 + const $ = _c(3);
50 + const customRef = useCustomRef();
51 + let t0;
52 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
53 + t0 = () => {
54 + customRef.current?.click();
55 + };
56 + $[0] = t0;
57 + } else {
58 + t0 = $[0];
59 + }
60 + const onClick = t0;
61 + let t1;
62 + if ($[1] !== onClick) {
63 + t1 = <button onClick={onClick} />;
64 + $[1] = onClick;
65 + $[2] = t1;
66 + } else {
67 + t1 = $[2];
68 + }
69 + return t1;
70 +}
71 +
72 +export const FIXTURE_ENTRYPOINT = {
73 + fn: Foo,
74 + params: [],
75 + isComponent: true,
76 +};
77 +
78 +```
79 +
80 +### Eval output
81 +(kind: ok) <button></button>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-like-name-in-useCallback.js new
+22
@@ -0,0 +1,22 @@
1 +// @enableTreatRefLikeIdentifiersAsRefs @validatePreserveExistingMemoizationGuarantees
2 +import { useRef, useCallback } from "react";
3 +
4 +function useCustomRef() {
5 + return useRef({ click: () => {} });
6 +}
7 +
8 +function Foo() {
9 + const customRef = useCustomRef();
10 +
11 + const onClick = useCallback(() => {
12 + customRef.current?.click();
13 + }, []);
14 +
15 + return <button onClick={onClick} />;
16 +}
17 +
18 +export const FIXTURE_ENTRYPOINT = {
19 + fn: Foo,
20 + params: [],
21 + isComponent: true,
22 +};