UNPKG

phlox

Version:

A frontend architecture that's easy to visualize

525 lines (474 loc) 16.2 kB
// 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);