@samitouri / QOS-React-1 / commits / 4395689980

[compiler] ref guards apply up to fallthrough of the test (#34024)

Fixes #30782 When developers do an `if (ref.current == null)` guard for lazy ref initialization, the "safe" blocks should extend up to the if's fallthrough. Previously we only allowed writing to the ref in the if consequent, but this meant that you couldn't use a ternary, logical, etc in the if body. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34024). * #34027 * #34026 * #34025 * __->__ #34024

Joseph Savona committed Jul 29, 2025 at 10:53 UTC 4395689980a3e7d771675c99e4de42f40ea5bf0d
3 files changed +107 -8
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts
+15 -8
@@ -27,6 +27,7 @@ import {
27 eachTerminalOperand,
28 } from '../HIR/visitors';
29 import {Err, Ok, Result} from '../Utils/Result';
30 +import {retainWhere} from '../Utils/utils';
31
32 /**
33 * Validates that a function does not access a ref value during render. This includes a partial check
@@ -279,9 +280,10 @@ function validateNoRefAccessInRenderImpl(
280 for (let i = 0; (i == 0 || env.hasChanged()) && i < 10; i++) {
281 env.resetChanged();
282 returnValues = [];
282 - const safeBlocks = new Map<BlockId, RefId>();
283 + const safeBlocks: Array<{block: BlockId; ref: RefId}> = [];
284 const errors = new CompilerError();
285 for (const [, block] of fn.body.blocks) {
286 + retainWhere(safeBlocks, entry => entry.block !== block.id);
287 for (const phi of block.phis) {
288 env.set(
289 phi.place.identifier.id,
@@ -503,15 +505,17 @@ function validateNoRefAccessInRenderImpl(
505 case 'PropertyStore':
506 case 'ComputedDelete':
507 case 'ComputedStore': {
506 - const safe = safeBlocks.get(block.id);
508 const target = env.get(instr.value.object.identifier.id);
509 + let safe: (typeof safeBlocks)['0'] | null | undefined = null;
510 if (
511 instr.value.kind === 'PropertyStore' &&
510 - safe != null &&
511 - target?.kind === 'Ref' &&
512 - target.refId === safe
512 + target != null &&
513 + target.kind === 'Ref'
514 ) {
514 - safeBlocks.delete(block.id);
515 + safe = safeBlocks.find(entry => entry.ref === target.refId);
516 + }
517 + if (safe != null) {
518 + retainWhere(safeBlocks, entry => entry !== safe);
519 } else {
520 validateNoRefUpdate(errors, env, instr.value.object, instr.loc);
521 }
@@ -599,8 +603,11 @@ function validateNoRefAccessInRenderImpl(
603
604 if (block.terminal.kind === 'if') {
605 const test = env.get(block.terminal.test.identifier.id);
602 - if (test?.kind === 'Guard') {
603 - safeBlocks.set(block.terminal.consequent, test.refId);
606 + if (
607 + test?.kind === 'Guard' &&
608 + safeBlocks.find(entry => entry.ref === test.refId) == null
609 + ) {
610 + safeBlocks.push({block: block.terminal.fallthrough, ref: test.refId});
611 }
612 }
613
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-lazy-initialization-with-logical.expect.md new
+68
@@ -0,0 +1,68 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateRefAccessDuringRender
6 +
7 +import {useRef} from 'react';
8 +
9 +function Component(props) {
10 + const ref = useRef(null);
11 + if (ref.current == null) {
12 + // the logical means the ref write is in a different block
13 + // from the if consequent. this tests that the "safe" blocks
14 + // extend up to the if's fallthrough
15 + ref.current = props.unknownKey ?? props.value;
16 + }
17 + return <Child ref={ref} />;
18 +}
19 +
20 +function Child({ref}) {
21 + 'use no memo';
22 + return ref.current;
23 +}
24 +
25 +export const FIXTURE_ENTRYPOINT = {
26 + fn: Component,
27 + params: [{value: 42}],
28 +};
29 +
30 +```
31 +
32 +## Code
33 +
34 +```javascript
35 +import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender
36 +
37 +import { useRef } from "react";
38 +
39 +function Component(props) {
40 + const $ = _c(1);
41 + const ref = useRef(null);
42 + if (ref.current == null) {
43 + ref.current = props.unknownKey ?? props.value;
44 + }
45 + let t0;
46 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
47 + t0 = <Child ref={ref} />;
48 + $[0] = t0;
49 + } else {
50 + t0 = $[0];
51 + }
52 + return t0;
53 +}
54 +
55 +function Child({ ref }) {
56 + "use no memo";
57 + return ref.current;
58 +}
59 +
60 +export const FIXTURE_ENTRYPOINT = {
61 + fn: Component,
62 + params: [{ value: 42 }],
63 +};
64 +
65 +```
66 +
67 +### Eval output
68 +(kind: ok) 42
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-lazy-initialization-with-logical.js new
+24
@@ -0,0 +1,24 @@
1 +// @validateRefAccessDuringRender
2 +
3 +import {useRef} from 'react';
4 +
5 +function Component(props) {
6 + const ref = useRef(null);
7 + if (ref.current == null) {
8 + // the logical means the ref write is in a different block
9 + // from the if consequent. this tests that the "safe" blocks
10 + // extend up to the if's fallthrough
11 + ref.current = props.unknownKey ?? props.value;
12 + }
13 + return <Child ref={ref} />;
14 +}
15 +
16 +function Child({ref}) {
17 + 'use no memo';
18 + return ref.current;
19 +}
20 +
21 +export const FIXTURE_ENTRYPOINT = {
22 + fn: Component,
23 + params: [{value: 42}],
24 +};