phlox
Version:
A frontend architecture that's easy to visualize
525 lines (474 loc) • 16.2 kB
JavaScript
// Generated by CoffeeScript 2.4.1
(function() {
var $, PE, Phlox, RE, _, _areResolved, _prepare, always, change, curry, customError, find, flip, func, has, invoker, isAffected, isEmpty, isNilOrEmpty, map, match, merge, partition, pick, popsiql, qq, qqq, reduceO, reject, replace, sf0, type, values, whereEq, without;
always = require('ramda/es/always').default;
curry = require('ramda/es/curry').default;
find = require('ramda/es/find').default;
flip = require('ramda/es/flip').default;
has = require('ramda/es/has').default;
invoker = require('ramda/es/invoker').default;
isEmpty = require('ramda/es/isEmpty').default;
map = require('ramda/es/map').default;
match = require('ramda/es/match').default;
merge = require('ramda/es/merge').default;
partition = require('ramda/es/partition').default;
pick = require('ramda/es/pick').default;
reject = require('ramda/es/reject').default;
replace = require('ramda/es/replace').default;
type = require('ramda/es/type').default;
values = require('ramda/es/values').default;
whereEq = require('ramda/es/whereEq').default;
without = require('ramda/es/without').default;
({change, reduceO, isAffected, func, $, isNilOrEmpty, sf0, customError} = RE = require('ramda-extras')); //auto_require: ramda-extras
//auto_sugar
[];
qq = function(f) {
return console.log(match(/return (.*);/, f.toString())[1], f());
};
qqq = function(f) {
return console.log(match(/return (.*);/, f.toString())[1], JSON.stringify(f(), null, 2));
};
_ = function(...xs) {
return xs;
};
// TODO: Move parseArguments out of popsiql
popsiql = require('popsiql');
PE = customError('PhloxError');
module.exports = func({
ui: Object,
queries: Object,
lifters: Object,
invokers: Object,
config: {
runQuery: function() {},
runLifter: function() {},
runInvoker: function() {},
debug: Boolean,
report: function() {} // report callback for logging
}
}, function({ui, queries, lifters, invokers, config}) {
return new Phlox({ui, queries, lifters, invokers, config});
});
Phlox = (function() {
class Phlox {
constructor({ui, queries, lifters, invokers, config}) {
var i, ql, qli;
this._flush = this._flush.bind(this);
this.ui = ui;
this.state = {};
this.data = {};
[qli, this.noDepQueries, this.noDepInvokers] = _prepare({ui, queries, lifters, invokers});
[i, ql] = partition(whereEq({
type: 'invoker'
}), qli);
this.queriesAndLifters = ql;
this.invokers = i;
this.listeners = [];
// TODO: try resolve q/l/i into listeners !!
window.setTimeout((() => {
return this._runNoDepQueriesAndLifters();
}), 50);
// @initialUI = ui
// @initialData = data
this.uiChanges = ui; // initial ui is the initial change to spark everything off
this.dataChanges = {};
this.isRunning = false;
// @commitHistory = [] # optimization: cap this to X items in a smart way to keep index consistent
this.config = config;
this.setCount = 0;
this.flushCount = 0;
this.isBlocked = false;
// @_flush() # initial flush
window.requestAnimationFrame(this._flush);
}
// optimization 0: use changeM instead, reasoning: if views for some reason rerenders before flush, they'll partially get some new data, no problem with that?
// optimization 1: call data-only dependencies before flush (requires rethink of viewModels)
// optimization 2: move flush to WebWorker
sub(deps, cb, name = void 0, commitId = void 0) {
var listener;
listener = name ? {deps, cb, name} : {deps, cb};
this.listeners.push(listener);
// if ! isNil commitId
// for id in [commitId..@commitHistory.length]
// qq => _ name, id, isAffected deps, @commitHistory[id]
// if isAffected deps, @commitHistory[id]
// cb {UI: @ui, Data: @data, State: @state}
// break
// cb {UI: @ui, Data: @data, State: @state} # call first with current data
// initialData = {UI: @ui, Data: @data, State: @state}
return () => {
return this.listeners = without([listener], this.listeners);
};
}
// unsub = () => @listeners = without [listener], @listeners
// return [initialData, unsub]
// getUDS: () -> return [{UI: @ui, Data: @data, State: @state}, @commitHistory.length]
getUDS() {
return {
UI: this.ui,
Data: this.data,
State: this.state
};
}
block() {
return this.isBlocked = true;
}
unblock() {
return this.isBlocked = false;
}
reset(ui) {
var d, k, ref, ref1, totalDelta, v;
if (!ui) {
throw new PE(`initial ui needs to be an object, not ${ui}`);
}
ref = this.data;
// reset data
for (k in ref) {
d = ref[k];
this._setData(k, void 0);
}
// figure out the total delta needed for the reset
totalDelta = {};
ref1 = this.ui;
for (k in ref1) {
v = ref1[k];
if (ui[k]) {
totalDelta[k] = ui[k];
} else {
totalDelta[k] = void 0;
}
}
this.setUI(totalDelta);
// re-run queries and lifters without dependencies
return window.setTimeout((() => {
return this._runNoDepQueriesAndLifters();
}), 50);
}
_setData(key) {
return curry((data, forceFlush = false) => {
var delta, undo;
this.setCount = this.setCount + 1;
undo = {};
delta = {
[key]: always(data)
};
this.data = change.meta(delta, this.data, undo, this.dataChanges);
if (forceFlush) {
return this._flush();
}
});
}
_flush() {
var affected, affectedInvokers, affectedListeners, data, dataBefore, dataChanges, dataChangesBefore, i0, l0, ql0, r0, setCount, state, stateChanges, time, ui, uiChanges;
if (this.isBlocked) {
return window.requestAnimationFrame(this._flush);
}
if (this.flushCount > 0 && isEmpty(this.uiChanges) && isEmpty(this.dataChanges)) {
return window.requestAnimationFrame(this._flush);
}
// if @flushCount > 0 && isEmpty(@uiChanges) && isEmpty(@dataChanges) then return
// RUN
setCount = this.setCount;
// if flushCount == 0
// dataChangesBefore = @initialData
// else
dataChangesBefore = this.dataChanges;
uiChanges = this.uiChanges;
this.config.report({
ts: performance.now(),
name: 'flush-start',
uiChanges,
dataChangesBefore,
setCount
});
r0 = performance.now();
this.setCount = 0;
time = {};
ui = this.ui;
this.uiChanges = {}; // reset so new uiChanges theoretically can happen during the run
dataBefore = this.data;
this.dataChanges = {}; // reset so new dataChanges theoretically can happen during the run
ql0 = performance.now();
[data, dataChanges, state, stateChanges, affected] = this._runQueriesAndLifters(ui, uiChanges, dataBefore, dataChangesBefore);
time.ql = performance.now() - ql0;
this.data = change(dataChanges, this.data);
this.state = state;
time.r = performance.now() - r0;
// @config.report {ts: r0, name: 'run', uiChanges, dataChanges, stateChanges, setCount, time}
// if flushCount == 0
// @commitHistory.push {UI: uiChanges, Data: dataChanges, State: stateChanges}
// else @commitHistory.push {UI: uiChanges, Data: dataChanges, State: stateChanges}
i0 = performance.now();
affectedInvokers = this._runInvokers(ui, data, state, uiChanges, dataChanges, stateChanges);
time.i = performance.now() - i0;
// LISTENERS
l0 = performance.now();
affectedListeners = this._runListeners(ui, data, state, uiChanges, dataChanges, stateChanges);
time.lis = performance.now() - l0;
time.tot = performance.now() - r0;
this.config.report({
ts: r0,
name: 'flush-end',
uiChanges,
dataChangesBefore,
dataChanges,
stateChanges,
setCount,
time,
affected: merge(affected, {
invokers: affectedInvokers,
listeners: affectedListeners
})
});
this.flushCount++;
return window.requestAnimationFrame(this._flush);
}
_runQueriesAndLifters(ui, uiChanges, dataBefore, dataChangesBefore) {
var affected, clientRes, data, dataChanges, j, len, lifterRes, ref, state, stateChanges, x;
dataChanges = dataChangesBefore;
stateChanges = {};
data = dataBefore;
state = this.state;
affected = {
queries: [],
lifters: []
};
ref = this.queriesAndLifters;
for (j = 0, len = ref.length; j < len; j++) {
x = ref[j];
// qq -> x
// qq -> isAffected x.deps, {UI: uiChanges, Data: dataChanges, State: stateChanges}
if (isAffected(x.deps, {
UI: uiChanges,
Data: dataChanges,
State: stateChanges
})) {
if (x.type === 'query') {
affected.queries.push(x.key);
clientRes = this.config.runQuery(x, {
UI: ui,
Data: data,
State: state
}, this._setData(x.key));
if (clientRes !== void 0) {
data = change.meta({
[x.key]: clientRes
}, data, {}, dataChanges);
}
} else {
affected.lifters.push(x.key);
lifterRes = this.config.runLifter(x, {
UI: ui,
Data: data,
State: state
});
if (lifterRes !== void 0) {
state = change.meta({
[x.key]: lifterRes
}, state, {}, stateChanges);
}
}
}
}
return [data, dataChanges, state, stateChanges, affected];
}
_runInvokers(ui, data, state, uiChanges, dataChanges, stateChanges) {
var affected, i, j, len, ref;
affected = [];
ref = this.invokers;
for (j = 0, len = ref.length; j < len; j++) {
i = ref[j];
if (isAffected(i.deps, {
UI: uiChanges,
Data: dataChanges,
State: stateChanges
})) {
this.config.runInvoker(i, {
UI: ui,
Data: data,
State: state
});
affected.push(i);
}
}
return affected;
}
_runListeners(ui, data, state, uiChanges, dataChanges, stateChanges) {
var affected, j, l, len, ref;
state = this.state;
affected = [];
ref = this.listeners;
for (j = 0, len = ref.length; j < len; j++) {
l = ref[j];
if (isAffected(l.deps, {
UI: uiChanges,
Data: dataChanges,
State: stateChanges
})) {
l.cb({
UI: ui,
Data: data,
State: state
});
affected.push(l);
}
}
return affected;
}
_runNoDepQueriesAndLifters() {
var clientRes, i, j, len, len1, m, q, ref, ref1, results;
ref = this.noDepQueries;
// run queries without dependencies
for (j = 0, len = ref.length; j < len; j++) {
q = ref[j];
// optimization: do this async instead to improve time to first paint
clientRes = this.config.runQuery(q, {
UI: {},
Data: {},
State: {}
}, this._setData(q.key));
if (clientRes !== void 0) {
this._setData(q.key, clientRes);
}
}
ref1 = this.noDepInvokers;
// run invokers without dependencies
results = [];
for (m = 0, len1 = ref1.length; m < len1; m++) {
i = ref1[m];
// optimization: do this async instead to improve time to first paint
results.push(this.config.runInvoker(i, {
UI: {},
Data: {},
State: {}
}));
}
return results;
}
};
Phlox.prototype.setUI = curry(function(delta, forceFlush = false) {
var undo;
this.setCount = this.setCount + 1;
undo = {};
this.ui = change.meta(delta, this.ui, undo, this.uiChanges);
if (forceFlush) {
return this._flush();
}
});
return Phlox;
}).call(this);
//##### Utils
_areResolved = function(deps, resMap, level = 0) {
var k, v;
if (level >= 2) {
return true; // {UI: {a: {a1}}} <-- we resolve to level a, not a1
}
for (k in deps) {
v = deps[k];
if (!has(k, resMap)) {
return false;
} else if (v !== null && !_areResolved(v, resMap[k], level + 1)) {
return false;
}
}
return true;
};
_prepare = function({ui, queries, lifters, invokers}) {
var _toQLI, d, f, j, k, lap, len, noDepInvokers, noDepQueries, o, qli, res, resMap, toDelete, toResolve;
toResolve = {};
noDepQueries = [];
noDepInvokers = [];
// remove debug from ui
ui = $(ui, flip(reduceO)({}, function(acc, v, k) {
return merge(acc, {
[replace(/_debug$/, '', k)]: v
});
}));
_toQLI = function(type, k, f) {
var Data, State, UI, key, qli;
key = replace(/_debug$/, '', k);
[UI, Data, State] = popsiql.utils.parseArguments(f.toString());
if (has(k, ui)) {
throw new PE(`${type} '${key}' also exists in initial ui, pick a unique key`);
}
if (has(k, toResolve) || find(whereEq({key}), noDepQueries)) {
throw new PE(`'${key}' exists twice in queries/lifters/invokers`);
}
qli = {
type,
key,
f,
debug: key !== k,
deps: reject(isNilOrEmpty, {UI, Data, State})
};
if (isEmpty(qli.deps)) {
if (type === 'lifter') {
throw new PE(`${type}/${k} is missing dependencies`);
}
}
return qli;
};
resMap = {
UI: ui,
Data: {},
State: {}
};
for (k in queries) {
f = queries[k];
qli = _toQLI('query', k, f);
if (isEmpty(qli.deps)) {
noDepQueries.push(qli);
resMap.Data[qli.key] = 1;
} else {
toResolve[qli.key] = qli;
}
}
for (k in lifters) {
f = lifters[k];
qli = _toQLI('lifter', k, f);
toResolve[qli.key] = qli;
}
for (k in invokers) {
f = invokers[k];
qli = _toQLI('invoker', k, f);
if (isEmpty(qli.deps)) {
noDepInvokers.push(qli);
} else {
toResolve[qli.key] = qli;
}
}
res = [];
lap = 0;
while (!isEmpty(toResolve)) {
toDelete = [];
for (k in toResolve) {
o = toResolve[k];
if (!_areResolved(o.deps, resMap)) {
continue;
}
toDelete.push(k);
if (o.type === 'query') {
res.push(o);
resMap.Data[o.key] = 1;
} else if (o.type === 'lifter') {
res.push(o);
resMap.State[o.key] = 1;
} else if (o.type === 'invoker') {
res.push(o);
}
}
for (j = 0, len = toDelete.length; j < len; j++) {
d = toDelete[j];
delete toResolve[d];
}
if (lap++ > 20) {
console.error(toResolve);
throw new PE(`cannot resolve: ${sf0(values($(toResolve, map(function({type, key}) {
return type + '/' + key;
}))))}`);
}
}
return [res, noDepQueries, noDepInvokers];
};
module.exports._prepare = _prepare;
}).call(this);