UNPKG

@jvitela/recompute

Version:

Selector functions based on Observable and Computed values.

277 lines (235 loc) 7.96 kB
// From fast-memoize // See: https://github.com/caiogondim/fast-memoize.js/blob/master/src/index.js function isPrimitive(value) { return ( value == null || typeof value === 'number' || typeof value === 'boolean' // string is an unsafe primitive for our needs // see test for 'single argument type detection' // || typeof value === 'string' ); } const _UNDEFINED_ = Symbol('undefined'); const defaultSerialize = args => { if (args.length === 0) { return _UNDEFINED_; } if (args.length === 1 && isPrimitive(args[0])) { return `${args[0]}`; } const key = JSON.stringify(args); if (key.length >= 1024) { console.warn('Passing large objects as arguments to selectors might impact memory usage. Arguments:', args); } return key; }; const getObserverKey = (id, arg) => { if (arg === undefined) { return `${id}`; } if (isPrimitive(arg)) { return `${id}:${arg}`; } return `${id}:${JSON.stringify(arg)}`; } // Also from fast-memoize class DefaultCache { constructor() { this.cache = Object.create(null); } // has(key) { // return (key in this.cache); // } get(key) { return this.cache[key]; } set(key, value) { this.cache[key] = value; } clear() { this.cache = Object.create(null); } } class Computation { constructor() { this.observersList = []; // list of observers, used for fast iteration this.observersIdx = {}; // index by key, used to merge with other computation objects } addObserver(observer) { const key = getObserverKey(observer.id, observer.arg); this.observersIdx[key] = observer; this.observersList = Object.values(this.observersIdx); } mergeObservers(other) { // Fast merge using the indexes Object.assign( this.observersIdx, other.observersIdx ); // Regenerate the iteration list this.observersList = Object.values(this.observersIdx); } dependenciesChanged(state) { for (let i = 0, l = this.observersList.length; i < l; ++i) { const observer = this.observersList[i]; const newResult = observer.arg === undefined ? observer.resultFunc(state) : observer.resultFunc(state, observer.arg); if (! observer.isEqual(newResult, observer.result)) { return true; } } return false; } // For testing getObserversIds() { return Object.keys(this.observersIdx); } } const createDefaultCache = () => new DefaultCache(); const defaultEquals = (a, b) => a === b; class Observer { constructor(id, resultFunc, isEqual, ctx) { this.id = `${id}`; // ensure string so that Computation can index by Id this.resultFunc = resultFunc; this.isEqual = isEqual; this.ctx = ctx; } invoke() { if (arguments.length > 1) { throw new Error('Observer methods cannot be invoked with more than one argument'); } const arg = arguments.length ? arguments[0] : undefined; const result = arg !== undefined ? this.resultFunc(this.ctx.state, arg) : this.resultFunc(this.ctx.state); // Create a link between this observer and // the selectors calling it. for (let i = 0, l = Context.callStack.length; i < l; ++i) { Context.callStack[i].addObserver({ id: this.id, isEqual: this.isEqual, resultFunc: this.resultFunc, arg, result, }); } return result; } getProxy() { const proxy = this.invoke.bind(this); proxy.id = this.id; proxy.key = arg => getObserverKey(this.id, arg); return proxy; } } class Selector { constructor(computeFunc, cache, serialize, ctx) { this.recomputations = 0; this.computeFunc = computeFunc; this.cache = cache; this.serialize = serialize; this.ctx = ctx; } mock() { const cacheKey = this.serialize(arguments); const computation = new Computation(cacheKey); this.cache.set(cacheKey, computation); return { result(res) { computation.result = res; } }; } clearCache() { this.cache.clear(); } invoke() { const cacheKey = this.serialize(arguments); let computation = this.cache.get(cacheKey); if ( !computation || computation.dependenciesChanged(this.ctx.state) // dependencies didn't change ) { // if dependencies changed it means that the observed state changed // therefore we should invalidate the cache if (computation) { this.cache.clear(); } // always create a new computation before we recompute the result // so that we can untrack previous dependencies. computation = new Computation(cacheKey); // Compute new result Context.callStack.push(computation); try { computation.result = this.computeFunc.apply(null, arguments); } finally { Context.callStack.pop(); } // Store new computation in the cache this.cache.set(cacheKey, computation); ++this.recomputations; } // Share observer dependencies with the parent selectors. for (let i = 0, l = Context.callStack.length; i < l; ++i) { Context.callStack[i].mergeObservers(computation); } return computation.result; }; dependencies() { const cacheKey = this.serialize(arguments); const computation = this.cache.get(cacheKey); return computation ? computation.getObserversIds() : []; } getProxy(context) { const proxy = this.invoke.bind(this); proxy.dependencies = this.dependencies.bind(this); proxy.mock = this.mock.bind(this); proxy.clearCache = this.clearCache.bind(this); proxy.recomputations = () => this.recomputations; proxy.withState = state => { context.setState(state); return proxy; }; return proxy; } } class Context { constructor(initialState) { this.state = initialState; } createObserver(resultFunc, options = {}) { if (resultFunc.length > 2) { throw new Error('Observer methods cannot receive more than two arguments'); } const id = ++Context.numObservers; // const name = options.name || `observer-${id}`; const isEqual = options.isEqual || defaultEquals; const observer = new Observer(id, resultFunc, isEqual, this); return observer.getProxy(); } createSelector(computeFunc, options = {}) { const cache = options.cache || createDefaultCache(); const serialize = options.serialize || defaultSerialize; const selector = new Selector(computeFunc, cache, serialize, this); return selector.getProxy(this); } getProxy() { return { createObserver: this.createObserver.bind(this), createSelector: this.createSelector.bind(this), setState: newState => this.state = newState }; } } Context.callStack = []; Context.numObservers = 0; export const createContext = initialState => { const context = new Context(initialState); return context.getProxy(); }; const context = createContext(); export const createObserver = context.createObserver; export const createSelector = context.createSelector; export const setState = context.setState;