Skip to content
Oxford Harrison edited this page Oct 28, 2023 · 2 revisions

Reflex Functions Docs

This is documentation for Reflex Functions v2.x

ProjectMotivationOverviewPolyfillDiscusionsIssues

Description

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!

Formal Syntax

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 }

Change Propagation

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:

Code showing reflex lines

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!

Heuristics

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;
}

Flow Control

Reflex Functions treat conditional expressions and loops as contract. And these are able to reflect updates to their dependencies in granular details.

Conditionals

"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:

Code showing reactive "if" statement

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:

Code showing reactive "switch" statement

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:

Code showing reactive "conditional" statements

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');

Fine-Grained Reflections Within Conditionals

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.)

Memoization

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!

Loops

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:

Code showing reactive "for" 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:

Code showing reactive "while" loops

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:

Code showing reactive "for" loops

Show Reflection
// Re-run whole loop
reflect('iteratee');

Fine-Grained Reflections Within Loops

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'.

Handling Labeled Break And Continue Statements

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

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);

Side Effects

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.

Code showing "side effects"

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.

API

ReflexFunction

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 );

Parameters

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.

Return Value

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

The this Binding

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' );

Async Mode

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 }, } );

ReflexFunction.inspect()

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);

Wildcard Segments

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' ];

Example Usecase

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!