diff --git a/src/getter.js b/src/getter.js index aece455..f4be2db 100644 --- a/src/getter.js +++ b/src/getter.js @@ -54,7 +54,7 @@ function getFlattenedDeps(getter, existing) { getDeps(getter).forEach(dep => { if (isKeyPath(dep)) { - set.add(List(dep)) + set.add(Immutable.List(dep)) } else if (isGetter(dep)) { set.union(getFlattenedDeps(dep)) } else { @@ -66,6 +66,45 @@ function getFlattenedDeps(getter, existing) { return existing.union(toAdd) } +/** + * Returns a set of deps that have been flattened and expanded + * expanded ex: ['store1', 'key1'] => [['store1'], ['store1', 'key1']] + * + * Note: returns a keypath as an Immutable.List(['store1', 'key1') + * @param {Getter} getter + * @param {Number} maxDepth + * @return {Immutable.Set} + */ +function getCanonicalKeypathDeps(getter, maxDepth) { + if (maxDepth === undefined) { + throw new Error('Must supply maxDepth argument') + } + + const cacheKey = `__storeDeps_${maxDepth}` + if (getter.hasOwnProperty(cacheKey)) { + return getter[cacheKey] + } + + const deps = Immutable.Set().withMutations(set => { + getFlattenedDeps(getter).forEach(keypath => { + if (keypath.size <= maxDepth) { + set.add(keypath) + } else { + set.add(keypath.slice(0, maxDepth)) + } + }) + }) + + Object.defineProperty(getter, cacheKey, { + enumerable: false, + configurable: false, + writable: false, + value: deps, + }) + + return deps +} + /** * @param {KeyPath} * @return {Getter} @@ -88,7 +127,6 @@ function getStoreDeps(getter) { } const storeDeps = getFlattenedDeps(getter) - .map(keyPath => keyPath.first()) .filter(x => !!x) @@ -106,6 +144,7 @@ export default { isGetter, getComputeFn, getFlattenedDeps, + getCanonicalKeypathDeps, getStoreDeps, getDeps, fromKeyPath, diff --git a/src/key-path.js b/src/key-path.js index 6cc3319..f8a677d 100644 --- a/src/key-path.js +++ b/src/key-path.js @@ -13,15 +13,3 @@ export function isKeyPath(toTest) { ) } -/** - * Checks if two keypaths are equal by value - * @param {KeyPath} a - * @param {KeyPath} a - * @return {Boolean} - */ -export function isEqual(a, b) { - const iA = Immutable.List(a) - const iB = Immutable.List(b) - - return Immutable.is(iA, iB) -} diff --git a/src/reactor.js b/src/reactor.js index 4b95a13..3050322 100644 --- a/src/reactor.js +++ b/src/reactor.js @@ -1,15 +1,15 @@ -import Immutable from 'immutable' +import { Map, is, Set } from 'immutable' import createReactMixin from './create-react-mixin' import * as fns from './reactor/fns' import { DefaultCache } from './reactor/cache' import { ConsoleGroupLogger } from './logging' import { isKeyPath } from './key-path' -import { isGetter } from './getter' +import { isGetter, getCanonicalKeypathDeps } from './getter' import { toJS } from './immutable-helpers' import { extend, toFactory } from './utils' +import ObserverState from './reactor/observer-state' import { ReactorState, - ObserverState, DEBUG_OPTIONS, PROD_OPTIONS, } from './reactor/records' @@ -60,8 +60,22 @@ class Reactor { * @return {*} */ evaluate(keyPathOrGetter) { - let { result, reactorState } = fns.evaluate(this.reactorState, keyPathOrGetter) - this.reactorState = reactorState + let result + + this.reactorState = this.reactorState.withMutations(reactorState => { + if (!isKeyPath(keyPathOrGetter)) { + // look through the keypathStates and see if any of the getters dependencies are dirty, if so resolve + // against the previous reactor state + const maxCacheDepth = fns.getOption(reactorState, 'maxCacheDepth') + fns.resolveDirtyKeypathStates( + this.prevReactorState, + reactorState, + getCanonicalKeypathDeps(keyPathOrGetter, maxCacheDepth) + ) + } + result = fns.evaluate(reactorState, keyPathOrGetter) + }) + return result } @@ -95,10 +109,9 @@ class Reactor { handler = getter getter = [] } - let { observerState, entry } = fns.addObserver(this.observerState, getter, handler) - this.observerState = observerState + const entry = this.observerState.addObserver(this.reactorState, getter, handler) return () => { - this.observerState = fns.removeObserverByEntry(this.observerState, entry) + this.observerState.removeObserverByEntry(this.reactorState, entry) } } @@ -110,7 +123,7 @@ class Reactor { throw new Error('Must call unobserve with a Getter') } - this.observerState = fns.removeObserver(this.observerState, getter, handler) + this.observerState.removeObserver(this.reactorState, getter, handler) } /** @@ -130,6 +143,7 @@ class Reactor { } try { + this.prevReactorState = this.reactorState this.reactorState = fns.dispatch(this.reactorState, actionType, payload) } catch (e) { this.__isDispatching = false @@ -171,6 +185,7 @@ class Reactor { * @param {Object} stores */ registerStores(stores) { + this.prevReactorState = this.reactorState this.reactorState = fns.registerStores(this.reactorState, stores) this.__notify() } @@ -196,6 +211,7 @@ class Reactor { * @param {Object} state */ loadState(state) { + this.prevReactorState = this.reactorState this.reactorState = fns.loadState(this.reactorState, state) this.__notify() } @@ -220,59 +236,47 @@ class Reactor { return } - const dirtyStores = this.reactorState.get('dirtyStores') - if (dirtyStores.size === 0) { - return - } + this.prevReactorState = this.prevReactorState.asMutable() + this.reactorState = this.reactorState.asMutable() fns.getLoggerFunction(this.reactorState, 'notifyStart')(this.reactorState, this.observerState) - let observerIdsToNotify = Immutable.Set().withMutations(set => { - // notify all observers - set.union(this.observerState.get('any')) + const keypathsToResolve = this.observerState.getTrackedKeypaths() + const changedKeypaths = fns.resolveDirtyKeypathStates( + this.prevReactorState, + this.reactorState, + keypathsToResolve, + true // increment all dirty states (this should leave no unknown state in the keypath tracker map): + ) - dirtyStores.forEach(id => { - const entries = this.observerState.getIn(['stores', id]) - if (!entries) { - return - } - set.union(entries) - }) - }) + // get observers to notify based on the keypaths that changed + const observersToNotify = this.observerState.getObserversToNotify(changedKeypaths) - observerIdsToNotify.forEach((observerId) => { - const entry = this.observerState.getIn(['observersMap', observerId]) - if (!entry) { - // don't notify here in the case a handler called unobserve on another observer + observersToNotify.forEach((observer) => { + if (!this.observerState.hasObserver(observer)) { + // the observer was removed in a hander function return } let didCall = false - const getter = entry.get('getter') - const handler = entry.get('handler') + const getter = observer.get('getter') + const handler = observer.get('handler') fns.getLoggerFunction(this.reactorState, 'notifyEvaluateStart')(this.reactorState, getter) - const prevEvaluateResult = fns.evaluate(this.prevReactorState, getter) - const currEvaluateResult = fns.evaluate(this.reactorState, getter) - - this.prevReactorState = prevEvaluateResult.reactorState - this.reactorState = currEvaluateResult.reactorState + const prevValue = fns.evaluate(this.prevReactorState, getter) + const currValue = fns.evaluate(this.reactorState, getter) - const prevValue = prevEvaluateResult.result - const currValue = currEvaluateResult.result - - if (!Immutable.is(prevValue, currValue)) { + // TODO(jordan) pull some comparator function out of the reactorState + if (!is(prevValue, currValue)) { handler.call(null, currValue) didCall = true } fns.getLoggerFunction(this.reactorState, 'notifyEvaluateEnd')(this.reactorState, getter, didCall, currValue) }) - const nextReactorState = fns.resetDirtyStores(this.reactorState) - - this.prevReactorState = nextReactorState - this.reactorState = nextReactorState + this.prevReactorState = this.prevReactorState.asImmutable() + this.reactorState = this.reactorState.asImmutable() fns.getLoggerFunction(this.reactorState, 'notifyEnd')(this.reactorState, this.observerState) } diff --git a/src/reactor/cache.js b/src/reactor/cache.js index d202631..85c88cb 100644 --- a/src/reactor/cache.js +++ b/src/reactor/cache.js @@ -2,7 +2,7 @@ import { Map, OrderedSet, Record } from 'immutable' export const CacheEntry = Record({ value: null, - storeStates: Map(), + states: Map(), dispatchId: null, }) @@ -93,6 +93,25 @@ export class BasicCache { evict(item) { return new BasicCache(this.cache.remove(item)) } + + /** + * Removes entry from cache + * @param {Iterable} items + * @return {BasicCache} + */ + evictMany(items) { + const newCache = this.cache.withMutations(c => { + items.forEach(item => { + c.remove(item) + }) + }) + + return new BasicCache(newCache) + } + + empty() { + return new BasicCache() + } } const DEFAULT_LRU_LIMIT = 1000 @@ -173,15 +192,12 @@ export class LRUCache { ) } - const cache = (this.lru - .take(this.evictCount) - .reduce((c, evictItem) => c.evict(evictItem), this.cache) - .miss(item, entry)) + const itemsToRemove = this.lru.take(this.evictCount) lruCache = new LRUCache( this.limit, this.evictCount, - cache, + this.cache.evictMany(itemsToRemove).miss(item, entry), this.lru.skip(this.evictCount).add(item) ) } else { @@ -212,6 +228,15 @@ export class LRUCache { this.lru.remove(item) ) } + + empty() { + return new LRUCache( + this.limit, + this.evictCount, + this.cache.empty(), + OrderedSet() + ) + } } /** diff --git a/src/reactor/fns.js b/src/reactor/fns.js index 27733ef..8ae3e02 100644 --- a/src/reactor/fns.js +++ b/src/reactor/fns.js @@ -2,22 +2,11 @@ import Immutable from 'immutable' import { CacheEntry } from './cache' import { isImmutableValue } from '../immutable-helpers' import { toImmutable } from '../immutable-helpers' -import { fromKeyPath, getStoreDeps, getComputeFn, getDeps, isGetter } from '../getter' +import { fromKeyPath, getStoreDeps, getComputeFn, getDeps, isGetter, getCanonicalKeypathDeps } from '../getter' import { isEqual, isKeyPath } from '../key-path' +import * as KeypathTracker from './keypath-tracker' import { each } from '../utils' -/** - * Immutable Types - */ -const EvaluateResult = Immutable.Record({ result: null, reactorState: null}) - -function evaluateResult(result, reactorState) { - return new EvaluateResult({ - result: result, - reactorState: reactorState, - }) -} - /** * @param {ReactorState} reactorState * @param {Object} stores @@ -44,8 +33,9 @@ export function registerStores(reactorState, stores) { reactorState .update('stores', stores => stores.set(id, store)) .update('state', state => state.set(id, initialState)) - .update('dirtyStores', state => state.add(id)) - .update('storeStates', storeStates => incrementStoreStates(storeStates, [id])) + .update('keypathStates', keypathStates => { + return KeypathTracker.changed(keypathStates, [id]) + }) }) incrementId(reactorState) }) @@ -78,7 +68,7 @@ export function dispatch(reactorState, actionType, payload) { } const currState = reactorState.get('state') - let dirtyStores = reactorState.get('dirtyStores') + let dirtyStores = [] const nextState = currState.withMutations(state => { getLoggerFunction(reactorState, 'dispatchStart')(reactorState, actionType, payload) @@ -106,17 +96,20 @@ export function dispatch(reactorState, actionType, payload) { if (currState !== newState) { // if the store state changed add store to list of dirty stores - dirtyStores = dirtyStores.add(id) + dirtyStores.push(id) } }) - getLoggerFunction(reactorState, 'dispatchEnd')(reactorState, state, dirtyStores, currState) + getLoggerFunction(reactorState, 'dispatchEnd')(reactorState, state, toImmutable(dirtyStores), currState) }) const nextReactorState = reactorState .set('state', nextState) - .set('dirtyStores', dirtyStores) - .update('storeStates', storeStates => incrementStoreStates(storeStates, dirtyStores)) + .update('keypathStates', k => k.withMutations(keypathStates => { + dirtyStores.forEach(storeId => { + KeypathTracker.changed(keypathStates, [storeId]) + }) + })) return incrementId(nextReactorState) } @@ -127,85 +120,31 @@ export function dispatch(reactorState, actionType, payload) { * @return {ReactorState} */ export function loadState(reactorState, state) { - let dirtyStores = [] - const stateToLoad = toImmutable({}).withMutations(stateToLoad => { + reactorState = reactorState.asMutable() + let dirtyStores = Immutable.Set().asMutable() + + const stateToLoad = Immutable.Map({}).withMutations(stateToLoad => { each(state, (serializedStoreState, storeId) => { const store = reactorState.getIn(['stores', storeId]) if (store) { const storeState = store.deserialize(serializedStoreState) if (storeState !== undefined) { stateToLoad.set(storeId, storeState) - dirtyStores.push(storeId) + dirtyStores.add(storeId) } } }) }) - const dirtyStoresSet = Immutable.Set(dirtyStores) - return reactorState + reactorState .update('state', state => state.merge(stateToLoad)) - .update('dirtyStores', stores => stores.union(dirtyStoresSet)) - .update('storeStates', storeStates => incrementStoreStates(storeStates, dirtyStores)) -} - -/** - * Adds a change observer whenever a certain part of the reactor state changes - * - * 1. observe(handlerFn) - 1 argument, called anytime reactor.state changes - * 2. observe(keyPath, handlerFn) same as above - * 3. observe(getter, handlerFn) called whenever any getter dependencies change with - * the value of the getter - * - * Adds a change handler whenever certain deps change - * If only one argument is passed invoked the handler whenever - * the reactor state changes - * - * @param {ObserverState} observerState - * @param {KeyPath|Getter} getter - * @param {function} handler - * @return {ObserveResult} - */ -export function addObserver(observerState, getter, handler) { - // use the passed in getter as the key so we can rely on a byreference call for unobserve - const getterKey = getter - if (isKeyPath(getter)) { - getter = fromKeyPath(getter) - } - - const currId = observerState.get('nextId') - const storeDeps = getStoreDeps(getter) - const entry = Immutable.Map({ - id: currId, - storeDeps: storeDeps, - getterKey: getterKey, - getter: getter, - handler: handler, - }) - - let updatedObserverState - if (storeDeps.size === 0) { - // no storeDeps means the observer is dependent on any of the state changing - updatedObserverState = observerState.update('any', observerIds => observerIds.add(currId)) - } else { - updatedObserverState = observerState.withMutations(map => { - storeDeps.forEach(storeId => { - let path = ['stores', storeId] - if (!map.hasIn(path)) { - map.setIn(path, Immutable.Set()) - } - map.updateIn(['stores', storeId], observerIds => observerIds.add(currId)) + .update('keypathStates', k => k.withMutations(keypathStates => { + dirtyStores.forEach(storeId => { + KeypathTracker.changed(keypathStates, [storeId]) }) - }) - } + })) - updatedObserverState = updatedObserverState - .set('nextId', currId + 1) - .setIn(['observersMap', currId], entry) - - return { - observerState: updatedObserverState, - entry: entry, - } + return reactorState.asImmutable() } /** @@ -222,130 +161,106 @@ export function getOption(reactorState, option) { } /** - * Use cases - * removeObserver(observerState, []) - * removeObserver(observerState, [], handler) - * removeObserver(observerState, ['keyPath']) - * removeObserver(observerState, ['keyPath'], handler) - * removeObserver(observerState, getter) - * removeObserver(observerState, getter, handler) - * @param {ObserverState} observerState - * @param {KeyPath|Getter} getter - * @param {Function} handler - * @return {ObserverState} + * @param {ReactorState} reactorState + * @return {ReactorState} */ -export function removeObserver(observerState, getter, handler) { - const entriesToRemove = observerState.get('observersMap').filter(entry => { - // use the getterKey in the case of a keyPath is transformed to a getter in addObserver - let entryGetter = entry.get('getterKey') - let handlersMatch = (!handler || entry.get('handler') === handler) - if (!handlersMatch) { - return false - } - // check for a by-value equality of keypaths - if (isKeyPath(getter) && isKeyPath(entryGetter)) { - return isEqual(getter, entryGetter) - } - // we are comparing two getters do it by reference - return (getter === entryGetter) - }) - - return observerState.withMutations(map => { - entriesToRemove.forEach(entry => removeObserverByEntry(map, entry)) - }) -} +export function reset(reactorState) { + const storeMap = reactorState.get('stores') -/** - * Removes an observer entry by id from the observerState - * @param {ObserverState} observerState - * @param {Immutable.Map} entry - * @return {ObserverState} - */ -export function removeObserverByEntry(observerState, entry) { - return observerState.withMutations(map => { - const id = entry.get('id') - const storeDeps = entry.get('storeDeps') - - if (storeDeps.size === 0) { - map.update('any', anyObsevers => anyObsevers.remove(id)) - } else { - storeDeps.forEach(storeId => { - map.updateIn(['stores', storeId], observers => { - if (observers) { - // check for observers being present because reactor.reset() can be called before an unwatch fn - return observers.remove(id) - } - return observers - }) + return reactorState.withMutations(reactorState => { + // update state + reactorState.update('state', s => s.withMutations(state => { + storeMap.forEach((store, id) => { + const storeState = state.get(id) + const resetStoreState = store.handleReset(storeState) + if (resetStoreState === undefined && getOption(reactorState, 'throwOnUndefinedStoreReturnValue')) { + throw new Error('Store handleReset() must return a value, did you forget a return statement') + } + if (getOption(reactorState, 'throwOnNonImmutableStore') && !isImmutableValue(resetStoreState)) { + throw new Error('Store reset state must be an immutable value, did you forget to call toImmutable') + } + state.set(id, resetStoreState) }) - } + })) - map.removeIn(['observersMap', id]) + reactorState.set('keypathStates', new KeypathTracker.RootNode()) + reactorState.set('dispatchId', 1) + reactorState.update('cache', cache => cache.empty()) }) } /** - * @param {ReactorState} reactorState - * @return {ReactorState} + * @param {ReactorState} prevReactorState + * @param {ReactorState} currReactorState + * @param {Array} keyPathOrGetter + * @return {Object} */ -export function reset(reactorState) { - const prevState = reactorState.get('state') +export function resolveDirtyKeypathStates(prevReactorState, currReactorState, keypaths, cleanAll = false) { + const prevState = prevReactorState.get('state') + const currState = currReactorState.get('state') - return reactorState.withMutations(reactorState => { - const storeMap = reactorState.get('stores') - const storeIds = storeMap.keySeq().toJS() - storeMap.forEach((store, id) => { - const storeState = prevState.get(id) - const resetStoreState = store.handleReset(storeState) - if (resetStoreState === undefined && getOption(reactorState, 'throwOnUndefinedStoreReturnValue')) { - throw new Error('Store handleReset() must return a value, did you forget a return statement') + // TODO(jordan): allow store define a comparator function + function equals(a, b) { + return Immutable.is(a, b) + } + + let changedKeypaths = []; + + currReactorState.update('keypathStates', k => k.withMutations(keypathStates => { + keypaths.forEach(keypath => { + if (KeypathTracker.isClean(keypathStates, keypath)) { + return } - if (getOption(reactorState, 'throwOnNonImmutableStore') && !isImmutableValue(resetStoreState)) { - throw new Error('Store reset state must be an immutable value, did you forget to call toImmutable') + + if (equals(prevState.getIn(keypath), currState.getIn(keypath))) { + KeypathTracker.unchanged(keypathStates, keypath) + } else { + KeypathTracker.changed(keypathStates, keypath) + changedKeypaths.push(keypath) } - reactorState.setIn(['state', id], resetStoreState) }) - reactorState.update('storeStates', storeStates => incrementStoreStates(storeStates, storeIds)) - resetDirtyStores(reactorState) - }) + if (cleanAll) { + // TODO(jordan): this can probably be a single traversal + KeypathTracker.incrementAndClean(keypathStates) + } + })) + + return changedKeypaths } /** + * This function must be called with mutable reactorState for performance reasons * @param {ReactorState} reactorState * @param {KeyPath|Gettter} keyPathOrGetter - * @return {EvaluateResult} + * @return {*} */ export function evaluate(reactorState, keyPathOrGetter) { const state = reactorState.get('state') if (isKeyPath(keyPathOrGetter)) { // if its a keyPath simply return - return evaluateResult( - state.getIn(keyPathOrGetter), - reactorState - ) + return state.getIn(keyPathOrGetter); } else if (!isGetter(keyPathOrGetter)) { throw new Error('evaluate must be passed a keyPath or Getter') } - // Must be a Getter const cache = reactorState.get('cache') - var cacheEntry = cache.lookup(keyPathOrGetter) + let cacheEntry = cache.lookup(keyPathOrGetter) const isCacheMiss = !cacheEntry || isDirtyCacheEntry(reactorState, cacheEntry) if (isCacheMiss) { cacheEntry = createCacheEntry(reactorState, keyPathOrGetter) } - return evaluateResult( - cacheEntry.get('value'), - reactorState.update('cache', cache => { - return isCacheMiss ? - cache.miss(keyPathOrGetter, cacheEntry) : - cache.hit(keyPathOrGetter) - }) - ) + // TODO(jordan): respect the Getter's `shouldCache` setting + reactorState.update('cache', cache => { + return isCacheMiss + ? cache.miss(keyPathOrGetter, cacheEntry) + : cache.hit(keyPathOrGetter) + }) + + return cacheEntry.get('value') } /** @@ -365,15 +280,6 @@ export function serialize(reactorState) { return serialized } -/** - * Returns serialized state for all stores - * @param {ReactorState} reactorState - * @return {ReactorState} - */ -export function resetDirtyStores(reactorState) { - return reactorState.set('dirtyStores', Immutable.Set()) -} - export function getLoggerFunction(reactorState, fnName) { const logger = reactorState.get('logger') if (!logger) { @@ -391,11 +297,15 @@ export function getLoggerFunction(reactorState, fnName) { * @return {boolean} */ function isDirtyCacheEntry(reactorState, cacheEntry) { - const storeStates = cacheEntry.get('storeStates') + if (reactorState.get('dispatchId') === cacheEntry.get('dispatchId')) { + return false + } - // if there are no store states for this entry then it was never cached before - return !storeStates.size || storeStates.some((stateId, storeId) => { - return reactorState.getIn(['storeStates', storeId]) !== stateId + const cacheStates = cacheEntry.get('states') + const keypathStates = reactorState.get('keypathStates') + + return cacheEntry.get('states').some((value, keypath) => { + return !KeypathTracker.isEqual(keypathStates, keypath, value) }) } @@ -407,20 +317,30 @@ function isDirtyCacheEntry(reactorState, cacheEntry) { */ function createCacheEntry(reactorState, getter) { // evaluate dependencies - const args = getDeps(getter).map(dep => evaluate(reactorState, dep).result) + const args = getDeps(getter).reduce((memo, dep) => { + memo.push(evaluate(reactorState, dep)) + return memo + }, []) + const value = getComputeFn(getter).apply(null, args) - const storeDeps = getStoreDeps(getter) - const storeStates = toImmutable({}).withMutations(map => { - storeDeps.forEach(storeId => { - const stateId = reactorState.getIn(['storeStates', storeId]) - map.set(storeId, stateId) + const maxCacheDepth = getOption(reactorState, 'maxCacheDepth') + const keypathDeps = getCanonicalKeypathDeps(getter, maxCacheDepth) + const keypathStates = reactorState.get('keypathStates') + + const cacheStates = Immutable.Map({}).withMutations(map => { + keypathDeps.forEach(keypath => { + const keypathState = KeypathTracker.get(keypathStates, keypath) + // The -1 case happens when evaluating soemthing against a previous reactorState + // where the getter's keypaths were never registered and the old keypathState is undefined + // for particular keypaths, this shouldn't matter because we can cache hit by dispatchId + map.set(keypath, keypathState ? keypathState : -1) }) }) return CacheEntry({ - value: value, - storeStates: storeStates, + value, + states: cacheStates, dispatchId: reactorState.get('dispatchId'), }) } @@ -433,19 +353,4 @@ function incrementId(reactorState) { return reactorState.update('dispatchId', id => id + 1) } - -/** - * @param {Immutable.Map} storeStates - * @param {Array} storeIds - * @return {Immutable.Map} - */ -function incrementStoreStates(storeStates, storeIds) { - return storeStates.withMutations(map => { - storeIds.forEach(id => { - const nextId = map.has(id) ? map.get(id) + 1 : 1 - map.set(id, nextId) - }) - }) -} - function noop() {} diff --git a/src/reactor/keypath-tracker.js b/src/reactor/keypath-tracker.js new file mode 100644 index 0000000..ed0848b --- /dev/null +++ b/src/reactor/keypath-tracker.js @@ -0,0 +1,270 @@ +/** + * KeyPath Tracker + * + * St + * { + * entityCache: { + * status: 'CLEAN', + * k + * + */ +import { Map, Record, Set } from 'immutable' +import { toImmutable, toJS } from '../immutable-helpers' + +export const status = { + CLEAN: 0, + DIRTY: 1, + UNKNOWN: 2, +} + +export const RootNode = Record({ + status: status.CLEAN, + state: 1, + children: Map(), + changedPaths: Set(), +}) + +const Node = Record({ + status: status.CLEAN, + state: 1, + children: Map(), +}) + +/** + * Denotes that a keypath hasn't changed + * Makes the Node at the keypath as CLEAN and recursively marks the children as CLEAN + * @param {Immutable.Map} map + * @param {Keypath} keypath + * @return {Status} + */ +export function unchanged(map, keypath) { + const childKeypath = getChildKeypath(keypath) + if (!map.hasIn(childKeypath)) { + return map.update('children', children => recursiveRegister(children, keypath)) + } + + return map.updateIn(childKeypath, entry => { + return entry + .set('status', status.CLEAN) + .update('children', children => recursiveSetStatus(children, status.CLEAN)) + }) +} + +/** + * Denotes that a keypath has changed + * Traverses to the Node at the keypath and marks as DIRTY, marks all children as UNKNOWN + * @param {Immutable.Map} map + * @param {Keypath} keypath + * @return {Status} + */ +export function changed(map, keypath) { + const childrenKeypath = getChildKeypath(keypath).concat('children') + // TODO(jordan): can this be optimized + return map.withMutations(m => { + m.update('changedPaths', p => p.add(toImmutable(keypath))) + m.update('children', children => recursiveIncrement(children, keypath)) + // handle the root node + m.update('state', val => val + 1) + m.set('status', status.DIRTY) + m.updateIn(childrenKeypath, entry => recursiveSetStatus(entry, status.UNKNOWN)) + }) +} + +/** + * @param {Immutable.Map} map + * @param {Keypath} keypath + * @return {Status} + */ +export function isEqual(map, keypath, value) { + const entry = map.getIn(getChildKeypath(keypath)) + + if (!entry) { + return false; + } + if (entry.get('status') === status.UNKNOWN) { + return false + } + return entry.get('state') === value; +} + +function recursiveClean(map) { + if (map.size === 0) { + return map + } + + const rootStatus = map.get('status') + if (rootStatus === status.DIRTY) { + map = setClean(map) + } else if (rootStatus === status.UNKNOWN) { + map = setClean(increment(map)) + } + return map + .update('children', c => c.withMutations(m => { + m.keySeq().forEach(key => { + m.update(key, recursiveClean) + }) + })) +} + +/** + * Increments all unknown states and sets everything to CLEAN + * @param {Immutable.Map} map + * @return {Status} + */ +export function incrementAndClean(map) { + if (map.size === 0) { + return map + } + const changedPaths = map.get('changedPaths') + // TODO(jordan): can this be optimized + return map.withMutations(m => { + changedPaths.forEach(path => { + m.update('children', c => traverseAndMarkClean(c, path)) + }) + + m.set('changedPaths', Set()) + const rootStatus = m.get('status') + if (rootStatus === status.DIRTY) { + setClean(m) + } else if (rootStatus === status.UNKNOWN) { + setClean(increment(m)) + } + }) +} + +export function get(map, keypath) { + return map.getIn(getChildKeypath(keypath).concat('state')) +} + +export function isClean(map, keypath) { + return map.getIn(getChildKeypath(keypath).concat('status')) === status.CLEAN +} + +function increment(node) { + return node.update('state', val => val + 1) +} + +function setClean(node) { + return node.set('status', status.CLEAN) +} + +function setDirty(node) { + return node.set('status', status.DIRTY) +} + +function recursiveIncrement(map, keypath) { + keypath = toImmutable(keypath) + if (keypath.size === 0) { + return map + } + + return map.withMutations(map => { + const key = keypath.first() + const entry = map.get(key) + + if (!entry) { + map.set(key, new Node({ + status: status.DIRTY, + })) + } else { + map.update(key, node => setDirty(increment(node))) + } + + map.updateIn([key, 'children'], children => recursiveIncrement(children, keypath.rest())) + }) +} + +/** + * Traverses up to a keypath and marks all entries as clean along the way, then recursively traverses over all children + * @param {Immutable.Map} map + * @param {Immutable.List} keypath + * @return {Status} + */ +function traverseAndMarkClean(map, keypath) { + if (keypath.size === 0) { + return recursiveCleanChildren(map) + } + return map.withMutations(map => { + const key = keypath.first() + + map.update(key, incrementAndCleanNode) + map.updateIn([key, 'children'], children => traverseAndMarkClean(children, keypath.rest())) + }) +} + +function recursiveCleanChildren(children) { + if (children.size === 0) { + return children + } + + return children.withMutations(c => { + c.keySeq().forEach(key => { + c.update(key, incrementAndCleanNode) + c.updateIn([key, 'children'], recursiveCleanChildren) + }) + }) +} + +/** + * Takes a node, marks it CLEAN, if it was UNKNOWN it increments + * @param {Node} node + * @return {Status} + */ +function incrementAndCleanNode(node) { + const nodeStatus = node.get('status') + if (nodeStatus === status.DIRTY) { + return setClean(node) + } else if (nodeStatus === status.UNKNOWN) { + return setClean(increment(node)) + } + return node +} + +function recursiveRegister(map, keypath) { + keypath = toImmutable(keypath) + if (keypath.size === 0) { + return map + } + + return map.withMutations(map => { + const key = keypath.first() + const entry = map.get(key) + + if (!entry) { + map.set(key, new Node()) + } + map.updateIn([key, 'children'], children => recursiveRegister(children, keypath.rest())) + }) +} + +/** + * Turns ['foo', 'bar', 'baz'] -> ['foo', 'children', 'bar', 'children', 'baz'] + * @param {Keypath} keypath + * @return {Keypath} + */ +function getChildKeypath(keypath) { + // TODO(jordan): handle toJS more elegantly + keypath = toJS(keypath) + let ret = [] + for (var i = 0; i < keypath.length; i++) { + ret.push('children') + ret.push(keypath[i]) + } + return ret +} + +function recursiveSetStatus(map, status) { + if (map.size === 0) { + return map + } + + return map.withMutations(map => { + map.keySeq().forEach(key => { + return map.update(key, entry => { + return entry + .update('children', children => recursiveSetStatus(children, status)) + .set('status', status) + }) + }) + }) +} diff --git a/src/reactor/observer-state.js b/src/reactor/observer-state.js new file mode 100644 index 0000000..1e482ee --- /dev/null +++ b/src/reactor/observer-state.js @@ -0,0 +1,189 @@ +import { Map, List, Set } from 'immutable' +import { getOption } from './fns' +import { fromKeyPath, getDeps, isGetter, getCanonicalKeypathDeps } from '../getter' +import { toImmutable } from '../immutable-helpers' +import { isKeyPath } from '../key-path' + +export default class ObserverState { + constructor() { + /* + { + : Set + } + */ + this.keypathToEntries = Map({}).asMutable() + + /* + { + : { + : + } + } + */ + this.observersMap = Map({}).asMutable() + + this.trackedKeypaths = Set().asMutable() + + // keep a flat set of observers to know when one is removed during a handler + this.observers = Set().asMutable() + } + + /** + * Adds a change observer whenever a certain part of the reactor state changes + * + * 1. observe(handlerFn) - 1 argument, called anytime reactor.state changes + * 2. observe(keyPath, handlerFn) same as above + * 3. observe(getter, handlerFn) called whenever any getter dependencies change with + * the value of the getter + * + * Adds a change handler whenever certain deps change + * If only one argument is passed invoked the handler whenever + * the reactor state changes + * + * @param {ReactorState} reactorState + * @param {KeyPath|Getter} getter + * @param {function} handler + * @return {ObserveResult} + */ + addObserver(reactorState, getter, handler) { + // use the passed in getter as the key so we can rely on a byreference call for unobserve + const rawGetter = getter + if (isKeyPath(getter)) { + // TODO(jordan): add a `dontCache` flag here so we dont waste caching overhead on simple keypath lookups + getter = fromKeyPath(getter) + } + + const maxCacheDepth = getOption(reactorState, 'maxCacheDepth') + const keypathDeps = getCanonicalKeypathDeps(getter, maxCacheDepth) + const entry = Map({ + getter: getter, + handler: handler, + }) + + keypathDeps.forEach(keypath => { + if (!this.keypathToEntries.has(keypath)) { + this.keypathToEntries.set(keypath, Set().asMutable().add(entry)) + } else { + this.keypathToEntries.get(keypath).add(entry) + } + }) + + const getterKey = createGetterKey(getter); + + // union doesn't work with asMutable + this.trackedKeypaths = this.trackedKeypaths.union(keypathDeps) + this.observersMap.setIn([getterKey, handler], entry) + this.observers.add(entry) + + return entry + } + + /** + * Use cases + * removeObserver(observerState, []) + * removeObserver(observerState, [], handler) + * removeObserver(observerState, ['keyPath']) + * removeObserver(observerState, ['keyPath'], handler) + * removeObserver(observerState, getter) + * removeObserver(observerState, getter, handler) + * @param {ReactorState} reactorState + * @param {KeyPath|Getter} getter + * @param {Function} handler + * @return {ObserverState} + */ + removeObserver(reactorState, getter, handler) { + if (isKeyPath(getter)) { + getter = fromKeyPath(getter) + } + let entriesToRemove; + const getterKey = createGetterKey(getter) + const maxCacheDepth = getOption(reactorState, 'maxCacheDepth') + const keypathDeps = getCanonicalKeypathDeps(getter, maxCacheDepth) + + if (handler) { + entriesToRemove = List([ + this.observersMap.getIn([getterKey, handler]), + ]) + } else { + entriesToRemove = this.observersMap.get(getterKey, Map({})).toList() + } + + entriesToRemove.forEach(entry => { + this.removeObserverByEntry(reactorState, entry, keypathDeps) + }) + } + + /** + * Removes an observer entry + * @param {ReactorState} reactorState + * @param {Immutable.Map} entry + * @param {Immutable.List|null} keypathDeps + * @return {ObserverState} + */ + removeObserverByEntry(reactorState, entry, keypathDeps = null) { + const getter = entry.get('getter') + if (!keypathDeps) { + const maxCacheDepth = getOption(reactorState, 'maxCacheDepth') + keypathDeps = getCanonicalKeypathDeps(getter, maxCacheDepth) + } + + this.observers.remove(entry) + + // update the keypathToEntries + keypathDeps.forEach(keypath => { + const entries = this.keypathToEntries.get(keypath) + + if (entries) { + // check for observers being present because reactor.reset() can be called before an unwatch fn + entries.remove(entry) + if (entries.size === 0) { + this.keypathToEntries.remove(keypath) + this.trackedKeypaths.remove(keypath) + } + } + }) + + // remove entry from observersobserverState + const getterKey = createGetterKey(getter) + const handler = entry.get('handler') + + this.observersMap.removeIn([getterKey, handler]) + // protect against unwatch after reset + if (this.observersMap.has(getterKey) && + this.observersMap.get(getterKey).size === 0) { + this.observersMap.remove(getterKey) + } + } + + getTrackedKeypaths() { + return this.trackedKeypaths.asImmutable() + } + + /** + * @param {Immutable.List} changedKeypaths + * @return {Entries[]} + */ + getObserversToNotify(changedKeypaths) { + return Set().withMutations(set => { + changedKeypaths.forEach(keypath => { + const entries = this.keypathToEntries.get(keypath) + if (entries && entries.size > 0) { + set.union(entries) + } + }) + }) + } + + hasObserver(observer) { + return this.observers.has(observer) + } +} + +/** + * Creates an immutable key for a getter + * @param {Getter} getter + * @return {Immutable.List} + */ +function createGetterKey(getter) { + return toImmutable(getter) +} diff --git a/src/reactor/records.js b/src/reactor/records.js index 642f8fc..29d3368 100644 --- a/src/reactor/records.js +++ b/src/reactor/records.js @@ -1,5 +1,6 @@ import { Map, Set, Record } from 'immutable' import { DefaultCache } from './cache' +import { RootNode as KeypathTrackerNode } from './keypath-tracker' export const PROD_OPTIONS = Map({ // logs information for each dispatch @@ -16,6 +17,8 @@ export const PROD_OPTIONS = Map({ throwOnNonImmutableStore: false, // if true, throws when dispatching in dispatch throwOnDispatchInDispatch: false, + // how many levels deep should getter keypath dirty states be tracked + maxCacheDepth: 3, }) export const DEBUG_OPTIONS = Map({ @@ -33,6 +36,8 @@ export const DEBUG_OPTIONS = Map({ throwOnNonImmutableStore: true, // if true, throws when dispatching in dispatch throwOnDispatchInDispatch: true, + // how many levels deep should getter keypath dirty states be tracked + maxCacheDepth: 3, }) export const ReactorState = Record({ @@ -41,21 +46,9 @@ export const ReactorState = Record({ stores: Map(), cache: DefaultCache(), logger: {}, - // maintains a mapping of storeId => state id (monotomically increasing integer whenever store state changes) - storeStates: Map(), - dirtyStores: Set(), + keypathStates: new KeypathTrackerNode(), debug: false, // production defaults options: PROD_OPTIONS, }) -export const ObserverState = Record({ - // observers registered to any store change - any: Set(), - // observers registered to specific store changes - stores: Map({}), - - observersMap: Map({}), - - nextId: 1, -}) diff --git a/tests/getter-tests.js b/tests/getter-tests.js index 93d89f6..8eeb328 100644 --- a/tests/getter-tests.js +++ b/tests/getter-tests.js @@ -1,4 +1,4 @@ -import { isGetter, getFlattenedDeps, fromKeyPath } from '../src/getter' +import { isGetter, getFlattenedDeps, fromKeyPath, getCanonicalKeypathDeps } from '../src/getter' import { Set, List, is } from 'immutable' describe('Getter', () => { @@ -78,4 +78,87 @@ describe('Getter', () => { }) }) }) + + describe('getCanonicalKeypathDeps', function() { + describe('when passed the identity getter', () => { + it('should return a set with only an empty list', () => { + var getter = [[], (x) => x] + var result = getCanonicalKeypathDeps(getter, 3) + var expected = Set().add(List()) + expect(is(result, expected)).toBe(true) + }) + }) + + describe('when passed a flat getter with maxDepth greater than each keypath' , () => { + it('return all keypaths', () => { + var getter = [ + ['store1', 'key1'], + ['store2', 'key2'], + (a, b) => 1, + ] + var result = getCanonicalKeypathDeps(getter, 3) + var expected = Set() + .add(List(['store1', 'key1'])) + .add(List(['store2', 'key2'])) + expect(is(result, expected)).toBe(true) + }) + }) + + describe('when passed a flat getter with maxDepth less than each keypath' , () => { + it('return all shortened keypaths', () => { + var getter = [ + ['store1', 'key1', 'prop1', 'bar1'], + ['store2', 'key2'], + (a, b) => 1, + ] + var result = getCanonicalKeypathDeps(getter, 3) + var expected = Set() + .add(List(['store1', 'key1', 'prop1'])) + .add(List(['store2', 'key2'])) + expect(is(result, expected)).toBe(true) + }) + }) + + describe('when passed getter with a getter dependency', () => { + it('should return flattened keypaths', () => { + var getter1 = [ + ['store1', 'key1'], + ['store2', 'key2'], + (a, b) => 1, + ] + var getter2 = [ + getter1, + ['store3', 'key3'], + (a, b) => 1, + ] + var result = getFlattenedDeps(getter2) + var expected = Set() + .add(List(['store1', 'key1'])) + .add(List(['store2', 'key2'])) + .add(List(['store3', 'key3'])) + expect(is(result, expected)).toBe(true) + }) + }) + + describe('when passed getter with a getter dependency with long keypaths', () => { + it('should return flattened and shortened keypaths', () => { + var getter1 = [ + ['store1', 'key1', 'bar', 'baz'], + ['store2', 'key2'], + (a, b) => 1, + ] + var getter2 = [ + getter1, + ['store3', 'key3'], + (a, b) => 1, + ] + var result = getCanonicalKeypathDeps(getter2, 3) + var expected = Set() + .add(List(['store1', 'key1', 'bar'])) + .add(List(['store2', 'key2'])) + .add(List(['store3', 'key3'])) + expect(is(result, expected)).toBe(true) + }) + }) + }) }) diff --git a/tests/keypath-tracker-tests.js b/tests/keypath-tracker-tests.js new file mode 100644 index 0000000..3e36268 --- /dev/null +++ b/tests/keypath-tracker-tests.js @@ -0,0 +1,582 @@ +/*eslint-disable one-var, comma-dangle*/ +import { Map, List, Set, is } from 'immutable' +import * as KeypathTracker from '../src/reactor/keypath-tracker' +import { toImmutable } from '../src/immutable-helpers' + +const { status, RootNode } = KeypathTracker + +describe('Keypath Tracker', () => { + describe('unchanged', () => { + it('should properly register ["foo"]', () => { + const keypath = ['foo'] + const state = new RootNode() + let tracker = KeypathTracker.unchanged(state, keypath) + + const expected = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }) + }) + expect(is(tracker, expected)).toBe(true) + }) + + it('should properly register ["foo", "bar"]', () => { + const keypath = ['foo', 'bar'] + const state = new RootNode() + let tracker = KeypathTracker.unchanged(state, keypath) + + const expected = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.CLEAN, + state: 1, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: {}, + }, + } + }, + }) + }) + + expect(is(tracker, expected)).toBe(true) + }) + + it('should register ["foo", "bar"] when ["foo"] is already registered', () => { + const keypath = ['foo', 'bar'] + const origTracker = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.UNKNOWN, + state: 2, + children: {}, + }, + }) + }) + const tracker = KeypathTracker.unchanged(origTracker, keypath) + const expected = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.UNKNOWN, + state: 2, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: {}, + }, + } + }, + }) + }) + + expect(is(tracker, expected)).toBe(true) + }) + + it('should mark something as unchanged', () => { + const keypath = ['foo', 'bar'] + const orig = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + } + }, + }), + }) + const tracker = KeypathTracker.unchanged(orig, keypath) + const expected = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: {}, + }, + } + }, + }), + }) + + expect(is(tracker, expected)).toBe(true) + }) + + it('should mark the root node as unchanged', () => { + const orig = new RootNode({ + status: status.UNKNOWN, + state: 1, + children: toImmutable({ + foo: { + status: status.UNKNOWN, + state: 2, + children: {} + }, + }), + }) + const tracker = KeypathTracker.unchanged(orig, []) + const expected = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.CLEAN, + state: 2, + children: {}, + }, + }), + }) + + expect(is(tracker, expected)).toBe(true) + }) + }) + + + describe('changed', () => { + it('should initialize a node with a DIRTY status', () => { + const orig = new RootNode({ + status: status.CLEAN, + state: 1, + }) + const result = KeypathTracker.changed(orig, ['foo']) + const expected = new RootNode({ + changedPaths: Set.of(toImmutable(['foo'])), + status: status.DIRTY, + state: 2, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 1, + children: {}, + }, + }), + }) + + expect(is(result, expected)).toBe(true) + }) + it('should traverse and increment for parents and mark children UNKNOWN', () => { + const orig = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + state: 1, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: { + baz: { + status: status.CLEAN, + state: 1, + children: {}, + }, + bat: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }), + }) + const result = KeypathTracker.changed(orig, ['foo', 'bar']) + const expected = new RootNode({ + changedPaths: Set.of(toImmutable(['foo', 'bar'])), + status: status.DIRTY, + state: 2, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.DIRTY, + state: 2, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }), + }) + + expect(is(result, expected)).toBe(true) + }) + + it('should handle the root node', () => { + const orig = new RootNode({ + status: status.CLEAN, + state: 1, + children: toImmutable({ + foo: { + status: status.UNKNOWN, + state: 1, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: { + baz: { + status: status.CLEAN, + state: 1, + children: {}, + }, + bat: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }), + }) + const result = KeypathTracker.changed(orig, []) + const expected = new RootNode({ + changedPaths: Set.of(toImmutable([])), + status: status.DIRTY, + state: 2, + children: toImmutable({ + foo: { + status: status.UNKNOWN, + state: 1, + children: { + bar: { + status: status.UNKNOWN, + state: 1, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }), + }) + + expect(is(result, expected)).toBe(true) + }) + }) + + describe('isEqual', () => { + const state = new RootNode({ + state: 1, + status: status.DIRTY, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.DIRTY, + state: 2, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }) + }) + + it('should return false for a mismatch on the root node', () => { + const result = KeypathTracker.isEqual(state, [], 2) + expect(result).toBe(false) + }) + + it('should return false with an invalid keypath', () => { + const result = KeypathTracker.isEqual(state, ['foo', 'wat'], 2) + expect(result).toBe(false) + }) + + it('should return false when values dont match', () => { + const result = KeypathTracker.isEqual(state, ['foo', 'bar'], 1) + expect(result).toBe(false) + }) + + it('should return false when node is unknown', () => { + const result = KeypathTracker.isEqual(state, ['foo', 'bar', 'baz'], 1) + expect(result).toBe(false) + }) + + it('should return true when values match and node is clean', () => { + const result = KeypathTracker.isEqual(state, ['foo', 'bar'], 2) + expect(result).toBe(true) + }) + }) + + describe('get', () => { + const state = new RootNode({ + state: 1, + status: status.DIRTY, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.DIRTY, + state: 2, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }) + }) + + it('should return undefined with an invalid keypath', () => { + const result = KeypathTracker.get(state, ['foo', 'wat']) + expect(result).toBe(undefined) + }) + + it('should return a value for a single depth', () => { + const result = KeypathTracker.get(state, ['foo']) + expect(result).toBe(2) + }) + + it('should return a value for a deeper keypath', () => { + const result = KeypathTracker.get(state, ['foo', 'bar', 'baz']) + expect(result).toBe(1) + }) + }) + + describe('isClean', () => { + const state = new RootNode({ + state: 1, + status: status.DIRTY, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.DIRTY, + state: 2, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }, + }, + }, + }, + }) + }) + + it('should return false with an invalid keypath', () => { + const result = KeypathTracker.isClean(state, ['foo', 'wat']) + expect(result).toBe(false) + }) + + it('should return false for a DIRTY value', () => { + const result = KeypathTracker.isClean(state, ['foo']) + expect(result).toBe(false) + }) + + it('should return false for an UNKNOWN value', () => { + const result = KeypathTracker.isClean(state, ['foo', 'bar', 'baz']) + expect(result).toBe(false) + }) + + it('should return true for an CLEAN value', () => { + const result = KeypathTracker.isClean(state, ['foo', 'bar', 'bat']) + expect(result).toBe(true) + }) + }) + + describe('incrementAndClean', () => { + it('should work when the root node is clean', () => { + const state = new RootNode({ + changedPaths: Set.of(List(['foo'])), + state: 2, + status: status.CLEAN, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.UNKNOWN, + state: 1, + children: {} + }, + }, + }, + }), + }) + + const expected = new RootNode({ + state: 2, + status: status.CLEAN, + children: toImmutable({ + foo: { + status: status.CLEAN, + state: 2, + children: { + bar: { + status: status.CLEAN, + state: 2, + children: {} + }, + }, + }, + }), + }) + + + const result = KeypathTracker.incrementAndClean(state) + expect(is(result, expected)).toBe(true) + }) + it('should traverse the tree and increment any state value thats UNKNOWN and mark everything CLEAN', () => { + const state = new RootNode({ + changedPaths: Set.of(List(['foo', 'bar']), List(['foo', 'bar', 'bat'])), + state: 2, + status: status.DIRTY, + children: toImmutable({ + foo: { + status: status.DIRTY, + state: 2, + children: { + bar: { + status: status.DIRTY, + state: 1, + children: { + baz: { + status: status.UNKNOWN, + state: 1, + children: {}, + }, + bat: { + status: status.DIRTY, + state: 2, + children: {}, + }, + }, + }, + }, + }, + top: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }), + }) + + const expected = new RootNode({ + state: 2, + status: status.CLEAN, + children: toImmutable({ + foo: { + status: status.CLEAN, + state: 2, + children: { + bar: { + status: status.CLEAN, + state: 1, + children: { + baz: { + status: status.CLEAN, + state: 2, + children: {}, + }, + bat: { + status: status.CLEAN, + state: 2, + children: {}, + }, + }, + }, + }, + }, + top: { + status: status.CLEAN, + state: 1, + children: {}, + }, + }), + }) + + + const result = KeypathTracker.incrementAndClean(state) + expect(is(result, expected)).toBe(true) + }) + }) +}) +/*eslint-enable one-var, comma-dangle*/ + diff --git a/tests/observer-state-tests.js b/tests/observer-state-tests.js new file mode 100644 index 0000000..420e19b --- /dev/null +++ b/tests/observer-state-tests.js @@ -0,0 +1,172 @@ +/*eslint-disable one-var, comma-dangle*/ +import { Map, Set, OrderedSet, List, is } from 'immutable' +import { Store } from '../src/main' +import * as fns from '../src/reactor/fns' +import * as KeypathTracker from '../src/reactor/keypath-tracker' +import { ReactorState } from '../src/reactor/records' +import ObserverState from '../src/reactor/observer-state' +import { toImmutable } from '../src/immutable-helpers' + +describe('ObserverState', () => { + beforeEach(() => { + jasmine.addCustomEqualityTester(is) + }) + + describe('#addObserver', () => { + let observerState, entry, handler, getter + + describe('when observing the identity getter', () => { + beforeEach(() => { + getter = [[], x => x] + handler = function() {} + const reactorState = new ReactorState() + + observerState = new ObserverState() + entry = observerState.addObserver(reactorState, getter, handler) + }) + + it('should properly update the observer state', () => { + expect(observerState.trackedKeypaths).toEqual(Set.of(List([]))) + + expect(observerState.observersMap).toEqual(Map().setIn( + [toImmutable(getter), handler], + Map({ getter, handler }) + )) + + expect(observerState.keypathToEntries).toEqual(Map([ + [toImmutable([]), Set.of(entry)] + ])) + + expect() + expect(observerState.observers).toEqual(Set.of(entry)) + }) + + it('should return a valid entry', () => { + expect(entry).toEqual(Map({ + getter: getter, + handler: handler, + })) + }) + }) + + describe('when observing a store backed getter', () => { + beforeEach(() => { + getter = [ + ['store1', 'prop1', 'prop2', 'prop3'], + ['store2'], + (a, b) => a + b + ] + handler = function() {} + + const reactorState = new ReactorState() + + observerState = new ObserverState() + entry = observerState.addObserver(reactorState, getter, handler) + }) + it('should properly update the observer state', () => { + expect(observerState.trackedKeypaths).toEqual(Set.of( + List(['store1', 'prop1', 'prop2']), + List(['store2']) + )) + + expect(observerState.observersMap).toEqual(Map().setIn( + [toImmutable(getter), handler], + Map({ getter, handler }) + )) + + expect(observerState.keypathToEntries).toEqual(Map([ + [toImmutable(['store1', 'prop1', 'prop2']), Set.of(entry)], + [toImmutable(['store2']), Set.of(entry)], + ])) + + expect(observerState.observers).toEqual(Set.of(entry)) + }) + it('should return a valid entry', () => { + const expected = Map({ + getter: getter, + handler: handler, + }) + expect(is(expected, entry)).toBe(true) + }) + }) + }) + + describe('#removeObserver', () => { + let reactorState, observerState, getter1, getter2, handler1, handler2, handler3 + + beforeEach(() => { + handler1 = () => 1 + handler2 = () => 2 + handler3 = () => 3 + + getter1 = [ + ['store1', 'prop1', 'prop2', 'prop3'], + ['store2'], + (a, b) => a + b + ] + getter2 = [[], x => x] + + reactorState = new ReactorState() + observerState = new ObserverState() + observerState.addObserver(reactorState, getter1, handler1) + observerState.addObserver(reactorState, getter1, handler2) + observerState.addObserver(reactorState, getter2, handler3) + }) + + describe('when removing by getter', () => { + it('should return a new ObserverState with all entries containing the getter removed', () => { + observerState.removeObserver(reactorState, getter1) + + expect(observerState.observersMap).toEqual(Map().setIn( + [toImmutable(getter2), handler3], + Map({ getter: getter2, handler: handler3 }) + )) + + const entry = Map({ + getter: getter2, + handler: handler3, + }) + expect(observerState.keypathToEntries).toEqual(Map().set(toImmutable([]), Set.of(entry))) + + expect(observerState.trackedKeypaths).toEqual(Set.of(toImmutable([]))) + + expect(observerState.observers).toEqual(Set.of(entry)) + }) + }) + + describe('when removing by getter / handler', () => { + it('should return a new ObserverState with all entries containing the getter removed', () => { + observerState.removeObserver(reactorState, getter1, handler1) + + const entry1 = Map({ getter: getter2, handler: handler3 }) + const entry2 = Map({ getter: getter1, handler: handler2 }) + expect(observerState.observersMap).toEqual(Map() + .setIn( + [toImmutable(getter2), handler3], + entry1 + ) + .setIn( + [toImmutable(getter1), handler2], + entry2 + )) + + + + const expectedKeypathToEntries = Map() + .set(toImmutable(['store1', 'prop1', 'prop2']), Set.of(Map({ getter: getter1, handler: handler2 }))) + .set(toImmutable(['store2']), Set.of(Map({ getter: getter1, handler: handler2 }))) + .set(toImmutable([]), Set.of(Map({ getter: getter2, handler: handler3 }))) + expect(observerState.keypathToEntries).toEqual(expectedKeypathToEntries) + + expect(observerState.trackedKeypaths).toEqual(Set.of( + toImmutable([]), + toImmutable(['store1', 'prop1', 'prop2']), + toImmutable(['store2']) + )) + + expect(observerState.observers).toEqual(Set.of(entry1, entry2)) + }) + }) + }) +}) +/*eslint-enable one-var, comma-dangle*/ diff --git a/tests/react-mixin-tests.js b/tests/react-mixin-tests.js index 6be2d47..8435158 100644 --- a/tests/react-mixin-tests.js +++ b/tests/react-mixin-tests.js @@ -189,7 +189,7 @@ describe('reactor.ReactMixin', () => { it('should unobserve all getters', () => { React.unmountComponentAtNode(mountNode) - expect(reactor.observerState.get('observersMap').size).toBe(0) + expect(reactor.observerState.observers.size).toBe(0) }) }) }) diff --git a/tests/reactor-fns-tests.js b/tests/reactor-fns-tests.js index ce73e10..e5aea5e 100644 --- a/tests/reactor-fns-tests.js +++ b/tests/reactor-fns-tests.js @@ -1,11 +1,19 @@ /*eslint-disable one-var, comma-dangle*/ -import { Map, Set, is } from 'immutable' +import { Map, Set, OrderedSet, List, is } from 'immutable' import { Store } from '../src/main' import * as fns from '../src/reactor/fns' +import * as KeypathTracker from '../src/reactor/keypath-tracker' import { ReactorState, ObserverState } from '../src/reactor/records' import { toImmutable } from '../src/immutable-helpers' +import { DefaultCache } from '../src/reactor/cache' + +const status = KeypathTracker.status describe('reactor fns', () => { + beforeEach(() => { + jasmine.addCustomEqualityTester(is) + }) + describe('#registerStores', () => { let reactorState let store1 @@ -52,17 +60,24 @@ describe('reactor fns', () => { expect(is(result, expected)).toBe(true) }) - it('should update reactorState.dirtyStores', () => { - const result = nextReactorState.get('dirtyStores') - const expected = Set.of('store1', 'store2') - expect(is(result, expected)).toBe(true) - }) - - it('should update reactorState.dirtyStores', () => { - const result = nextReactorState.get('storeStates') - const expected = Map({ - store1: 1, - store2: 1, + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode({ + changedPaths: Set.of(List(['store1']), List(['store2'])), + state: 3, + status: status.DIRTY, + children: toImmutable({ + store1: { + state: 1, + status: status.DIRTY, + children: {}, + }, + store2: { + state: 1, + status: status.DIRTY, + children: {}, + }, + }), }) expect(is(result, expected)).toBe(true) }) @@ -74,7 +89,7 @@ describe('reactor fns', () => { }) }) - describe('#registerStores', () => { + describe('#replaceStores', () => { let reactorState let store1 let store2 @@ -149,9 +164,8 @@ describe('reactor fns', () => { }, }) - initialReactorState = fns.resetDirtyStores( - fns.registerStores(reactorState, { store1, store2 }) - ) + initialReactorState = fns.registerStores(reactorState, { store1, store2 }) + .update('keypathStates', KeypathTracker.incrementAndClean) }) describe('when dispatching an action that updates 1 store', () => { @@ -176,18 +190,26 @@ describe('reactor fns', () => { expect(is(result, expected)).toBe(true) }) - it('should update dirtyStores', () => { - const result = nextReactorState.get('dirtyStores') - const expected = Set.of('store2') - expect(is(result, expected)).toBe(true) - }) - - it('should update storeStates', () => { - const result = nextReactorState.get('storeStates') - const expected = Map({ - store1: 1, - store2: 2, + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode({ + changedPaths: Set.of(List(['store2'])), + state: 4, + status: status.DIRTY, + children: toImmutable({ + store1: { + state: 1, + status: status.CLEAN, + children: {}, + }, + store2: { + state: 2, + status: status.DIRTY, + children: {}, + }, + }), }) + expect(is(result, expected)).toBe(true) }) }) @@ -214,17 +236,68 @@ describe('reactor fns', () => { expect(is(result, expected)).toBe(true) }) - it('should not update dirtyStores', () => { - const result = nextReactorState.get('dirtyStores') - const expected = Set() + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode({ + state: 3, + status: status.CLEAN, + children: toImmutable({ + store1: { + state: 1, + status: status.CLEAN, + children: {}, + }, + store2: { + state: 1, + status: status.CLEAN, + children: {}, + }, + }), + }) expect(is(result, expected)).toBe(true) }) + }) - it('should not update storeStates', () => { - const result = nextReactorState.get('storeStates') - const expected = Map({ - store1: 1, - store2: 1, + describe('when a deep keypathState exists and dispatching an action that changes non-leaf node', () => { + beforeEach(() => { + // add store2, prop1, prop2 entries to the keypath state + // this is similiar to someone observing this keypath before it's defined + const newReactorState = initialReactorState.update('keypathStates', k => { + return KeypathTracker.unchanged(k, ['store2', 'prop1', 'prop2']) + }) + nextReactorState = fns.dispatch(newReactorState, 'set2', 3) + }) + + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode({ + changedPaths: Set.of(List(['store2'])), + state: 4, + status: status.DIRTY, + children: toImmutable({ + store1: { + state: 1, + status: status.CLEAN, + children: {}, + }, + store2: { + state: 2, + status: status.DIRTY, + children: { + prop1: { + state: 1, + status: status.UNKNOWN, + children: { + prop2: { + state: 1, + status: status.UNKNOWN, + children: {}, + }, + }, + }, + }, + }, + }), }) expect(is(result, expected)).toBe(true) }) @@ -261,9 +334,8 @@ describe('reactor fns', () => { }, }) - initialReactorState = fns.resetDirtyStores( - fns.registerStores(reactorState, { store1, store2 }) - ) + initialReactorState = fns.registerStores(reactorState, { store1, store2 }) + .update('keypathStates', KeypathTracker.incrementAndClean) nextReactorState = fns.loadState(initialReactorState, stateToLoad) }) @@ -279,27 +351,41 @@ describe('reactor fns', () => { expect(is(expected, result)).toBe(true) }) - it('should update dirtyStores', () => { - const result = nextReactorState.get('dirtyStores') - const expected = Set.of('store1') - expect(is(expected, result)).toBe(true) - }) - - it('should update storeStates', () => { - const result = nextReactorState.get('storeStates') - const expected = Map({ - store1: 2, - store2: 1, + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode({ + changedPaths: Set.of(List(['store1'])), + state: 4, + status: status.DIRTY, + children: toImmutable({ + store1: { + state: 2, + status: status.DIRTY, + children: {}, + }, + store2: { + state: 1, + status: status.CLEAN, + children: {}, + }, + }), }) - expect(is(expected, result)).toBe(true) + expect(is(result, expected)).toBe(true) }) + }) describe('#reset', () => { let initialReactorState, nextReactorState, store1, store2 beforeEach(() => { - const reactorState = new ReactorState() + const cache = DefaultCache() + cache.miss('key', 'value') + + const reactorState = new ReactorState({ + cache: DefaultCache(), + }) + store1 = new Store({ getInitialState() { return toImmutable({ @@ -320,9 +406,8 @@ describe('reactor fns', () => { }, }) - initialReactorState = fns.resetDirtyStores( - fns.registerStores(reactorState, { store1, store2, }) - ) + initialReactorState = fns.registerStores(reactorState, { store1, store2, }) + .update('keypathStates', KeypathTracker.incrementAndClean) // perform a dispatch then reset nextReactorState = fns.reset( @@ -341,216 +426,22 @@ describe('reactor fns', () => { expect(is(expected, result)).toBe(true) }) - it('should reset dirtyStores', () => { - const result = nextReactorState.get('dirtyStores') - const expected = Set() - expect(is(expected, result)).toBe(true) - }) - - it('should update storeStates', () => { - const result = nextReactorState.get('storeStates') - const expected = Map({ - store1: 3, - store2: 2, - }) - expect(is(expected, result)).toBe(true) + it('should empty the cache', () => { + const cache = nextReactorState.get('cache') + expect(cache.asMap()).toEqual(Map({})) }) - }) - - describe('#addObserver', () => { - let initialObserverState, nextObserverState, entry, handler, getter - describe('when observing the identity getter', () => { - beforeEach(() => { - getter = [[], x => x] - handler = function() {} - - initialObserverState = new ObserverState() - const result = fns.addObserver(initialObserverState, getter, handler) - nextObserverState = result.observerState - entry = result.entry - - }) - it('should update the "any" observers', () => { - const expected = Set.of(1) - const result = nextObserverState.get('any') - expect(is(expected, result)).toBe(true) - }) - it('should not update the "store" observers', () => { - const expected = Map({}) - const result = nextObserverState.get('stores') - expect(is(expected, result)).toBe(true) - }) - it('should increment the nextId', () => { - const expected = 2 - const result = nextObserverState.get('nextId') - expect(is(expected, result)).toBe(true) - }) - it('should update the observerMap', () => { - const expected = Map([ - [1, Map({ - id: 1, - storeDeps: Set(), - getterKey: getter, - getter: getter, - handler: handler, - })], - ]) - const result = nextObserverState.get('observersMap') - expect(is(expected, result)).toBe(true) - }) - it('should return a valid entry', () => { - const expected = Map({ - id: 1, - storeDeps: Set(), - getterKey: getter, - getter: getter, - handler: handler, - }) - expect(is(expected, entry)).toBe(true) - }) + it('reset the dispatchId', () => { + expect(nextReactorState.get('dispatchId')).toBe(1) }) - describe('when observing a store backed getter', () => { - beforeEach(() => { - getter = [ - ['store1'], - ['store2'], - (a, b) => a + b - ] - handler = function() {} - - initialObserverState = new ObserverState() - const result = fns.addObserver(initialObserverState, getter, handler) - nextObserverState = result.observerState - entry = result.entry - }) - it('should not update the "any" observers', () => { - const expected = Set.of() - const result = nextObserverState.get('any') - expect(is(expected, result)).toBe(true) - }) - it('should not update the "store" observers', () => { - const expected = Map({ - store1: Set.of(1), - store2: Set.of(1), - }) - - const result = nextObserverState.get('stores') - expect(is(expected, result)).toBe(true) - }) - it('should increment the nextId', () => { - const expected = 2 - const result = nextObserverState.get('nextId') - expect(is(expected, result)).toBe(true) - }) - it('should update the observerMap', () => { - const expected = Map([ - [1, Map({ - id: 1, - storeDeps: Set.of('store1', 'store2'), - getterKey: getter, - getter: getter, - handler: handler, - })] - ]) - const result = nextObserverState.get('observersMap') - expect(is(expected, result)).toBe(true) - }) - it('should return a valid entry', () => { - const expected = Map({ - id: 1, - storeDeps: Set.of('store1', 'store2'), - getterKey: getter, - getter: getter, - handler: handler, - }) - expect(is(expected, entry)).toBe(true) - }) + it('should update keypathStates', () => { + const result = nextReactorState.get('keypathStates') + const expected = new KeypathTracker.RootNode() + expect(is(result, expected)).toBe(true) }) }) - describe('#removeObserver', () => { - let initialObserverState, nextObserverState, getter1, getter2, handler1, handler2, handler3 - - beforeEach(() => { - handler1 = () => 1 - handler2 = () => 2 - handler3 = () => 3 - - getter1 = [ - ['store1'], - ['store2'], - (a, b) => a + b - ] - getter2 = [[], x => x] - - const initialObserverState1 = new ObserverState() - const result1 = fns.addObserver(initialObserverState1, getter1, handler1) - const initialObserverState2 = result1.observerState - const result2 = fns.addObserver(initialObserverState2, getter1, handler2) - const initialObserverState3 = result2.observerState - const result3 = fns.addObserver(initialObserverState3, getter2, handler3) - initialObserverState = result3.observerState - }) - - describe('when removing by getter', () => { - it('should return a new ObserverState with all entries containing the getter removed', () => { - nextObserverState = fns.removeObserver(initialObserverState, getter1) - const expected = Map({ - any: Set.of(3), - stores: Map({ - store1: Set(), - store2: Set(), - }), - nextId: 4, - observersMap: Map([ - [3, Map({ - id: 3, - storeDeps: Set(), - getterKey: getter2, - getter: getter2, - handler: handler3, - })] - ]) - }) - const result = nextObserverState - expect(is(expected, result)).toBe(true) - }) - }) - - describe('when removing by getter / handler', () => { - it('should return a new ObserverState with all entries containing the getter removed', () => { - nextObserverState = fns.removeObserver(initialObserverState, getter2, handler3) - const expected = Map({ - any: Set(), - stores: Map({ - store1: Set.of(1, 2), - store2: Set.of(1, 2), - }), - nextId: 4, - observersMap: Map([ - [1, Map({ - id: 1, - storeDeps: Set.of('store1', 'store2'), - getterKey: getter1, - getter: getter1, - handler: handler1, - })], - [2, Map({ - id: 2, - storeDeps: Set.of('store1', 'store2'), - getterKey: getter1, - getter: getter1, - handler: handler2, - })] - ]) - }) - const result = nextObserverState - expect(is(expected, result)).toBe(true) - }) - }) - }) describe('#getDebugOption', () => { it('should parse the option value in a reactorState', () => { const reactorState = new ReactorState({ diff --git a/tests/reactor-tests.js b/tests/reactor-tests.js index bc107bf..66acb67 100644 --- a/tests/reactor-tests.js +++ b/tests/reactor-tests.js @@ -4,6 +4,7 @@ import { getOption } from '../src/reactor/fns' import { toImmutable } from '../src/immutable-helpers' import { PROD_OPTIONS, DEBUG_OPTIONS } from '../src/reactor/records' import { NoopLogger, ConsoleGroupLogger } from '../src/logging' +import * as utils from '../src/utils' describe('Reactor', () => { it('should construct without \'new\'', () => { @@ -522,10 +523,10 @@ describe('Reactor', () => { it('should update all state', () => { checkoutActions.addItem(item.name, item.price) - expect(reactor.evaluateToJS(['items', 'all'])).toEqual([item]) + //expect(reactor.evaluateToJS(['items', 'all'])).toEqual([item]) - expect(reactor.evaluate(['taxPercent'])).toEqual(0) - expect(reactor.evaluate(taxGetter)).toEqual(0) + //expect(reactor.evaluate(['taxPercent'])).toEqual(0) + //expect(reactor.evaluate(taxGetter)).toEqual(0) expect(reactor.evaluate(totalGetter)).toEqual(10) }) @@ -1964,4 +1965,136 @@ describe('Reactor', () => { expect(reactor.evaluate(['counter2'])).toBe(21) }) }) + + describe('caching', () => { + let reactor + + beforeEach(() => { + reactor = new Reactor({ + debug: true, + }) + + const entity = new Store({ + getInitialState() { + return toImmutable({}) + }, + + initialize() { + this.on('loadEntities', (state, payload) => { + return state.withMutations(s => { + utils.each(payload.data, (val, key) => { + const id = Number(val.id) + s.setIn([payload.entity, id], toImmutable(val)) + }) + }) + }) + }, + }) + + const currentProjectId = new Store({ + getInitialState() { + return null + }, + + initialize() { + this.on('setCurrentProjectId', (state, payload) => payload) + }, + }) + + reactor.registerStores({ + entity, + currentProjectId + }) + }) + + describe('when observing the current project', () => { + let projectsGetter, currentProjectGetter + let projectsGetterSpy, currentProjectGetterSpy, currentProjectObserverSpy + + beforeEach(() => { + projectsGetterSpy = jasmine.createSpy() + currentProjectGetterSpy = jasmine.createSpy() + currentProjectObserverSpy = jasmine.createSpy() + + projectsGetter = [ + ['entity', 'projects'], + (projects) => { + projectsGetterSpy() + if (!projects) { + return toImmutable({}) + } + + return projects + } + ] + + currentProjectGetter = [ + projectsGetter, + ['currentProjectId'], + (projects, id) => { + currentProjectGetterSpy() + return projects.get(id) + } + ] + + // load initial data + reactor.dispatch('loadEntities', { + entity: 'projects', + data: { + 1: { id: 1, name: 'proj1' }, + 2: { id: 2, name: 'proj2' }, + 3: { id: 3, name: 'proj3' }, + }, + }) + + reactor.dispatch('setCurrentProjectId', 1) + + reactor.observe(currentProjectGetter, currentProjectObserverSpy) + }) + + + it('should not re-evaluate for the same dispatch cycle when using evaluate', () => { + const expected = toImmutable({ id: 1, name: 'proj1' }) + const result1 = reactor.evaluate(currentProjectGetter) + + expect(is(result1, expected)).toBe(true) + expect(currentProjectGetterSpy.calls.count()).toEqual(1) + + const result2 = reactor.evaluate(currentProjectGetter) + + expect(is(result2, expected)).toBe(true) + expect(currentProjectGetterSpy.calls.count()).toEqual(1) + expect(projectsGetterSpy.calls.count()).toEqual(1) + }) + + it('should not re-evaluate when another entity is loaded', () => { + expect(projectsGetterSpy.calls.count()).toEqual(0) + expect(currentProjectGetterSpy.calls.count()).toEqual(0) + reactor.dispatch('setCurrentProjectId', 2) + + // both getter spies are called twice, once with the prevReactorState and once with the currReactorState + expect(projectsGetterSpy.calls.count()).toEqual(2) + expect(currentProjectGetterSpy.calls.count()).toEqual(2) + expect(currentProjectObserverSpy.calls.count()).toEqual(1) + + reactor.dispatch('loadEntities', { + entity: 'other', + data: { + 11: { id: 11, name: 'other 11' }, + }, + }) + + // modifying a piece of the state map that isn't a dependencey should have no getter re-evaluation + expect(projectsGetterSpy.calls.count()).toEqual(2) + expect(currentProjectGetterSpy.calls.count()).toEqual(2) + expect(currentProjectObserverSpy.calls.count()).toEqual(1) + + reactor.dispatch('setCurrentProjectId', 3) + // ['entity', 'projects'] didn't change so projectsGetter should be cached + expect(projectsGetterSpy.calls.count()).toEqual(2) + expect(currentProjectGetterSpy.calls.count()).toEqual(3) + expect(currentProjectObserverSpy.calls.count()).toEqual(2) + }) + }) + }) })