-
-
Notifications
You must be signed in to change notification settings - Fork 2
v2.x
This is documentation for Reflex Functions v2.x
Project • Motivation • Overview • Polyfill • Discusions • Issues
Reflex Functions are a proposed new type of JavaScript function that enable fine-grained Reactive Programming in the imperative form and linear flow of the language, where every logic expressed is maintained as a "contract" throughout the lifetime of the program!
Reflex Functions have a distinguishing syntax: a double star notation.
function** calculate() {
// Function body
}
Show more
// As function expression
const calculate = function** () {
// Function body
}
// As object property
const object = {
calculate: function** () {
// Function body
},
}
// As object method
const object = {
**calculate() {
// Function body
},
}
// As class method
class Object {
**calculate() {
// Function body
}
}
// As function constructor
let reflexFunction = ReflexFunction( functionBody );
let reflexFunction = ReflexFunction( arg1, ... argN, functionBody );
That may already be striking a similarity with the Generator Functions syntax, but we'll come to that in the design discussion!
Function body is any regular piece of code that should statically reflect changes to its external dependencies:
let count = 10; // External dependency
function** calculate(factor) {
// Reactive expressions
let doubled = count * factor;
console.log(doubled);
}
Return value is a two-part array that contains both the function's actual return value and a special reflect
function for getting the function to reflect updates:
let [ returnValue, reflect ] = calculate(2);
console.log(returnValue); // undefined
Console
doubled | returnValue |
---|---|
20 |
undefined |
Differences from earlier versions
In earlier iterations of the idea, the reflection mechanism was a .thread()
static method on the function object itself!
let returnValue1 = calculate(2);
calculate.thread(...references);
But Reflex Functions are stateful and the reflection mechanism needs to be associated with the internal state created on each instance!
let [ returnValue2, reflect2 ] = calculate(4);
reflect2(...references);
let [ returnValue3, reflect3 ] = calculate(6);
reflect3(...references);
The functions reflect2
and reflect3
are tied to two different states of calculate
!
The reflect()
function takes just the string representation of the external dependencies that have changed:
count = 20;
reflect('count');
Console
doubled |
---|
40 |
Path dependencies are expressed in array notation. And multiple dependencies can be reflected at once, if they changed at once:
count++;
this.property = value;
reflect('count', [ 'this', 'property' ]);
Return value for the reflect()
function itself is determined by where the reflection terminates within the function body:
let count = 10; // External dependency
function** calculate(factor) {
// Reactive expressions
let doubled = count * factor;
if (doubled > 40) {
return Promise.resolve(doubled);
}
return doubled;
}
let [ returnValue, reflect ] = calculate(4);
console.log(returnValue); // 40
Console
returnValue |
---|
40 |
count = 20;
returnValue = reflect('count');
console.log(returnValue);
Console
returnValue |
---|
Promise { 80 } |
Reactivity exists with Reflex Functions where there are dependencies "up" the scope to respond to! And here's the mental model for that:
┌─
a change happens outside of function scope
└─
is propagated into function, then self-propagates down ─┐
Changes within the function body itself self-propagate down the scope, but re-running only those expressions that depend on the specific change... and rippling down the dependency graph!
Below is a good way to see that: a Reflex Function having score
as an external dependency, with "reflex lines" having been drawn to show the dependency graph for that variable, or, in other words, the deterministic update path for that dependency:
It turns out, it's the same mental model you would have drawn as you set out to think about your code! Everything happens in just how anyone would predict it!
And here's what's noteworthy about reflex actions:
- that "pixel-perfect" level of fine-grained reactivity that the same algorithm translates to - which you could never model manually
- that precision that means no more, no less performance - which you could never achieve with manual optimization
and what's more: all without you working for it!
Reflex Functions are designed to rather "understand" your code - using good compile-time (and a bit of runtime) heuristics - than impose constraints over how you write your code. This lets us enjoy the full range of language features without “loosing" reactivity!
For example, expressions that reference deep object properties are bound to updates that actually happen along those specific paths:
function** demo() {
let username = candidate.username;
let avatarUrl = candidate.profile.avatar;
}
Above, the first expression responds only when the candidate.username
property is updated or deleted, or when the root object candidate
is changed. And the second expression responds only when the candidate.profile.avatar
property or the parent path candidate.profile
is updated or deleted, or when the root object candidate
is changed.
And all of that holds even with a dynamic syntax:
function** demo() {
let username = candidate[1 ? 'username' : 'name'];
let avatarUrl = (1 ? candidate : {}).profile?.avatar;
}
Also, the two expressions continue to be treated individually - as two distinct contracts - even when combined in one declaration:
function** demo() {
let username = candidate.username, avatarUrl = candidate.profile.avatar;
}
Heuristics make it all work with all of ES6+ syntax niceties. Thus, they continue to be two distinct contracts (and reactivity remains fine-grained) even with a destructuring syntax:
function** demo() {
let { username, profile: { avatar: avatarUrl } } = candidate;
}
And even when so dynamically destructured:
function** demo() {
let profileProp = 'avatar';
let { username, profile: { [ profileProp ]: avatarUrl } } = candidate;
}
Reflex Functions treat conditional expressions and loops as contract. And these are able to reflect updates to their dependencies in granular details.
"If/else" statements are bound to references in their "test" expression - testExpr
- and an update to those dependencies is reflected via a re-run of the whole construct. Nested "if/else" statements reflect their own dependencies in isolation, but subject to when the state of the parent condition is truthy for the branch they are nested in:
Show Reflection
// Re-evaluate parent block
reflect('testExpr1');
// Re-evaluate child block
// But this is subject to when the state of parent's testExpr1 (via memoization) is "falsey"
reflect('testExpr2');
"Switch" statements are bound to references in their "switch/case" expressions - operandExpr
, caseExpr
- and an update to those dependencies is reflected via a re-run of the whole construct:
Show Reflection
// Re-evaluate whole construct
reflect('operandExpr');
// Re-evaluate whole construct
reflect('caseExpr2');
Conditional expressions with logical and ternary operators also work as conditional contracts. These expressions are bound to references in their “test" expression - testExpr
- and an update to those dependencies is reflected via a re-run of the whole statement:
Show Reflection
// Re-evaluate whole statement
reflect('testExpr');
// Re-evaluate whole statement
// But this is subject to when the state of testExpr (via memoization) is "falsey"
reflect('alternateExpr');
Being each a contract of their own, individual expressions within each branch of a conditional construct can reflect their own dependencies in isolation. This time, this is subject to when the "memoized" state of the conditional construct is “truthy" for the branch!
--> Example 1: In the nested "if" construct below...
if (parentTestExpr) {
if (childTestExpr) {
} else {
console.log(reference);
}
}
...the console.log()
expression will reflect an update to its reference
when both parentTestExpr
is truthy and childTestExpr
is falsey.
--> Example 2: In the logical and ternary expressions below...
let result = testExpr && consequentExpr || alternateExpr;
let result = testExpr ? consequentExpr : alternateExpr;
...the statement will always re-evaluate on an update to its testExpr
. But as for changes to consequentExpr
, the statement will only re-evaluate when testExpr
is truthy. (And conversely so in the case of alternateExpr
.)
In all cases above, Reflex Functions determine whether or not a re-evaluation should actually happen by consulting the "state" of testExpr
through "memoization"; meaning that if testExpr
were a function call - testExpr()
, no invokation actually happens during this time of asking; in other words, no unnecessary re-evaluation of any part of the conditional construct.
Below are examples showing each part of a conditional construct as a function call. We should now see at what point a specific part of the construct is evaluated:
--> Parts:
// For testExpr
let _testExpr = false;
function testExpr() {
console.log('"testExpr" called.');
return _testExpr;
}
// For consequentExpr
function consequentExpr() {
console.log('"consequentExpr" called.');
return 'consequentExpr';
}
// For alternateExpr
function alternateExpr() {
console.log('"alternateExpr" called.');
return 'alternateExpr';
}
--> Conditional Constructs:
// If/else construct
function** demo() {
let result;
if (testExpr()) {
result = consequentExpr();
} else {
result = alternateExpr();
}
console.log('Result:', result);
}
// Logical expression
function** demo() {
let result = testExpr() && consequentExpr() || alternateExpr();
console.log('Result:', result);
}
// Ternary expression
function** demo() {
let result = testExpr() ? consequentExpr() : alternateExpr();
console.log('Result:', result);
}
--> Reflection Tests:
reflect('testExpr')
_testExpr = true;
reflect('testExpr');
> "testExpr" called.
> "consequentExpr" called.
> Result: consequentExpr
reflect('consequentExpr')
reflect('consequentExpr');
> "testExpr" called.
> "consequentExpr" called.
> Result: consequentExpr
reflect('alternateExpr')
reflect('alternateExpr');
Nothing happens; neither the statement itself, nor any part of the statement!
testExpr
is still truthy (from memoization), and thus,alternateExpr
was never going to run even if the entire statement was forced to re-evaluate!
This level of precision makes it possible that nothing is over-run, and nothing is under-run!
When the parameters of a loop contain references, the loop is bound to those references. This lets us have loops that can statically reflect updates.
A "for" loop is bound to references in its 3-statement definition - initStatement
, testStatement
, incrementStatement
- and an update to those dependencies is reflected via a re-run of the loop:
Show Reflection
// Re-run whole loop
reflect('testStatement');
// Re-run whole loop
reflect('initStatement');
"While" and "do ... while" loops are bound to references in their "test" expression - testExpr
- and an update to those dependencies is reflected via a re-run of the loop:
Show Reflection
// Re-run whole loop
reflect('testExpr');
"For ... of" and “for … in" loops are bound to references in their iteratee - iteratee
- and an update to those dependencies is reflected via a re-run of the loop:
Show Reflection
// Re-run whole loop
reflect('iteratee');
Being each a contract of their own, individual expressions in the body of a loop can reflect their own dependencies in isolation. Here, refelction happens statically in the "already-made" iterations of the loop - meaning, without the loop itself ever re-running.
--> Example 1: In the loop below...
let sourceItems = [ 'one', 'two', 'three', 'four', 'five' ];
let targetItems = [];
let prefix = '';
function** loop() {
for ( let index = 0; index < sourceItems.length; index ++ ) {
console.log( `Current iteration index is: ${ index }, and value is: '${ sourceItems[ index ] }'` );
targetItems[ index ] = prefix + sourceItems[ index ];
}
}
let [ , reflect ] = loop();
...an update to the reference prefix
will be reflected by the second statement (specifically) in each "already-made" iteration of the loop - without restarting the loop.
// >> Skips the loop;
// >> Gets each "already-made" iteration to re-rin
prefix = 'Updated-';
reflect('prefix');
Now, each entry in targetItems
gets updated with prefixed values without the loop itself re-running.
If this were to be a “for … of" or a "for … in" loop, there would be one more level of detail: each iteration of the loop is bound to the corresponding key in iteratee
! This has it that an update to the value of a key in iteratee
will be reflected by the specific "already-made" iteration corresponding to that key - meaning, without the loop itself ever knowing.
--> Example 1: In the loop below...
let sourceItems = [ { name: 'one' }, { name: 'two' }, { name: 'three' }, { name: 'four' }, { name: 'five' } ];
function** loop() {
for ( let entry of sourceItems ) {
let index = sourceItems.indexOf( entry );
console.log( `Current iteration index is: ${ index }, and name is: '${ entry.name }'.` );
targetItems[ index ] = sourceItems[ index ].name;
}
}
let [ , reflect ] = loop();
...an update to key 2
of sourceItems
will be reflected by the "already-made" iteration at key 2
- without restarting the loop.
// >> Skips the loop;
// >> Gets the "already-made" iteration at key 2 to re-rin
sourceItems[ 2 ] = { name: 'new three' };
reflect( [ 'sourceItems', 2 ] );
Now, index 2
of targetItems
is updated, and the console reports:
Current iteration index is: 2, and name is: 'new three'.
If we mutate the name
property of the above entry in-place, then it gets even more fine-grained: only the dependent console.log()
expression in "already-made" iteration at key 2
will reflect the update.
--> Example:
// >> Skips the loop;
// >> Enters the "already-made" iteration at key 2
// >> Gets the console.log() expression to re-run
sourceItems[ 2 ].name = 'new three';
reflect( [ 'sourceItems', 2 ] );
Now, the console reports:
Current iteration index is: 2, and name is: 'new three'.
Fine-grained reflections observe break
and continue
statements, even when these redirect control to a parent block using labels.
Example
let entries = { one: { name: 'one' }, two: { name: 'two' } };
function** loop() {
parentLoop: for ( let propertyName in entries ) {
childLoop: for ( let subPropertyName in entries[ propertyName ] ) {
If ( propertyName === 'two' ) {
break parentLoop;
}
console.log( propertyName, subPropertyName );
}
}
}
let [ , reflect ] = loop();
So, above, on updating the entries
object, the nested loops re-run as expected, and the child loop effectively breaks the parent loop at the appropriate point.
reflect('entries');
If we mutated the object in-place to make just the child loop rerun...
reflect( [ 'entries', 'two' ] );
...the break
directive in the child loop would be pointing to a parent loop that isn't running, but this would be harmless. The child loop would simply exit as it would if the parent were to actually break at that point.
But if we did the above with a continue
directive, the child loop would also exit as it would if the parent were to actually receive control back at that point, without control actually moving to a now non-running parent.
Functions are static definitions and nothing about their definition has any "contract" significance!
function sum( a, b ) {}
There is really only the "contract" significance at call-time; and call-time arguments are rightly treated as dependencies of the calling expression:
let result = sum( score, 100 );
The expression above is bound to the reference score
and will re-execute each time score
needs to be reflected.
Reflex Functions may contain nested Reflex Functions as needed:
function** calculate(score) {
let factor = 2;
function** eval( a, b ) {
return (a + b) * factor;
}
let [ result, reflect ] = eval( score, 100 );
if (someCondition) {
factor = 4;
result = reflect('factor');
}
return result;
}
let [ result, reflect ] = calculate(10);
When a function modifies anything outside of its scope, it is said to have side effects.
let callCount = 0;
function sum( a, b ) {
callCount ++;
return a + b;
}
When it does not, it is said to be a pure function.
function sum( a, b ) {
return a + b;
}
Regardless, Reflex Functions are fully able to pick up changes made via a side effect - where statically analyzable.
Above, a side effect happens whenever sum()
is called: callCount
is updated. Although the console.log()
expression isn't directly dependent on the result = sum()
expression, it is directly dependent on the side effect - callCount
.
So, on getting the result = sum()
expression to re-execute, to reflect an update to score
, the side effect happens, and thus, the console.log()
expression re-executes, to reflect that.
ReflexFunction
is a one-to-one implementation of the JavaScript Function constructor.
// Statically
let reflexFunction = ReflexFunction( functionBody );
let reflexFunction = ReflexFunction( arg1, functionBody );
let reflexFunction = ReflexFunction( arg1, ... argN, functionBody );
// With the new operator
let reflexFunction = new ReflexFunction( functionBody );
let reflexFunction = new ReflexFunction( arg1, functionBody );
let reflexFunction = new ReflexFunction( arg1, ... argN, functionBody );
arg1
, ... argN
: Names to be used by the function as formal argument names. Each must be a string that corresponds to a valid JavaScript parameter (any of plain identifier, rest parameter, or destructured parameter, optionally with a default), or a list of such strings separated with commas.
functionBody
: A string that represents the function body.
A regular Function
object; but where the await
keyword was used within functionBody
, an async function
object. (See async mode below.)
// Create a regular function - sum
let sum = ReflexFunction( 'a', 'b', 'return a + b;' );
// Call the returned sum function and log the result
console.log( sum( 10, 2 )[0] ); // 12
Functions returned by ReflexFunction
are standard functions that can have their own this binding at call time.
// Create a function - colorSwitch - that sets a DOM element's color
let colorSwitch = ReflexFunction( 'color', 'this.style.color = color;' );
// Call colorSwitch, with document.body as it's this binding
let element = document.body;
colorSwitch.call( element, 'red' );
But, where the this binding is undefined at call time, the this binding of the ReflexFunction itself is used. This lets us have a default this binding at creation time.
// Create the same colorSwitch, this time, with a this binding that can be used at call time
let element = document.body;
let colorSwitch = ReflexFunction.call( element, 'color', 'this.style.color = color;' );
// Call colorSwitch, without a this binding
colorSwitch( 'red' );
colorSwitch.call( undefined, 'red' );
// Call colorSwitch, with a different this binding
let h1Element = document.getElementById( 'h1' );
colorSwitch.call( h1Element, 'red' );
Reflex Functions can be used as an async function wherein the await
keyword is permitted within function body:
async function** calculate() {
// await expression
}
For the ReflexFunction
constructor, to be async is automatic where the function body has a top-level await
keyword:
// Create an async function - sum
let sum = ReflexFunction( 'a', 'b', 'return a + await b;' );
// Call the returned sum function and log the result
sum(10, 2).then(([ result, reflect ]) => {
console.log( result ); // 12
});
// Alternatively...
let [ result, reflect ] = await sum(10, 2);
console.log( result ); // 12
But it can also be forced into an async function programmatically - using the options.runtimeParams.async
parameter:
let sum = ReflexFunction( source, { runtimeParams: { async: true }, } );
The ReflexFunction.inspect()
static method provides a way to access certain compiler analysis of a specific ReflexFunction
object, along with some additional meta information.
This method accepts the specific ReflexFunction
object, optionally along with a property name of what to inspect, e.g. dependencies
:
// External dependency
globalThis.externalVar = 10;
// Initial run
let sum = ReflexFunction( `a`, `b`, `return a + b + externalVar;` );
const properties = ReflexFunction.inspect(sum);
console.log(properties);
Console
name | sideEffects | dependencies |
---|---|---|
null | false |
[ [a ], [b ], [externalVar ] ] |
const dependencies = ReflexFunction.inspect(sum, 'dependencies');
If the given ReflexFunction
object is an async function, .inspect()
will return a promise:
const properties = await ReflexFunction.inspect(sum);
Often, there will be dynamic paths and it would be impossible to determine via static analysis all possible resolutions of those paths. What .inspect()
does is to represent the dynamic segments in those paths as wildcard - Infinity
- segments; implying "any property here"!
Below, the dynamic path in the second statement...
function** demo() {
let profileProp = condition ? 'avatar_url_1' : 'avatar_url_2';
let avatarUrlLength = candidate.profile[ profileProp ].length;
}
...would resolve to the array path:
[ 'candidate', 'profile', Infinity, 'length' ];
These meta data become useful for tooling around Reflex Functions. For example, .inspect()
integrates well with the Observer API for automatically plumbing updates:
// External dependency
globalThis.baselineScore = 10;
// Person
const candidate = {
name: { first: 'John', last: 'Doe', },
score: 30,
**summary() {
console.log(`Candidate "${ this.name.first } ${ this.name.last }" scored "${ this.score + baselineScore }"!`);
},
}
// Initial call
let [ , reflect ] = candidate.summary();
Automatic plumbing using the Observer API:
ReflexFunction.inspect(candidate.summary, 'dependencies').forEach(path => {
let abortController;
if (path[0] === 'this') {
path = Observer.path(...path.slice(1)); // So ['this','name','first'] becomes ['name','first']
abortController = Observer.observe(candidate, path, mutation => {
reflect([ 'this', ...mutation.path ]); // Here ['name','first'] becomes ['this','name','first'] again
});
} else {
path = Observer.path(...path);
abortController = Observer.observe(globalThis, path, mutation => {
reflect(mutation.path);
});
}
});
Now, mutations from anywhere are automatically reflected:
candidate.name.first = 'Johny';
candidate.score = 40;
baselineScore = 5;
Try it using the polyfill
Because the double star notation isn't supported as-is in JavaScript, we would need to use the constructable form of Reflex Functions:
const candidate = {
name: { first: 'John', last: 'Doe', },
score: 30,
summary: ReflexFunction(`
console.log('Candidate "${ this.name.first } ${ this.name.last }" scored "${ this.score + baselineScore }"!');
`),
}
And for the Observer API polyfill, we would need to use the mutation methods in place of the above direct assignments:
Observer.set(candidate.name, 'first', 'Johny');
Observer.set(candidate, 'score', 40);
Observer.set(globalThis, 'baselineScore', 5);
All of the above is what's at play across implementations like Play UI PlayElement!
Note that, interestingly, the Observer API also has the concept of wildcard segments, and that makes it very intuitive when automatically plumbing updates!