Assignment expressions to a member path are a special case because they're the
only place where a value isn't assigned to a (possibly temporary) variable,
which is our unit of memoization. #901 demonstrated how this can lead to values
that can't be independently memoized:
```javascript
const x = {a: a}
x.y = [b, c]; // array recomputed w `x`, even if only `a` changed
```
This PR ensures that assignment expressions where the LHS is a member path lower
the RHS to a Place. That means the above example is handled as if you wrote:
```javascript
const x = {a: a};
const tmp1 = [b, c];
x.y = tmp1;
```
And we independently memoize the temporary.
Completes a todo for the `if` condition of scopes without any inputs. In this
case since there are no inputs we can check for changes, we check if the first
_output_ cache slot is set to the sentinel.
For this to work we need to ensure that all scopes have at least one output,
which isn't currently the case. Dead code can produce output-less sentinels. So
this PR also adds a pass to find scopes w/o any outputs and convert them to
regular blocks. We could in theory also just delete them, but for now let's be
more conservative. This is something we'd want to highlight as a diagnostic in
an IDE, though existing dead code linters would almost certainly find this case
too.
Remove our existing compiler flags since they were only being used for
enabling/disabling passes to aid debugging and to simplify in preparation for
the upcoming work on diagnostics and bailouts. Additionally with the new
playground tabs disabling passes has become less necessary. In the future when
we have actual compiler flags (eg tweaking optimization levels) we can add this
back.
I opted to keep the existing `CompilerResult` return value instead of just
returning the optimized AST as we're still using `scopes` in our test fixtures.
Small reorganization to move Pipeline out of HIR since it's a compiler module.
It used to make sense before to be in HIR since the old architecture was the
still the primary, but no longer!
It's not safe to infer types of arguments and return values because Javascript
is so polymorphic. Instead just infer the type of the callee for non methods.
Interestingly, even this is not conservative enough for JavaScript because Proxy
can also be callable. But I think for our use cases we will treat Proxy and
Functions similarly (they're all just objects) so it's ok.
Thanks old architecture! We learned a lot about what does and doesn't work via
this POC, but it's time to move forward w the ~~new~~ current architecture.
When collecting the output of each scope I was checking to see whether each
operand was being used after the end of the _current_ scope. What we really want
to be checking is whether the operand is used after _the scope in which it's
defined_. Those are the same thing when there is no nesting, involved, so the
previous logic worked for most examples.
It isn't super easy to tell when if the operand's scope has ended, because we
don't always know what the "current" InstructionId is inside a ReactiveFunction.
And that in turn isn't quite so easy to change, because of some edge cases like
break statements that we synthesize. The solution here is to track the set of
active scopes, and if an operand is used and its scope is not active then voila,
it's scope must have completed and its an output.
Moves _some_ files from HIR into new top-level directories. To not make
@gsathya's life a pain I left the files he's touching alone, but I moved some
others. My intent is to have something like this:
* Babel/ - code for the Babel plugin, though ideally this actually gets split
into a separate package and the compiler itself is AST in, AST out w no Babel
dep.
* HIR/ - the core HIRFunction, HIR and related data types, plus the HIR
construction and printing, HIR visitors.
* Inference/ - the core inference passes that operate on the HIR, including type
inference, reference effects, alias analysis, mutable range analysis, etc. I'll
let @gsathya move these files when at a good stopping point.
* ReactiveScopes/ - inference relating to reactive scopes,
constructing/printing/codegenning ReactiveFunction
* SSA/ - enter/leave SSA and eliminate redundant phis
* Utils/ - every project needs a place to put stuff that doesn't fit into the
other categories, this is ours.
This leaves just index.ts at the top level, and overall feels pretty tidy. Not
too tedious to figure out where anything goes, hopefully.
It's pretty tedious to keep the playground in sync w `Pipeline` — we need some
abstraction so we can write the sequence of passes once and reuse it (while
inspecting intermediate states).
This is mostly unused and there's just not enough benefit for now. We can re-add
this if folks start using this site on mobile. Removing now to simplify the
codebase.
Updates the ReactiveFunction-based codegen from the previous PR to emit
memoization code for each scope. This is currently naive and has some bugs, but
it gets the idea across. The core logic is straightforward at this point, all
the hard work is in earlier passes:
* Compute one change variable per scope dependency, eg `const c_0 = $[0] ===
maxItems`
* Generate one `let` binding for each scope output
* Generate an if block where the test is if any of the change variables are true
(`||` them together)
* Generate the consequent block with the original code block, plus statements to
save dependencies and outputs to their cache slots
* Generate the alternate block to populate the scope outputs from their cached
values
## Todos
A few things don't quite work yet:
* Codegen is designed to avoid emitting variables for temporary values, but
that's causing a few values to sort of disappear n the examples, or get emitted
twice. There are a variety of ways to achieve this but we'll need to ensure that
this category of values gets assigned to a variable and then reference the
variable. This is more involved.
* Scopes can end up with zero dependencies, in which case we should check that
the first output cache is initialized. This one is more straightforward.
* If there are early returns, we don't record that they occurred and replay them
in the `else` branch for each scope. We know the algorithm though so i'm okay
delaying that for now.
Currently codegen operates from HIR using a tree visitor, but for scope
construction we're converting the HIR (CFG) into a ReactiveFunction (AST-like).
Our original idea for codegen was that we would convert the ReactiveFunction
back to HIR, and then codegen from there. However, the ReactiveFunction is
already in tree form...which makes it very straightforward to generate code
from.
So this PR implements codegen from ReactiveFunction. The output is _identical_
thanks in large part to reusing as much logic from Codegen.ts as possible. The
next PR will add memoization logic.
While we're collecting scope dependencies, we have the exact right information
to record scope outputs. These are variables that need to be defined outside of
the scope and populated by recomputing (on change) or via the cached value (if
no change).
I realized that properly propagating scope dependencies requires reusing the
same logic as dependency collection itself: a dependency of an inner scope
should only be propagated upward if the dependency was declared before the outer
scope, for example. So this PR reimplements dependency collection in the
propagation pass.
At the same time I made a few other improvements:
* Don't report dependencies that are "constant". This is a bit simplistic for
now, we can use a more advanced analysis later.
* Try to avoid creating duplicate dependencies.
This addresses the todo from the previous PR (flattening scopes in loops) since
now we don't need to compute deps until after that runs. As a follow-up i'll
remove the existing dependency collection.
Dependency collection has to visit the instruction id first before evaluating
the instruction, in order to completely any scopes that would end at that
instruction. Note the removed dependencies that don't appear within the scopes.
We can't independently memoize values created within a loop, so this pass
flattens scopes within loops. Right now this just flattens the scope away
without propagating any dependency (or output) information, follow-ups will
extend it to do that.
We don't need to create scopes for primitive values that are never reassigned.
The actual rules are more complex — we could choose to skip creating scopes for
values that don't allocate — but this simple heuristic is good for now.
The new LeaveSSA looks ahead to the phis of fallback blocks. However, HIR can
sometimes have multiple blocks with the same fallthrough (totally fine), so this
diff clears the phis of fallbacks as they are reached to avoid reprocessing
them. This caused a previously incorrect case to now fail, yay.
There are a bunch of ways we can go about converting from the input HIR into a
final form that has the preamble inserted and memoized blocks of code wrapped
with change detection and caching. This is just one way, it might not be the
ideal way. In any case, this pass converts HIRFunction -> ReactiveFunction. The
latter is a recursive (tree-shaped) data structure that attempts to represent
blocks each of composed of scopes or instructions, where scopes are themselves
composed of blocks etc. The idea is a) this makes it easy to visualize the
structure and check that the scopes and their dependencies are correct and b)
this is a really nice form for adding the memoization code. We can convert from
a ReactiveFunction back to an HIRFunction, wrapping each scope in the
appropriate if checks and caching.
## Example
Consider the following example, which has 2 main scopes: an outer one for `x`
and an inner one in the consequent for `y`:
```javascript
function foo(a, b, c) {
const x = [];
if (a) {
const y = [];
y.push(b);
x.push(<div>{y}</div>);
} else {
x.push(c);
}
return x;
}
```
## Output
The new builder constructs a ReactiveFunction for this example along the lines
of the following (note that inputs are always empty bc we don't collect those
yet):
```
{
scope @0 [1:11] inputs=[] {
[1] Const mutate x$11_@0[1:11] = Array []
[2] if (read a$8) {
scope @1 [3:5] inputs=[] {
[3] Const mutate y$12_@1[3:5] = Array []
[4] Call mutate y$12_@1.push(read b$9)
}
scope @2 [5:6] inputs=[] {
[5] Const mutate $13_@2 = "div"
}
scope @3 [6:7] inputs=[] {
[6] Const mutate $14_@3 = JSX <read $13_@2>{freeze y$12_@1}</read $13_@2>
}
[7] Call mutate x$11_@0.push(read $14_@3)
} else {
[9] Call mutate x$11_@0.push(read c$10)
}
}
[10] return x$11;
}
```
This shows the hierarchy: there's an outer scope, `@0` to compute `x` (the first
scope), then within the if consequent there's another scope, `@1`, to compute
`y`. We have some technically extraneous scopes to compute the JSX element; that
can be cleaned up with a bit more refinement.
With this structure — and the inputs and outputs of each scope filled in — we
can convert to code in a straightforward manner. Each scope turns into a block
along the lines of the following:
(note here we use strings to index the cache, in reality these would be ints)
```javascript
// one change variable pet input:
let c_a = a !== $['a'];
...
// one variable for each output:
let x;
...
// if (changed) { recompute } else { use-cache }
if (c_a || ... ) {
x = ...;
// one assignment per output
$['x'] = x;
// update cache per input
$['a'] = a;
...
} else {
// one assignment per output
x = $['x'];
...
}
```
TreeVisitor didn't distinguish between the type of a block and the type of an
item that can occur within a block - this was fine for Codegen which can use
`t.Statement` for both of those values. However, the upcoming scope construction
needs to distinguish instructions in a block from a block itself, so this PR
adds a new type parameter.
Per the title, this PR adds support for assignment expressions in update
clauses. This was mostly fixed by the previous diff to improve value block
handling, and there's only a bit more to do here to allow a "value block" that
doesn't produce a value (we need a better name).