-
-
Notifications
You must be signed in to change notification settings - Fork 2
Home
This is documentation for Quantum JS v3.x. (Looking for v2.x?)
Imperative programs are a set of instructions that "act" on data - with some declaring or modifying said data (e.g. "let", "set" and "delete" operations) and some simply relying on said data (e.g. "if" constructs and other control structures) Those datapoints are the singular basis for reactivity in Quantum programs!
Here, the individual instructions stay "sensitive" to their data dependencies with a view to "re-acting" on any given change! Reactivity is simply implicit with every data dependency!
For example:
With each instruction below, an update to a data dependency will trigger a "self re-evaluation", and for the instructions that modify state, said state ends up modified:
// "var1" and "var2" being the data dependencies in this case
// and "var3" being the end state
let var3 = var1 + var2;
// "var1" and "var2" being the data dependencies in this case
console.log(var1, var2);
// "var1" and "var2" being the data dependencies in this case
// and "var3" getting updated
var3 = new Component(var1, var2);
// "var1" and "var2" being the data dependencies in this case
var1.prop = 2;
delete var2.prop;
// "var1" and "var2" being the data dependencies in this case
// and "var3" getting updated
var3 = { prop1: var1, [var2]: 0 };
For control structures - conditionals and loops, we get equivalent behaviour with data dependencies that appear at the top-level area of the construct, while leaving those "nested" within the body area to function at the level of their respective expression or statement. Here, changes to these top-level dependencies will trigger a block-level update.
For example:
With each construct below, an update to a data dependency will cause the respective construct to automatically re-evaluate, thus getting the relevant branch of the construct re-run:
// "testExpr" being the data dependency in this case
if (testExpr) {
// Consequent branch
} else {
// Alternate branch
}
// "testExpr", "case1" and "case2" being the data dependencies in this case
switch(testExpr) {
case case1:
// Branch 1
break;
case case2:
// Branch 2
break;
}
More examples
// "var1" and "var2" being the data dependencies in this case
if (var1 && var2) {
// Consequent branch
} else {
// Alternate branch
}
// "var1" and "var2" being the data dependencies in this case
switch(var1) {
case 100:
// Branch 1
break;
case var2:
// Branch 2
break;
}
And in the case of loops, for each instruction below, an update to a data dependency will cause the respective construct to automatically re-evaluate, thus getting the loop re-run:
// "testExpr" being the data dependency in this case
while(testExpr) {
// Body
}
// "testExpr" being the data dependency in this case
do {
// Body
} while(testExpr);
// "testExpr" being the data dependency in this case
for ( /*initExpr*/; testExpr; /*updateExpr*/) {
// Body
}
// "iteratee" being the data dependency in this case
for (let val of iteratee) {
// Body
}
// "iteratee" being the data dependency in this case
for (let key in iteratee) {
// Body
}
More examples
// "var1" and "var2" being the data dependencies in this case
for (let count = var1; count < var2; count++) {
// Body
}
// "var1" and "var2" being the data dependencies in this case
for (const val of [var1, var2]) {
// Body
}
// "var1" being the data dependency in this case
for (const key in var1) {
// Body
}
Here, nested control structures simply follow the same behaviour as every other expression or statement nested within a block: being their own independent unit of reactivity!
For example:
Being itself a statement nested within a block, the child "if" construct below will function independently - but this time, for as long as that containing branch in the overall hierarchy remains active:
// Parent construct
if (testExpr1) {
// Consequent branch
} else {
// Alternate branch
// Child construct
if (testExpr2) {
// Consequent branch
} else {
// Alternate branch
}
}
See equivalent layout
// Parent construct
if (testExpr1) {
// Consequent branch
} else if (testExpr2) {
// Consequent branch
} else {
// Alternate branch
}
Generally, reactivity is based on changes happening on data dependencies, with each given dependency seen as one unit of change, regardless of data type or structure! For example, given the following data structure:
let document = { head: {}, body: {} };
the reference to document
below would be seen in terms of value, not of structure, and so, we stay sensitive to the document
variable itself, not its properties:
console.log(document);
while for the reference to document.body
below, we know to stay sensitive to both the document
variable itself and the referenced body
property, but not properties of body
:
console.log(document.body);
But, what about certain language constructs that work with given value in terms of structure? It becomes necessary here to be structure-sensitive.
Whereas destructuring assignments may appear as though seeing their right-hand side in terms of value, the runtime correctly understands them as syntax sugars for multiple path mappings to local variables, and thus works with given value at the granularity of said paths.
Thus, in the declaration below, a change to the path object.prop2
, for example, is reflected only by the prop2
subset of the declaration, and no re-evaluation ever happens to the whole statement:
let { prop1, prop2, prop3: prop3Alias } = object;
Thus, generally, destructuring assignments are as reactively granular as their standalone equivalents:
let prop1 = object.prop1;
let prop2 = object.prop2;
let prop3Alias = object.prop3;
Whereas "rest" assignments may appear as regular variable assignments, the runtime correctly understands them as syntax sugars for a map of an object (or portion of said object) and manages the relationship at the property level.
Here, rest elements are implemented, not in terms of a one-time copy of given portion of object, but in terms of a "live" map of said portion of object, such that below, an update to object.prop3
is still automatically reflected by the indirect target variable prop3Alias
:
let { prop1, ...rest } = object;
let prop2 = rest.prop2;
let prop3Alias = rest.prop3;
In fact, as object
eventually gets new properties, rest
also has same, and we as well have reactivity in tact with something like:
let prop4 = rest.prop4;
So, generally, rest elements are as reactively transparent as their direct equivalents:
let prop1 = object.prop1;
let prop2 = object.prop2;
let prop3Alias = object.prop3;
While the for ... of
and for ... in
loops can already auto-restart on a "data-dependency" update of given iteratee, they are additionaly sensitive to in-place mutations (additions and removals) on said iteratee and will statically reflect those! This lets us have "live" lists and all sorts of possibilities around that!
Below, subsequent additions to items
are reflected (as new "rounds") without restarting the loop:
let items = [ 'one', 'two', 'three' ];
for (let item of items) {
console.log(item);
}
items.push('four');
items.push('five', 'six');
And any deletions from `items` will also automatically be reflected
Here, the corresponding "round" in the loop is "deactivated", freeing up any relationships that may have been made on that round. Meanwhile any expression in the deactivated round that may have referenced the now undefined variable item
(in this case, the console.log()
expression) would have been triggered with undefined
before the round dies. This gives us an opportunity to remove associated DOM elements, for example.
Examples
Here are rudimentary live lists examples wherein every additions and deletions are automatically reflected in the UI.
For for ... of
loops, we detect deletions by testing item === undefined
!
let items = ['one', 'two', 'three'];
for (let item of items) {
let liElement = document.createElement('li');
if (item === undefined) {
liElement.remove();
continue;
}
liElement.innerHTML = item;
ulElement.append(liElement);
}
// Mutate items
items.push('four', 'five');
items.pop();
In practice...
...since the Observer API isn't yet native, the above mutations would need to happen via the Observer API:
Observer.proxy(items).push('four', 'five');
Observer.proxy(items).pop();
And for for ... in
loops, we detect deletions by testing key in object
!
let object = { one: 'one', two: 'two', three: 'three' };
for (let key in object) {
let liElement = document.createElement('li');
if (!(key in object)) {
liElement.remove();
continue;
}
liElement.innerHTML = key;
ulElement.append(liElement);
}
object.four = 'four';
object.five = 'five';
delete object.five;
In practice...
...since the Observer API isn't yet native, the above mutations would need to happen via the Observer API:
Observer.set(object, 'four', 'four');
Observer.set(object, 'five', 'five');
Observer.deleteProperty(object, 'five');
Or alternatively:
const $object = Observer.proxy(object);
$object.four = 'four';
$object.five = 'five';
delete $object.five;
Unlike traditional reactive systems that follow an event-based update model, Quantum programs follow a linear, control-flow-based update model!
In the former, updates are broadcast as events to arbitrary callback locations:
import { createSignal, createEffect } from 'solid-js';
// count
const [ operand1, setOperand1 ] = createSignal(5);
const [ operand2, setOperand2 ] = createSignal(5);
const [ operand3, setOperand3 ] = createSignal(5);
// Callback location 1
createEffect(() => {
console.log(operand1(), operand2(), operand3());
// (5, 5, 5) <---- on the initial run
// (5, 10, 5) <--- on the update below
});
// Update operand2
setOperand2(10); // Re-invokes callback location 1
// Callback location 2
createEffect(() => {
console.log(operand1(), operand2(), operand3());
// (5, 10, 5) <----- given the update above
});
But in contrast, in the equivalent Quantum program below, the update expression in the middle of the flow simply relies on the ongoing flow and ends up reflected at just the dependent expression downstream! And that upholds the behaviour of imperative programs:
let operand1 = 5;
let operand2 = 5;
let operand3 = 5;
console.log(operand1, operand2, operand3);
// (5, 5, 5) <----- on the initial flow
// Update operand2
operand2 = 10; // Point of change
// Reflection
console.log(operand1, operand2, operand3);
// (5, 10, 5) <----- given the update above
But should the update be made from outside the given flow, i.e. by an external event, then we have "reactivity"!
Here, updates trigger an artifical program flow involving just the dependents, and effective from the point of change:
let operand1 = 5;
let operand2 = 5;
let operand3 = 5;
// Reflection
console.log(operand1, operand2, operand3);
// (5, 5, 5) <----- on the initial flow
// (5, 10, 5) <---- on the external update below
// External event
setTimeout(() => {
// Update operand2
operand2 = 10;
}, 500);
// Reflection
console.log(operand1, operand2, operand3);
// (5, 5, 5) <----- on the initial flow
// (5, 10, 5) <---- on the external update above
This is to say: an update model that follows the same linear, deterministic flow of imperative programs!
But, there's also a cursor-level precision that happens with said flow at each dependent expression or statement! Here, the actual re-evaluation of the given dependent expressin or statement begins from the exact point of reference within said expression or statement!
For example, at each console.log()
expression above, the actual re-evaluation begins from just after the middle argument:
console.log(operand1, operand2, operand3);
└--->
Then it ends with the function call itself being made:
┌-<------------------------------┐
console.log(operand1, operand2, operand3); |
(5), (10), └-(5)--------┘
Now, only the operand3
reference was ever looked up again, because, being the target, operand2
statically received the value 10
, and being pre the target, operand1
was never visited, but had its value come from memo!
The significance of this level of precision becomes clearer especially where there's a cost to the resolution of said arguments:
Code
let operand1 = () => {
rebuildTheWorld();
console.log('Reading operand1');
return 5;
};
let operand2 = {
_value: 5,
get value() {
rebuildTheWorld();
console.log('Reading operand2');
return this._value;
},
set value(val) {
console.log('Setting operand2');
this._value = val;
},
};
let operand3 = () => {
rebuildTheWorld();
console.log('Reading operand3');
return 5;
};
console.log(operand1(), operand2.value, operand3());
Now, the update operation below should tell us which arguments are being re-evaluated:
operand2.value = 10;
Console
Setting operand2
Reading operand3
This is to say: a cursor-level precision that does no less and no more of what's needed to reflect an update! (The truest definition of "fine-grained", given its zero unnecessary recalculations/re-evaluations!)
Polyfill limitation here
This inline precision is yet to be attained by the current polyfill! But this is coming in the next major update!
The Observer API allows us to make batched updates, and Quantum programs are able to handle these updates in one event loop (the event loop within Quantum programs)! The significance of this can be seen in the demo below:
let object = {
prop1: 1,
prop2: 2,
};
function** program() {
console.log(object.prop1);
console.log(object.prop2);
console.log(object.prop1, object.prop2);
}
program();
Here, we, of course, get the following console output on the initial run:
1
2
1, 2
Now, if we updated prop1
and prop2
in two separate events:
Observer.set(object, 'prop1', 10);
Observer.set(object, 'prop2', 20);
then we get the following console output:
10
10, 2
20
10, 20
But, if we updated prop1
and prop2
in one batch:
Observer.set(object, {
prop2: 20,
prop1: 10,
});
then, we get:
10
20
10, 20
Here, concurrency doesn't only ensure one linear flow for multiple updates, it also helps us optimise update performance!
Good a thing, this concurrency support doesn't involve any microtask scheduling - wherein the paradign entirely changes from synchronous to asynchronous programming. Everything happens synchronously and let's you work asynchronously at will.
Quantum programs support everything JavaScript gives us for plotting program flow; i.e. the various control structures we have: conditionals and loops - with which, respectively, we get the flow of execution to either branch conditionally or loop continuously until a certain condition is met! And with reactivity in the picture, as seen above, we are able to do more: get the program to automatically take a different execution path as state changes invalidate the conditions for the initial execution path!
For example, in the "if" construct below, we ordinarily would need to re-invoke program()
in whole, in order to have a different execution path for when the condition (condition.value
) is flipped:
const condition = { value: true };
function program() {
buildTheWorld();
if (condition.value) {
console.log('Execution path: "consequent" branch.');
} else {
console.log('Execution path: "alternate" branch.');
}
}
program();
setTimeout(() => condition.value = false);
But that's potentially tons of manual work and overheads dramatically cut with a little dose of reactivity! And that's Quantum programs automatically managing their own flow for us!
Yet, the story of "state-sensitive" control flow wouldn't be complete without the other flow control mechanisms coming in: the return
, continue
, and break
statements!
The return
statement is an instruction to terminate execution within a running function and get control passed, optionally along with data, back to its caller. But while this spells the end of the story for regular functions, this is only another "instruction" for Quantum functions - on which we could also have reactivity! It's logical: state may still, for some valid reasons, change within a function post-return; and in that case...
-
State changes may invalidate the initial return value:
function program() { let returnValue = 0; setInterval(() => returnValue++); return returnValue; }
let returnValue = program();
and accounting for such changes (i.e. designing for "streaming returns") would ordinarily require a callback-based communication model:
Code
function program(callback) { let returnValue = 0; setInterval(() => { returnValue++; callback(returnValue); }); }
program((returnValue) => { // Handle });
-
For "early returns", state changes may invalidate the conditions for the given execution path:
const condition = { value: true }; function program() { if (condition.value) { return 'Execution path with "early return".'; } ...more code downstream return 'Execution path with "normal return".'; } program();
setTimeout(() => condition.value = false);
and as with control structures generally, we ordinarily would need to re-invoke
program()
in whole, in order to have a different execution path!
Quantum programs would simply statically reflect those changes in each case!
-
For "return values", the
return
instruction in Quantum programs is sensitive to changes on its given value and gets any subsequent update reflected on the program's output:function** program() { let returnValue = 0; setInterval(() => returnValue++); return returnValue; }
let state = program(); // Log initial return value console.log(state.value); // Log subsequent return value Observer.observe(state, 'value', e => { console.log(e.value); });
and we could go without the
Observer.observe()
part when working withstate.value
(being a "live" property) from within a Quantum program itself:Code
(function** () { let state = program(); // Log return value always console.log(state.value); })();
-
For "early returns", the idea remains the normal control flow behaviour in Quantum programs: automatically taking a different execution path as state changes invalidate the conditions for the initial execution path!
Thus, to whatever extent that the situation calls for "early returns", e.g. in flattening "if/else" constructs, game on! you get the same behaviour as when you use multiple nested "if/else" constructs!
Here, when the conditions for an "early return" changes, control will return to where it left off and proceed as normal with the code downstream:
if (!condition1) { return 'Condition 1 not met'; } ...more code downstream if (!condition2) { return 'Condition 2 not met'; } ...more code downstream if (condition3) { if (!condition3_1) { return 'Condition 3 met, but not 3.1'; } // Now, conditions 1, 2, 3, 3.1 have all been met. // We can even now do streaming values let returnValue = 0; setInterval(() => returnValue++); return returnValue; } // Condition 3 wasn't even met doSomething();
and the program's overall output -
state.value
- will continue to reflect the corresponding return value at any given point!
The continue
statement is an instruction to bail out of the active "round" of a loop and advance to its next round, or where a label is used, to the next round of the parent loop identified by the label:
parent: for (const val of iteratee) {
child: for (const key in val) {
if (condition1) {
continue; // or: continue child
}
...more code downstream
if (condition2) {
continue parent;
}
...more code downstream
}
}
This is, in many ways, like "early returns", but scoped to the individual rounds of a loop! So, can we have equivalent behaviour here between "continue" statements and "early returns"? Yes!
Here, when the initial conditions for a given continue statement changes, control will return to where it left off and proceed as normal with the code downstream!
If the continue statement had initially moved control out of child loop entirely to parent loop, thus, abandoning all subsequent rounds of child loop, said resumption will proceed until actual completion of said child loop, thus spanning everything that was initially abandoned!
The break
statement is an instruction to terminate the immediate running loop, or where a label is used, the parent loop identified by the label:
parent: for (const val of iteratee) {
child: for (const key in val) {
if (condition1) {
break; // or: break child
}
...more code downstream
if (condition2) {
break parent;
}
...more code downstream
}
}
This is, in many ways, like "early returns", but scoped to loops! So, again, can we have equivalent behaviour here between "break" statements and "early returns"? Yes!
Here, when the initial conditions for breaking a loop changes, control will return to where it left off and proceed as normal with the code downstream, and, afterwards, with the remaining rounds of the loop!
The following features let us do more with Quantum programs but are non-standard and may change in the future!
Whereas, standard function constructors have no provision for some params
option, Quantum JS APIs have a params
option - passed as the "last" argument that is of type object:
const params = {};
const sum = QuantumFunction(`a`, `b`, `
return a + b;
`, params);
const program = new QuantumModule(`
doSomething();
`, params);
This feature currently has many niche use cases around Quantum JS.
Using the params
option, it is possible to pass a virtual environment (env
) object into the program from which to resolve non-local variables, just before asking the global scope:
const params = {
env: { c: 5 },
};
const sum = QuantumFunction(`a`, `b`, `
return a + b + c;
`, params);
console.log(sum(5, 5)); // 15
If you passed an env
object to a QuantumScript instance specifically, then that becomes the "variable declaration" scope:
const params = {
env: { c: 5 },
};
const program = new QuantumScript(`
var d = 5;
var c = 10 // Error; already declared in env
`, params);
await program.execute();
console.log(params.env); // { c: 5, d: c }
And, if you passed the same `env` object to more than one QuantumScript instance, then variables across scripts are cross-referencable
const program1 = new QuantumScript(`
var e = 5;
var f = 10
`, params);
await program1.execute();
const program2 = new QuantumScript(`
console.log(e); // 5
var f = 20 // Error; already declared in env by a sibling script
`, params);
await program2.execute();
With Quantum Modules, we can already have "live" module exports:
const program1 = new QuantumModule(`
export let localVar = 0;
setInterval(() => localVar++, 500);
`);
const state = await program1.execute();
Observer.observe(state.exports, 'localVar', mutation => {
console.log(mutation.value); // 1, 2, 3, 4, etc.
});
Now, what if we could also have "live" module imports, such that reactivity is carried through the wire, as it were:
const program2 = new QuantumModule(`
import { localVar } from '...';
console.log(localVar); // 1, 2, 3, 4, etc.
`);
await program2.execute();
This is currently possible!
Here, the exporting script would need to be named (via params.exportNamespace
) for reference purposes:
const params = {
exportNamespace: '#module-1',
};
const program1 = new QuantumModule(`
export let localVar = 0;
setInterval(() => localVar++, 500);
`, params);
const state = await program1.execute();
And the importing script would reference that as import specifier:
const program2 = new QuantumModule(`
import { localVar } from '#module-1';
console.log(localVar); // 1, 2, 3, 4, etc.
`);
await program2.execute();
Here, the import resolution system looks up #module-1
first from an in-memory "hot" module registry and returns that if found, then, or otherwise, falls back to the normal import resolution system.
This feature is currently being explored at OOHTML for a document-scoped "hot" module system for Single Page Applications:
<html>
<head>
<!-- include the OOHTML polyfill here -->
</head>
<body>
<!-- exporting module -->
<script type="module" quantum id="module-1">
export let localVar = 0;
setInterval(() => localVar++, 500);
</script>
<main> <!-- <<- this could be the individual pages of an SPA, which goes in and out of the DOM -->
<!-- importing module -->
<script type="module" quantum>
import { localVar } from '#module-1'; // the ID of the module script above
console.log(localVar); // 1, 2, 3, 4, etc.
</script>
</main>
</body>
</html>
The idea here is to have modules exported somewhere at a parent location in the tree for use at child locations! The parent-child model helps ensure that the implied exporting script is in the DOM as at when importing script initiates an import!
Also, note that when a script leaves the DOM, its exports also become unavailable in the "hot" module registry.