UNPKG

phlox

Version:

A frontend architecture that's easy to visualize

316 lines (239 loc) 10.5 kB
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; #auto_require: srcramda {change, reduceO, isAffected, func, $, isNilOrEmpty, sf0, customError} = RE = require 'ramda-extras' #auto_require: ramda-extras [] = [] #auto_sugar qq = (f) -> console.log match(/return (.*);/, f.toString())[1], f() qqq = (f) -> console.log match(/return (.*);/, f.toString())[1], JSON.stringify(f(), null, 2) _ = (...xs) -> 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: -> runLifter: -> runInvoker: -> debug: Boolean report: () -> # report callback for logging , ({ui, queries, lifters, invokers, config}) -> return new Phlox {ui, queries, lifters, invokers, config} class Phlox constructor: ({ui, queries, lifters, invokers, config}) -> @ui = ui @state = {} @data = {} [qli, @noDepQueries, @noDepInvokers] = _prepare {ui, queries, lifters, invokers} [i, ql] = partition whereEq({type: 'invoker'}), qli @queriesAndLifters = ql @invokers = i @listeners = [] # TODO: try resolve q/l/i into listeners !! window.setTimeout (=> @_runNoDepQueriesAndLifters()), 50 # @initialUI = ui # @initialData = data @uiChanges = ui # initial ui is the initial change to spark everything off @dataChanges = {} @isRunning = false # @commitHistory = [] # optimization: cap this to X items in a smart way to keep index consistent @config = config @setCount = 0 @flushCount = 0 @isBlocked = false # @_flush() # initial flush window.requestAnimationFrame @_flush setUI: curry (delta, forceFlush=false) -> @setCount = @setCount + 1 undo = {} @ui = change.meta delta, @ui, undo, @uiChanges if forceFlush then @_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 = undefined, commitId = undefined) -> listener = if name then {deps, cb, name} else {deps, cb} @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 () => @listeners = without [listener], @listeners # unsub = () => @listeners = without [listener], @listeners # return [initialData, unsub] # getUDS: () -> return [{UI: @ui, Data: @data, State: @state}, @commitHistory.length] getUDS: () -> return {UI: @ui, Data: @data, State: @state} block: () -> @isBlocked = true unblock: () -> @isBlocked = false reset: (ui) -> # NOTE: not sure if this is a correct = full reset if !ui then throw new PE "initial ui needs to be an object, not #{ui}" # reset data for k,d of @data @_setData k, undefined # figure out the total delta needed for the reset totalDelta = {} for k, v of @ui if ui[k] then totalDelta[k] = ui[k] else totalDelta[k] = undefined @setUI totalDelta # re-run queries and lifters without dependencies window.setTimeout (=>@_runNoDepQueriesAndLifters()), 50 _setData: (key) -> curry (data, forceFlush=false) => @setCount = @setCount + 1 undo = {} delta = {[key]: always data} @data = change.meta delta, @data, undo, @dataChanges if forceFlush then @_flush() _flush: => if @isBlocked then return window.requestAnimationFrame @_flush if @flushCount > 0 && isEmpty(@uiChanges) && isEmpty(@dataChanges) then return window.requestAnimationFrame @_flush # if @flushCount > 0 && isEmpty(@uiChanges) && isEmpty(@dataChanges) then return # RUN setCount = @setCount # if flushCount == 0 # dataChangesBefore = @initialData # else dataChangesBefore = @dataChanges uiChanges = @uiChanges @config.report {ts: performance.now(), name: 'flush-start', uiChanges, dataChangesBefore, setCount} r0 = performance.now() @setCount = 0 time = {} ui = @ui @uiChanges = {} # reset so new uiChanges theoretically can happen during the run dataBefore = @data @dataChanges = {} # reset so new dataChanges theoretically can happen during the run ql0 = performance.now() [data, dataChanges, state, stateChanges, affected] = @_runQueriesAndLifters ui, uiChanges, dataBefore, dataChangesBefore time.ql = performance.now() - ql0 @data = change dataChanges, @data @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 = @_runInvokers ui, data, state, uiChanges, dataChanges, stateChanges time.i = performance.now() - i0 # LISTENERS l0 = performance.now() affectedListeners = @_runListeners ui, data, state, uiChanges, dataChanges, stateChanges time.lis = performance.now() - l0 time.tot = performance.now() - r0 @config.report {ts: r0, name: 'flush-end', uiChanges, dataChangesBefore, dataChanges, stateChanges, setCount, time, affected: merge affected, {invokers: affectedInvokers, listeners: affectedListeners}} @flushCount++ window.requestAnimationFrame @_flush _runQueriesAndLifters: (ui, uiChanges, dataBefore, dataChangesBefore) -> dataChanges = dataChangesBefore stateChanges = {} data = dataBefore state = @state affected = {queries: [], lifters: []} for x in @queriesAndLifters # 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 = @config.runQuery x, {UI: ui, Data: data, State: state}, @_setData x.key if clientRes != undefined data = change.meta {[x.key]: clientRes}, data, {}, dataChanges else affected.lifters.push x.key lifterRes = @config.runLifter x, {UI: ui, Data: data, State: state} if lifterRes != undefined state = change.meta {[x.key]: lifterRes}, state, {}, stateChanges return [data, dataChanges, state, stateChanges, affected] _runInvokers: (ui, data, state, uiChanges, dataChanges, stateChanges) -> affected = [] for i in @invokers if isAffected i.deps, {UI: uiChanges, Data: dataChanges, State: stateChanges} @config.runInvoker i, {UI: ui, Data: data, State: state} affected.push i return affected _runListeners: (ui, data, state, uiChanges, dataChanges, stateChanges) -> state = @state affected = [] for l in @listeners if isAffected l.deps, {UI: uiChanges, Data: dataChanges, State: stateChanges} l.cb {UI: ui, Data: data, State: state} affected.push l return affected _runNoDepQueriesAndLifters: () -> for q in @noDepQueries # run queries without dependencies # optimization: do this async instead to improve time to first paint clientRes = @config.runQuery q, {UI: {}, Data: {}, State: {}}, @_setData q.key if clientRes != undefined @_setData q.key, clientRes for i in @noDepInvokers # run invokers without dependencies # optimization: do this async instead to improve time to first paint @config.runInvoker i, {UI: {}, Data: {}, State: {}} ###### Utils _areResolved = (deps, resMap, level = 0) -> if level >= 2 then return true # {UI: {a: {a1}}} <-- we resolve to level a, not a1 for k,v of deps if ! has k, resMap then return false else if v != null && ! _areResolved v, resMap[k], level + 1 then return false return true _prepare = ({ui, queries, lifters, invokers}) -> toResolve = {} noDepQueries = [] noDepInvokers = [] # remove debug from ui ui = $ ui, flip(reduceO) {}, (acc, v, k) -> merge acc, {[replace /_debug$/, '', k]: v} _toQLI = (type, k, f) -> key = replace /_debug$/, '', k [UI, Data, State] = popsiql.utils.parseArguments f.toString() if has k, ui then 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' then throw new PE "#{type}/#{k} is missing dependencies" return qli resMap = {UI: ui, Data: {}, State: {}} for k,f of queries qli = _toQLI 'query', k, f if isEmpty qli.deps noDepQueries.push qli resMap.Data[qli.key] = 1 else toResolve[qli.key] = qli for k,f of lifters qli = _toQLI 'lifter', k, f toResolve[qli.key] = qli for k,f of invokers 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,o of toResolve if ! _areResolved o.deps, resMap then 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 d in toDelete delete toResolve[d] if lap++ > 20 console.error toResolve throw new PE "cannot resolve: #{sf0 values $ toResolve, map ({type, key}) -> type+'/'+key}" return [res, noDepQueries, noDepInvokers] module.exports._prepare = _prepare