Skip to content

Commit 7b109de

Browse files
committed
Refactor getter caching based on keypath state
The current version of NuclearJS uses a cache key consisting of store states (monotomically incresing ID per store). This has the disadvantage of allowing only a single level of depth when figuring out if a cache entry is stale. This leads to poor performance when the shape of a Reactor's state is more deep than wide, ie a store having multiple responsibilities for state tracking. The implementation is as follows: - Consumer can set the maxCacheDepth when instantiating a reactor - Getters are broken down into the canonical set of keypaths based on the maxCacheDepth - Add a keypath tracker abstraction to maintain the state value of all tracked keypaths - After any state change (`Reactor.__notify`) dirty keypaths are resolved and then based on which keypaths have changed any dependent observers are called
1 parent 5f9353c commit 7b109de

11 files changed

+1528
-353
lines changed

src/getter.js

+41-2
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function getFlattenedDeps(getter, existing) {
5454

5555
getDeps(getter).forEach(dep => {
5656
if (isKeyPath(dep)) {
57-
set.add(List(dep))
57+
set.add(Immutable.List(dep))
5858
} else if (isGetter(dep)) {
5959
set.union(getFlattenedDeps(dep))
6060
} else {
@@ -66,6 +66,45 @@ function getFlattenedDeps(getter, existing) {
6666
return existing.union(toAdd)
6767
}
6868

69+
/**
70+
* Returns a set of deps that have been flattened and expanded
71+
* expanded ex: ['store1', 'key1'] => [['store1'], ['store1', 'key1']]
72+
*
73+
* Note: returns a keypath as an Immutable.List(['store1', 'key1')
74+
* @param {Getter} getter
75+
* @param {Number} maxDepth
76+
* @return {Immutable.Set}
77+
*/
78+
function getCanonicalKeypathDeps(getter, maxDepth) {
79+
if (maxDepth === undefined) {
80+
throw new Error('Must supply maxDepth argument')
81+
}
82+
83+
const cacheKey = `__storeDeps_${maxDepth}`
84+
if (getter.hasOwnProperty(cacheKey)) {
85+
return getter[cacheKey]
86+
}
87+
88+
const deps = Immutable.Set().withMutations(set => {
89+
getFlattenedDeps(getter).forEach(keypath => {
90+
if (keypath.size <= maxDepth) {
91+
set.add(keypath)
92+
} else {
93+
set.add(keypath.slice(0, maxDepth))
94+
}
95+
})
96+
})
97+
98+
Object.defineProperty(getter, cacheKey, {
99+
enumerable: false,
100+
configurable: false,
101+
writable: false,
102+
value: deps,
103+
})
104+
105+
return deps
106+
}
107+
69108
/**
70109
* @param {KeyPath}
71110
* @return {Getter}
@@ -88,7 +127,6 @@ function getStoreDeps(getter) {
88127
}
89128

90129
const storeDeps = getFlattenedDeps(getter)
91-
.map(keyPath => keyPath.first())
92130
.filter(x => !!x)
93131

94132

@@ -106,6 +144,7 @@ export default {
106144
isGetter,
107145
getComputeFn,
108146
getFlattenedDeps,
147+
getCanonicalKeypathDeps,
109148
getStoreDeps,
110149
getDeps,
111150
fromKeyPath,

src/key-path.js

-12
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,3 @@ export function isKeyPath(toTest) {
1313
)
1414
}
1515

16-
/**
17-
* Checks if two keypaths are equal by value
18-
* @param {KeyPath} a
19-
* @param {KeyPath} a
20-
* @return {Boolean}
21-
*/
22-
export function isEqual(a, b) {
23-
const iA = Immutable.List(a)
24-
const iB = Immutable.List(b)
25-
26-
return Immutable.is(iA, iB)
27-
}

src/reactor.js

+50-30
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fns from './reactor/fns'
44
import { DefaultCache } from './reactor/cache'
55
import { NoopLogger, ConsoleGroupLogger } from './logging'
66
import { isKeyPath } from './key-path'
7-
import { isGetter } from './getter'
7+
import { isGetter, getCanonicalKeypathDeps } from './getter'
88
import { toJS } from './immutable-helpers'
99
import { extend, toFactory } from './utils'
1010
import {
@@ -60,7 +60,20 @@ class Reactor {
6060
* @return {*}
6161
*/
6262
evaluate(keyPathOrGetter) {
63-
let { result, reactorState } = fns.evaluate(this.reactorState, keyPathOrGetter)
63+
// look through the keypathStates and see if any of the getters dependencies are dirty, if so resolve
64+
// against the previous reactor state
65+
let updatedReactorState = this.reactorState
66+
if (!isKeyPath(keyPathOrGetter)) {
67+
const maxCacheDepth = fns.getOption(updatedReactorState, 'maxCacheDepth')
68+
let res = fns.resolveDirtyKeypathStates(
69+
this.prevReactorState,
70+
this.reactorState,
71+
getCanonicalKeypathDeps(keyPathOrGetter, maxCacheDepth)
72+
)
73+
updatedReactorState = res.reactorState
74+
}
75+
76+
let { result, reactorState } = fns.evaluate(updatedReactorState, keyPathOrGetter)
6477
this.reactorState = reactorState
6578
return result
6679
}
@@ -95,10 +108,10 @@ class Reactor {
95108
handler = getter
96109
getter = []
97110
}
98-
let { observerState, entry } = fns.addObserver(this.observerState, getter, handler)
111+
let { observerState, entry } = fns.addObserver(this.reactorState, this.observerState, getter, handler)
99112
this.observerState = observerState
100113
return () => {
101-
this.observerState = fns.removeObserverByEntry(this.observerState, entry)
114+
this.observerState = fns.removeObserverByEntry(this.reactorState, this.observerState, entry)
102115
}
103116
}
104117

@@ -110,7 +123,7 @@ class Reactor {
110123
throw new Error('Must call unobserve with a Getter')
111124
}
112125

113-
this.observerState = fns.removeObserver(this.observerState, getter, handler)
126+
this.observerState = fns.removeObserver(this.reactorState, this.observerState, getter, handler)
114127
}
115128

116129
/**
@@ -130,6 +143,7 @@ class Reactor {
130143
}
131144

132145
try {
146+
this.prevReactorState = this.reactorState
133147
this.reactorState = fns.dispatch(this.reactorState, actionType, payload)
134148
} catch (e) {
135149
this.__isDispatching = false
@@ -171,6 +185,7 @@ class Reactor {
171185
* @param {Object} stores
172186
*/
173187
registerStores(stores) {
188+
this.prevReactorState = this.reactorState
174189
this.reactorState = fns.registerStores(this.reactorState, stores)
175190
this.__notify()
176191
}
@@ -196,6 +211,7 @@ class Reactor {
196211
* @param {Object} state
197212
*/
198213
loadState(state) {
214+
this.prevReactorState = this.reactorState
199215
this.reactorState = fns.loadState(this.reactorState, state)
200216
this.__notify()
201217
}
@@ -210,6 +226,15 @@ class Reactor {
210226
this.observerState = new ObserverState()
211227
}
212228

229+
/**
230+
* Denotes a new state, via a store registration, dispatch or some other method
231+
* Resolves any outstanding keypath states and sets a new reactorState
232+
* @private
233+
*/
234+
__nextState(newState) {
235+
// TODO(jordan): determine if this is actually needed
236+
}
237+
213238
/**
214239
* Notifies all change observers with the current state
215240
* @private
@@ -220,33 +245,32 @@ class Reactor {
220245
return
221246
}
222247

223-
const dirtyStores = this.reactorState.get('dirtyStores')
224-
if (dirtyStores.size === 0) {
225-
return
226-
}
227-
228-
let observerIdsToNotify = Immutable.Set().withMutations(set => {
229-
// notify all observers
230-
set.union(this.observerState.get('any'))
248+
const keypathsToResolve = this.observerState.get('trackedKeypaths')
249+
const { reactorState, changedKeypaths } = fns.resolveDirtyKeypathStates(
250+
this.prevReactorState,
251+
this.reactorState,
252+
keypathsToResolve,
253+
true // increment all dirty states (this should leave no unknown state in the keypath tracker map):
254+
)
255+
this.reactorState = reactorState
231256

232-
dirtyStores.forEach(id => {
233-
const entries = this.observerState.getIn(['stores', id])
234-
if (!entries) {
235-
return
257+
// get observers to notify based on the keypaths that changed
258+
let observersToNotify = Immutable.Set().withMutations(set => {
259+
changedKeypaths.forEach(keypath => {
260+
const entries = this.observerState.getIn(['keypathToEntries', keypath])
261+
if (entries && entries.size > 0) {
262+
set.union(entries)
236263
}
237-
set.union(entries)
238264
})
239265
})
240266

241-
observerIdsToNotify.forEach((observerId) => {
242-
const entry = this.observerState.getIn(['observersMap', observerId])
243-
if (!entry) {
244-
// don't notify here in the case a handler called unobserve on another observer
267+
observersToNotify.forEach((observer) => {
268+
if (!this.observerState.get('observers').has(observer)) {
269+
// the observer was removed in a hander function
245270
return
246271
}
247-
248-
const getter = entry.get('getter')
249-
const handler = entry.get('handler')
272+
const getter = observer.get('getter')
273+
const handler = observer.get('handler')
250274

251275
const prevEvaluateResult = fns.evaluate(this.prevReactorState, getter)
252276
const currEvaluateResult = fns.evaluate(this.reactorState, getter)
@@ -257,15 +281,11 @@ class Reactor {
257281
const prevValue = prevEvaluateResult.result
258282
const currValue = currEvaluateResult.result
259283

284+
// TODO pull some comparator function out of the reactorState
260285
if (!Immutable.is(prevValue, currValue)) {
261286
handler.call(null, currValue)
262287
}
263288
})
264-
265-
const nextReactorState = fns.resetDirtyStores(this.reactorState)
266-
267-
this.prevReactorState = nextReactorState
268-
this.reactorState = nextReactorState
269289
}
270290

271291
/**

src/reactor/cache.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Map, OrderedSet, Record } from 'immutable'
22

33
export const CacheEntry = Record({
44
value: null,
5-
storeStates: Map(),
5+
states: Map(),
66
dispatchId: null,
77
})
88

0 commit comments

Comments
 (0)