Skip to content

Commit 6584a6e

Browse files
authored
[compiler] Hoist dependencies from functions more conservatively (facebook#32616)
Alternative to facebook#31584 which sets enableTreatFunctionDepsAsConditional:true` by default. This PR changes dependency hoisting to be more conservative while trying to preserve an optimal "happy path". We assume that a function "is likely called" if we observe the following in the react function body. - a direct callsite - passed directly as a jsx attribute or child - passed directly to a hook - a direct return A function is also "likely called" if it is directly called, passed to jsx / hooks, or returned from another function that "is likely called". Note that this approach marks the function definition site with its hoistable properties (not its use site). I tried implementing use-site hoisting semantics, but it felt both unpredictable (i.e. as a developer, I can't trust that callbacks are well memoized) and not helpful (type + null checks of a value are usually colocated with their use site) In this fixture (copied here for easy reference), it should be safe to use `a.value` and `b.value` as dependencies, even though these functions are conditionally called. ```js // inner-function/nullable-objects/assume-invoked/conditional-call-chain.tsx function Component({a, b}) { const logA = () => { console.log(a.value); }; const logB = () => { console.log(b.value); }; const hasLogged = useRef(false); const log = () => { if (!hasLogged.current) { logA(); logB(); hasLogged.current = true; } }; return <Stringify log={log} shouldInvokeFns={true} />; } ``` On the other hand, this means that we produce invalid output for code like manually implementing `Array.map` ```js // inner-function/nullable-objects/bug-invalid-array-map-manual.js function useFoo({arr1, arr2}) { const cb = e => arr2[0].value + e.value; const y = []; for (let i = 0; i < arr1.length; i++) { y.push(cb(arr1[i])); } return y; } ```
1 parent 86d5ac0 commit 6584a6e

File tree

41 files changed

+2107
-53
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2107
-53
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts

Lines changed: 167 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
BlockId,
1414
DependencyPathEntry,
1515
GeneratedSource,
16+
getHookKind,
1617
HIRFunction,
1718
Identifier,
1819
IdentifierId,
1920
InstructionId,
2021
InstructionValue,
22+
LoweredFunction,
2123
PropertyLiteral,
2224
ReactiveScopeDependency,
2325
ScopeId,
@@ -112,6 +114,9 @@ export function collectHoistablePropertyLoads(
112114
hoistableFromOptionals,
113115
registry,
114116
nestedFnImmutableContext: null,
117+
assumedInvokedFns: fn.env.config.enableTreatFunctionDepsAsConditional
118+
? new Set()
119+
: getAssumedInvokedFunctions(fn),
115120
});
116121
}
117122

@@ -127,6 +132,11 @@ type CollectHoistablePropertyLoadsContext = {
127132
* but are currently kept separate for readability.
128133
*/
129134
nestedFnImmutableContext: ReadonlySet<IdentifierId> | null;
135+
/**
136+
* Functions which are assumed to be eventually called (as opposed to ones which might
137+
* not be called, e.g. the 0th argument of Array.map)
138+
*/
139+
assumedInvokedFns: ReadonlySet<LoweredFunction>;
130140
};
131141
function collectHoistablePropertyLoadsImpl(
132142
fn: HIRFunction,
@@ -338,7 +348,13 @@ function collectNonNullsInBlocks(
338348
context.registry.getOrCreateIdentifier(identifier),
339349
);
340350
}
341-
const nodes = new Map<BlockId, BlockInfo>();
351+
const nodes = new Map<
352+
BlockId,
353+
{
354+
block: BasicBlock;
355+
assumedNonNullObjects: Set<PropertyPathNode>;
356+
}
357+
>();
342358
for (const [_, block] of fn.body.blocks) {
343359
const assumedNonNullObjects = new Set<PropertyPathNode>(
344360
knownNonNullIdentifiers,
@@ -358,32 +374,30 @@ function collectNonNullsInBlocks(
358374
) {
359375
assumedNonNullObjects.add(maybeNonNull);
360376
}
361-
if (
362-
(instr.value.kind === 'FunctionExpression' ||
363-
instr.value.kind === 'ObjectMethod') &&
364-
!fn.env.config.enableTreatFunctionDepsAsConditional
365-
) {
377+
if (instr.value.kind === 'FunctionExpression') {
366378
const innerFn = instr.value.loweredFunc;
367-
const innerHoistableMap = collectHoistablePropertyLoadsImpl(
368-
innerFn.func,
369-
{
370-
...context,
371-
nestedFnImmutableContext:
372-
context.nestedFnImmutableContext ??
373-
new Set(
374-
innerFn.func.context
375-
.filter(place =>
376-
isImmutableAtInstr(place.identifier, instr.id, context),
377-
)
378-
.map(place => place.identifier.id),
379-
),
380-
},
381-
);
382-
const innerHoistables = assertNonNull(
383-
innerHoistableMap.get(innerFn.func.body.entry),
384-
);
385-
for (const entry of innerHoistables.assumedNonNullObjects) {
386-
assumedNonNullObjects.add(entry);
379+
if (context.assumedInvokedFns.has(innerFn)) {
380+
const innerHoistableMap = collectHoistablePropertyLoadsImpl(
381+
innerFn.func,
382+
{
383+
...context,
384+
nestedFnImmutableContext:
385+
context.nestedFnImmutableContext ??
386+
new Set(
387+
innerFn.func.context
388+
.filter(place =>
389+
isImmutableAtInstr(place.identifier, instr.id, context),
390+
)
391+
.map(place => place.identifier.id),
392+
),
393+
},
394+
);
395+
const innerHoistables = assertNonNull(
396+
innerHoistableMap.get(innerFn.func.body.entry),
397+
);
398+
for (const entry of innerHoistables.assumedNonNullObjects) {
399+
assumedNonNullObjects.add(entry);
400+
}
387401
}
388402
}
389403
}
@@ -591,3 +605,130 @@ function reduceMaybeOptionalChains(
591605
}
592606
} while (changed);
593607
}
608+
609+
function getAssumedInvokedFunctions(
610+
fn: HIRFunction,
611+
temporaries: Map<
612+
IdentifierId,
613+
{fn: LoweredFunction; mayInvoke: Set<LoweredFunction>}
614+
> = new Map(),
615+
): ReadonlySet<LoweredFunction> {
616+
const hoistableFunctions = new Set<LoweredFunction>();
617+
/**
618+
* Step 1: Conservatively collect identifier to function expression mappings
619+
*/
620+
for (const block of fn.body.blocks.values()) {
621+
for (const {lvalue, value} of block.instructions) {
622+
/**
623+
* Conservatively only match function expressions which can have guaranteed ssa.
624+
* ObjectMethods and ObjectProperties do not.
625+
*/
626+
if (value.kind === 'FunctionExpression') {
627+
temporaries.set(lvalue.identifier.id, {
628+
fn: value.loweredFunc,
629+
mayInvoke: new Set(),
630+
});
631+
} else if (value.kind === 'StoreLocal') {
632+
const lvalue = value.lvalue.place.identifier;
633+
const maybeLoweredFunc = temporaries.get(value.value.identifier.id);
634+
if (maybeLoweredFunc != null) {
635+
temporaries.set(lvalue.id, maybeLoweredFunc);
636+
}
637+
} else if (value.kind === 'LoadLocal') {
638+
const maybeLoweredFunc = temporaries.get(value.place.identifier.id);
639+
if (maybeLoweredFunc != null) {
640+
temporaries.set(lvalue.identifier.id, maybeLoweredFunc);
641+
}
642+
}
643+
}
644+
}
645+
/**
646+
* Step 2: Forward pass to do analysis of assumed function calls. Note that
647+
* this is conservative and does not count indirect references through
648+
* containers (e.g. `return {cb: () => {...}})`).
649+
*/
650+
for (const block of fn.body.blocks.values()) {
651+
for (const {lvalue, value} of block.instructions) {
652+
if (value.kind === 'CallExpression') {
653+
const callee = value.callee;
654+
const maybeHook = getHookKind(fn.env, callee.identifier);
655+
const maybeLoweredFunc = temporaries.get(callee.identifier.id);
656+
if (maybeLoweredFunc != null) {
657+
// Direct calls
658+
hoistableFunctions.add(maybeLoweredFunc.fn);
659+
} else if (maybeHook != null) {
660+
/**
661+
* Assume arguments to all hooks are safe to invoke
662+
*/
663+
for (const arg of value.args) {
664+
if (arg.kind === 'Identifier') {
665+
const maybeLoweredFunc = temporaries.get(arg.identifier.id);
666+
if (maybeLoweredFunc != null) {
667+
hoistableFunctions.add(maybeLoweredFunc.fn);
668+
}
669+
}
670+
}
671+
}
672+
} else if (value.kind === 'JsxExpression') {
673+
/**
674+
* Assume JSX attributes and children are safe to invoke
675+
*/
676+
for (const attr of value.props) {
677+
if (attr.kind === 'JsxSpreadAttribute') {
678+
continue;
679+
}
680+
const maybeLoweredFunc = temporaries.get(attr.place.identifier.id);
681+
if (maybeLoweredFunc != null) {
682+
hoistableFunctions.add(maybeLoweredFunc.fn);
683+
}
684+
}
685+
for (const child of value.children ?? []) {
686+
const maybeLoweredFunc = temporaries.get(child.identifier.id);
687+
if (maybeLoweredFunc != null) {
688+
hoistableFunctions.add(maybeLoweredFunc.fn);
689+
}
690+
}
691+
} else if (value.kind === 'FunctionExpression') {
692+
/**
693+
* Recursively traverse into other function expressions which may invoke
694+
* or pass already declared functions to react (e.g. as JSXAttributes).
695+
*
696+
* If lambda A calls lambda B, we assume lambda B is safe to invoke if
697+
* lambda A is -- even if lambda B is conditionally called. (see
698+
* `conditional-call-chain` fixture for example).
699+
*/
700+
const loweredFunc = value.loweredFunc.func;
701+
const lambdasCalled = getAssumedInvokedFunctions(
702+
loweredFunc,
703+
temporaries,
704+
);
705+
const maybeLoweredFunc = temporaries.get(lvalue.identifier.id);
706+
if (maybeLoweredFunc != null) {
707+
for (const called of lambdasCalled) {
708+
maybeLoweredFunc.mayInvoke.add(called);
709+
}
710+
}
711+
}
712+
}
713+
if (block.terminal.kind === 'return') {
714+
/**
715+
* Assume directly returned functions are safe to call
716+
*/
717+
const maybeLoweredFunc = temporaries.get(
718+
block.terminal.value.identifier.id,
719+
);
720+
if (maybeLoweredFunc != null) {
721+
hoistableFunctions.add(maybeLoweredFunc.fn);
722+
}
723+
}
724+
}
725+
726+
for (const [_, {fn, mayInvoke}] of temporaries) {
727+
if (hoistableFunctions.has(fn)) {
728+
for (const called of mayInvoke) {
729+
hoistableFunctions.add(called);
730+
}
731+
}
732+
}
733+
return hoistableFunctions;
734+
}

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/function-expression-prototype-call.expect.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ import { c as _c } from "react/compiler-runtime";
2323
function Component(props) {
2424
const $ = _c(4);
2525
let t0;
26-
if ($[0] !== props.name) {
26+
if ($[0] !== props) {
2727
t0 = function () {
2828
return <div>{props.name}</div>;
2929
};
30-
$[0] = props.name;
30+
$[0] = props;
3131
$[1] = t0;
3232
} else {
3333
t0 = $[1];
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {Stringify} from 'shared-runtime';
6+
7+
/**
8+
* Forked from array-map-simple.js
9+
*
10+
* Named lambdas (e.g. cb1) may be defined in the top scope of a function and
11+
* used in a different lambda (getArrMap1).
12+
*
13+
* Here, we should try to determine if cb1 is actually called. In this case:
14+
* - getArrMap1 is assumed to be called as it's passed to JSX
15+
* - cb1 is not assumed to be called since it's only used as a call operand
16+
*/
17+
function useFoo({arr1, arr2}) {
18+
const cb1 = e => arr1[0].value + e.value;
19+
const getArrMap1 = () => arr1.map(cb1);
20+
const cb2 = e => arr2[0].value + e.value;
21+
const getArrMap2 = () => arr1.map(cb2);
22+
return (
23+
<Stringify
24+
getArrMap1={getArrMap1}
25+
getArrMap2={getArrMap2}
26+
shouldInvokeFns={true}
27+
/>
28+
);
29+
}
30+
31+
export const FIXTURE_ENTRYPOINT = {
32+
fn: useFoo,
33+
params: [{arr1: [], arr2: []}],
34+
sequentialRenders: [
35+
{arr1: [], arr2: []},
36+
{arr1: [], arr2: null},
37+
{arr1: [{value: 1}, {value: 2}], arr2: [{value: -1}]},
38+
],
39+
};
40+
41+
```
42+
43+
## Code
44+
45+
```javascript
46+
import { c as _c } from "react/compiler-runtime";
47+
import { Stringify } from "shared-runtime";
48+
49+
/**
50+
* Forked from array-map-simple.js
51+
*
52+
* Named lambdas (e.g. cb1) may be defined in the top scope of a function and
53+
* used in a different lambda (getArrMap1).
54+
*
55+
* Here, we should try to determine if cb1 is actually called. In this case:
56+
* - getArrMap1 is assumed to be called as it's passed to JSX
57+
* - cb1 is not assumed to be called since it's only used as a call operand
58+
*/
59+
function useFoo(t0) {
60+
const $ = _c(13);
61+
const { arr1, arr2 } = t0;
62+
let t1;
63+
if ($[0] !== arr1[0]) {
64+
t1 = (e) => arr1[0].value + e.value;
65+
$[0] = arr1[0];
66+
$[1] = t1;
67+
} else {
68+
t1 = $[1];
69+
}
70+
const cb1 = t1;
71+
let t2;
72+
if ($[2] !== arr1 || $[3] !== cb1) {
73+
t2 = () => arr1.map(cb1);
74+
$[2] = arr1;
75+
$[3] = cb1;
76+
$[4] = t2;
77+
} else {
78+
t2 = $[4];
79+
}
80+
const getArrMap1 = t2;
81+
let t3;
82+
if ($[5] !== arr2) {
83+
t3 = (e_0) => arr2[0].value + e_0.value;
84+
$[5] = arr2;
85+
$[6] = t3;
86+
} else {
87+
t3 = $[6];
88+
}
89+
const cb2 = t3;
90+
let t4;
91+
if ($[7] !== arr1 || $[8] !== cb2) {
92+
t4 = () => arr1.map(cb2);
93+
$[7] = arr1;
94+
$[8] = cb2;
95+
$[9] = t4;
96+
} else {
97+
t4 = $[9];
98+
}
99+
const getArrMap2 = t4;
100+
let t5;
101+
if ($[10] !== getArrMap1 || $[11] !== getArrMap2) {
102+
t5 = (
103+
<Stringify
104+
getArrMap1={getArrMap1}
105+
getArrMap2={getArrMap2}
106+
shouldInvokeFns={true}
107+
/>
108+
);
109+
$[10] = getArrMap1;
110+
$[11] = getArrMap2;
111+
$[12] = t5;
112+
} else {
113+
t5 = $[12];
114+
}
115+
return t5;
116+
}
117+
118+
export const FIXTURE_ENTRYPOINT = {
119+
fn: useFoo,
120+
params: [{ arr1: [], arr2: [] }],
121+
sequentialRenders: [
122+
{ arr1: [], arr2: [] },
123+
{ arr1: [], arr2: null },
124+
{ arr1: [{ value: 1 }, { value: 2 }], arr2: [{ value: -1 }] },
125+
],
126+
};
127+
128+
```
129+
130+
### Eval output
131+
(kind: ok) <div>{"getArrMap1":{"kind":"Function","result":[]},"getArrMap2":{"kind":"Function","result":[]},"shouldInvokeFns":true}</div>
132+
<div>{"getArrMap1":{"kind":"Function","result":[]},"getArrMap2":{"kind":"Function","result":[]},"shouldInvokeFns":true}</div>
133+
<div>{"getArrMap1":{"kind":"Function","result":[2,3]},"getArrMap2":{"kind":"Function","result":[0,1]},"shouldInvokeFns":true}</div>

0 commit comments

Comments
 (0)