Support OptionalCallExpression as LHS of LogicalExpression

A common idiom is to map over some possibly-missing list of items from a data 
payload and fall back to an empty array: 

```javascript 

const renderedItems = data?.items?.map(renderItem) ?? []; 

``` 

The way we were lowering OptionalCallExpression meant that in this case, we'd 
end up with an OptionalCallTerminal as the terminal of the logical expression's 
test block, which violates our internal invariant. Logical test blocks must end 
in a Branch! This PR fixes the immediate issue, which is that the callee - in 
this case `data?.items?.map` — was being lowered prior to the 
OptionalCallTerminal instead of inside its test block. Changing that fixes the 
shape of the IR and makes this example work. 

As part of investigating this I realized that the way I originally handled 
lowering of optional call isn't quite right. The difference isn't observable 
unless we did more sophisticated DCE but we don't correctly model the fact that 
if `data.items` is null that the `map()` call won't occur. That is technically 
fine bc we do model the fact that the `map()` call is conditional, and notably 
its arguments are only conditional dependencies. So it's good enough. But in a 
follow-up I'll change to model the fact that `data.items` is null, that the map 
call isn't reachable at all.
This commit is contained in:
Joe Savona
2023-05-02 15:00:11 -07:00
parent 8292c65d84
commit 9a12e8ba58
11 changed files with 195 additions and 63 deletions
+55 -52
View File
@@ -1093,6 +1093,29 @@ function lowerExpression(
const loc = expr.node.loc ?? GeneratedSource;
const place = buildTemporaryPlace(builder, loc);
const continuationBlock = builder.reserve(builder.currentBlockKind());
const consequent = builder.reserve("value");
// block to evaluate if the callee is null/undefined, this sets the result of the call to undefined.
const alternate = builder.enter("value", () => {
const temp = lowerValueToTemporary(builder, {
kind: "Primitive",
value: undefined,
loc,
});
lowerValueToTemporary(builder, {
kind: "StoreLocal",
lvalue: { kind: InstructionKind.Const, place: { ...place } },
value: { ...temp },
loc,
});
return {
kind: "goto",
variant: GotoVariant.Break,
block: continuationBlock.id,
id: makeInstructionId(0),
loc,
};
});
// Lower the callee in the current block: the callee is always unconditionally evaluated
// The test block's branch will test on this value to determine whether to evaluate the call (consequent)
@@ -1100,27 +1123,42 @@ function lowerExpression(
let callee:
| { kind: "CallExpression"; callee: Place }
| { kind: "MethodCall"; receiver: Place; property: Place };
if (
calleePath.isMemberExpression() ||
calleePath.isOptionalMemberExpression()
) {
const memberExpr = lowerMemberExpression(builder, calleePath);
const propertyPlace = lowerValueToTemporary(builder, memberExpr.value);
callee = {
kind: "MethodCall",
receiver: memberExpr.object,
property: propertyPlace,
const testBlock = builder.enter("value", () => {
if (
calleePath.isMemberExpression() ||
calleePath.isOptionalMemberExpression()
) {
const memberExpr = lowerMemberExpression(builder, calleePath);
const propertyPlace = lowerValueToTemporary(
builder,
memberExpr.value
);
callee = {
kind: "MethodCall",
receiver: memberExpr.object,
property: propertyPlace,
};
} else {
callee = {
kind: "CallExpression",
callee: lowerExpressionToTemporary(builder, calleePath),
};
}
const testPlace =
callee.kind === "CallExpression" ? callee.callee : callee.property;
return {
kind: "branch",
test: { ...testPlace },
consequent: consequent.id,
alternate,
id: makeInstructionId(0),
loc,
};
} else {
callee = {
kind: "CallExpression",
callee: lowerExpressionToTemporary(builder, calleePath),
};
}
});
// block to evaluate if the callee is non-null/undefined. arguments are lowered in this block to preserve
// the semantic of conditional evaluation depending on the callee
const consequent = builder.enter("value", () => {
builder.enterReserved(consequent, () => {
const args = lowerArguments(builder, expr.get("arguments"));
const temp = buildTemporaryPlace(builder, loc);
if (callee.kind === "CallExpression") {
@@ -1164,41 +1202,6 @@ function lowerExpression(
};
});
// block to evaluate if the callee is null/undefined, this sets the result of the call to undefined.
const alternate = builder.enter("value", () => {
const temp = lowerValueToTemporary(builder, {
kind: "Primitive",
value: undefined,
loc,
});
lowerValueToTemporary(builder, {
kind: "StoreLocal",
lvalue: { kind: InstructionKind.Const, place: { ...place } },
value: { ...temp },
loc,
});
return {
kind: "goto",
variant: GotoVariant.Break,
block: continuationBlock.id,
id: makeInstructionId(0),
loc,
};
});
const testBlock = builder.enter("value", () => {
const testPlace =
callee.kind === "CallExpression" ? callee.callee : callee.property;
return {
kind: "branch",
test: { ...testPlace },
consequent,
alternate,
id: makeInstructionId(0),
loc,
};
});
builder.terminateWithContinuation(
{
kind: "optional-call",
+19 -9
View File
@@ -340,16 +340,13 @@ export default class HIRBuilder {
}
/**
* Create a new block and execute the provided callback with the new block
* set as the current, resetting to the previously active block upon exit.
* The lambda must return a terminal node, which is used to terminate the
* newly constructed block.
* Sets the given wip block as the current block, executes the provided callback to populate the block
* up to its terminal, and then resets the previous actively block.
*/
enter(nextBlockKind: BlockKind, fn: (blockId: BlockId) => Terminal): BlockId {
enterReserved(wip: WipBlock, fn: () => Terminal): void {
const current = this.#current;
const nextId = this.#env.nextBlockId;
this.#current = newBlock(nextId, nextBlockKind);
const terminal = fn(nextId);
this.#current = wip;
const terminal = fn();
const { id: blockId, kind, instructions } = this.#current;
this.#completed.set(blockId, {
kind,
@@ -360,7 +357,20 @@ export default class HIRBuilder {
phis: new Set(),
});
this.#current = current;
return nextId;
}
/**
* Create a new block and execute the provided callback with the new block
* set as the current, resetting to the previously active block upon exit.
* The lambda must return a terminal node, which is used to terminate the
* newly constructed block.
*/
enter(nextBlockKind: BlockKind, fn: (blockId: BlockId) => Terminal): BlockId {
const wip = this.reserve(nextBlockKind);
this.enterReserved(wip, () => {
return fn(wip.id);
});
return wip.id;
}
label<T>(label: string, breakBlock: BlockId, fn: () => T): T {
@@ -768,12 +768,26 @@ class Driver {
testBlock.terminal.consequent,
terminal.loc
);
const call: ReactiveSequenceValue = {
kind: "SequenceExpression",
instructions: [
{
id: test.id,
loc: testBlock.terminal.loc,
lvalue: test.place,
value: test.value,
},
],
id: consequent.id,
value: consequent.value,
loc: terminal.loc,
};
return {
place: { ...consequent.place },
value: {
kind: "OptionalCall",
optional: terminal.optional,
call: consequent.value,
call: call,
id: terminal.id,
loc: terminal.loc,
},
@@ -151,6 +151,14 @@ function printReactiveValue(writer: Writer, value: ReactiveValue): void {
});
break;
}
case "OptionalCall": {
writer.append(`OptionalCall optional=${value.optional}`);
writer.newline();
writer.indented(() => {
printReactiveValue(writer, value.call);
});
break;
}
default: {
writer.append(printInstructionValue(value));
}
@@ -262,7 +262,10 @@ function computeMemoizedIdentifiers(state: State): Set<IdentifierId> {
// Visit an identifier, optionally forcing it to be memoized
function visit(id: IdentifierId, forceMemoize: boolean = false): boolean {
const node = state.identifiers.get(id);
invariant(node !== undefined, "Expected a node for all identifiers");
invariant(
node !== undefined,
`Expected a node for all identifiers, none found for '${id}'`
);
if (node.seen) {
return node.memoized;
}
@@ -0,0 +1,32 @@
## Input
```javascript
function Component(props) {
const object = makeObject();
return object.a?.b?.c(props);
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react";
function Component(props) {
const $ = useMemoCache(2);
const c_0 = $[0] !== props;
let t0;
if (c_0) {
const object = makeObject();
t0 = object.a?.b?.c(props);
$[0] = props;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
@@ -0,0 +1,4 @@
function Component(props) {
const object = makeObject();
return object.a?.b?.c(props);
}
@@ -0,0 +1,21 @@
## Input
```javascript
function Component(props) {
const item = useFragment(graphql`...`, props.item);
return item.items?.map((item) => renderItem(item)) ?? [];
}
```
## Code
```javascript
function Component(props) {
const item = useFragment(graphql`...`, props.item);
return item.items?.map((item_0) => renderItem(item_0)) ?? [];
}
```
@@ -0,0 +1,4 @@
function Component(props) {
const item = useFragment(graphql`...`, props.item);
return item.items?.map((item) => renderItem(item)) ?? [];
}
@@ -0,0 +1,30 @@
## Input
```javascript
function Component(props) {
return foo?.(props);
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react";
function Component(props) {
const $ = useMemoCache(2);
const c_0 = $[0] !== props;
let t0;
if (c_0) {
t0 = foo?.(props);
$[0] = props;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
@@ -0,0 +1,3 @@
function Component(props) {
return foo?.(props);
}