-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
framework: add treeSelect (a new type of memoized selector) #20547
Conversation
af1584f
to
ad595cf
Compare
b032177
to
412d8d1
Compare
Update: added support for non-object keys via |
constructor( init ) { | ||
forEach( init, ( [ key, val ] ) => { | ||
this.mapForKey( key ).set( key, val ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
did you see this on MDN
var kvArray = [['key1', 'value1'], ['key2', 'value2']];
// Use the regular Map constructor to transform a 2D key-value Array into a map
var myMap = new Map(kvArray);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep! which is why i needed to bake in some custom support for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this constructor takes all the values that have object keys and throws them into the weakmap. the rest go into the regular map
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah right. good good
|
||
delete( k ) { | ||
this.mapForKey( k ).delete( k ); | ||
return this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you anticipate chaining these calls like cache.clear().set().set()
etc…?
I find that it's helpful to not return this
when we are mutating the underlying object because the code itself kind of indicates based on return value if this is a side-effecting action or a pure one. moment()
is a good example where it supports chaining but the underlying object mutates throughout the chain. it gives the appearance of being safe from a purity standpoint, but that can lead to mistakes on that assumption.
const then = moment( thenDate )
const nextDay = then.startOf( 'day' ).add( 1, 'day' )
const prevDay = then.startOf( 'day' ).subtract( 1, 'day' )
^^^ looks fine until you realize that prevDay
is actually the start of thenDate
const then = moment( thenDate )
const nextDay = then
nextDay.startOf( 'day' ).add( 1, 'day' )
^^^ wait, that's odd, something must be up - I wonder if there's implicit mutation happening…
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you anticipate chaining these calls like cache.clear().set().set() etc…?
I don't think returning this
indicates purity...I don't usually assume purity unless its specifically stated.
The thing I would want most is to adhere to the WeakMap
specification. Some of them return this and some don't
set
--> returns thisget
--> returns the value associated with the keyclear
--> returns undefineddelete
--> returns true/false depending on if the key was found and removed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
odd. carry on.
import warn from 'lib/warn'; | ||
|
||
/* | ||
* A map that is Weak with objects but Strong with primitives |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a great description
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😊
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we have this? It strikes me as a dangerous idea, as anything that ends up in the strong map is effectively a memory leak. The point of using a WeakMap is to weakly hold the keys so that when they go out of scope elsewhere, we can GC them. If we allow some keys to end up in the strong map, they're never be GC'd.
For true primitives, that's probably ok, they don't take up much space, but then there's also not much benefit to caching them this way. For strings, it could get ugly, as they're effectively unbounded in size.
So why do we need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was torn on whether or not to allow primitives.
There are perfectly valid use-cases for having a primitive as a dependent.
Imagine state looked like this:
state = {
priceOfSite: 5.40,
sites: [ ... ],
...
}
If primitives weren't allowed, then you wouldn't be able to write a cached selector that used priceOfSite
. For example:
// would be invalid without MixedMap
const getTotalPrice = createCachedSelector(
({ sites, price }) =>return price * sites.length,
state => ({ sites: state.sites, price: state.priceOfSite })
)
The solutions i could think of are:
- restrict this cachedSelector function to to selectors that depend on objects within state, no primitives allowed.
MixedMap
- selector-ception: Do something clever with primitives. If we notice that a dependent is a primitive, then create a cached function for it so that it will return the value wrapped in an object
{ value: 'primitive-value', objectified: true }
and make sure to return a referentially equal one whenever the value hasn't changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@blowery we can't use primitive values as keys in WeakMap
s sadly. that is, this is getting as much out of the WeakMap
as we can and only falling back to classical maps when JavaScript won't let us use the WeakMap
our alternative is probably to skip caching these values
* @return {Function} Cached selector | ||
*/ | ||
export default function createCachedSelector( { selector, getDependents } ) { | ||
if ( ! isFunction( selector ) || ! isFunction( getDependents ) ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cough TypeScript
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export default function createCachedSelector( selector: Function, getDependents: Function )
would be nice :)
|
||
const cache = new MixedMap(); | ||
|
||
const cachedSelector = function( state, ...args ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
function( { state, ...args } )
const cachedSelector = function( state, ...args ) { | ||
const dependents = getDependents( state, ...args ); | ||
const sortedDependentsArray = Object.keys( dependents ) | ||
.sort() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is not what you think it is, it is also not stable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we discussed this on slack. the keys will always be unique strings therefore Array#sort does what we want -- to produce the same array for the same object of dependents every time
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The enumeration order of object properties is guaranteed to be the same as the order in which the properties were created. So you don't need to sort the keys nearly as often as you might think. You still need to make cases like this behave correctly:
getDependents = state => cond ? { dep1, dep2 } : { dep2, dep1 };
The fact that you require a particular property order, and that the keys are not used by the createCachedSelector
for anything, suggests that the data structure you're really looking for is an array, not an object:
createCachedSelector(
( [ dep1, dep2 ], ...args ) => combine( dep1, dep2, args ),
( state, ...args ) => [ dep1, dep2 ]
);
Or, even better in my opinion, follow the widely used reselect
function signature:
createCachedSelector(
( state, ...args ) => computeDep1,
( state, ...args ) => computeDep2,
( dep1, dep2, ...args ) => combine( dep1, dep2, args )
)
The disadvantage here is that it's hard to add a getKey
function as another argument, if you ever needed it. And the code in #21507 suggests that you might need it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fact that you require a particular property order, and that the keys are not used by the createCachedSelector for anything, suggests that the data structure you're really looking for is an array, not an object:
They keys are used! Not in a way that is necessary, but in a way that I think makes for a nice api. The return value of getDependents
becomes the first parameter to selector
So for example:
getDependents = (state, siteId) => ({ site: getSite(siteId), posts: getPosts(siteId), comments: getComments(siteId)});
selector = ({site, posts, comments}) => doThings(site, posts, comments)
All that said, I may be alone in how much I love using objects for named parameters, so I'll switch to an array that may make more sense here for all the reasons you cited:
getDependents = (state, siteId) => [getSite(siteId), getPosts(siteId), getComments(siteId)];
selector = ([site, posts, comments]) => doThings(site, posts, comments)
While the reselect api is really pleasant, I agree that we may need to sometimes use custom cache key generation which discounts that as an option. If we were rebuilding calypso from scratch we wouldn't, but in order to easily transition code that already depends on objects as selector args then we'll need it.
|
||
if ( process.env.NODE_ENV !== 'production' ) { | ||
if ( some( args, isObject ) ) { | ||
warn( 'Do not pass complex objects as arguments to a cachedSelector' ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is this important? we have a number of existing selectors which take objects as arguments after state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's part of the current createSelector as well.
If an argument is an object literal then it shouldn't be used for caching because it's a new object on each call. if it is in object from state, then it should be part of getDependents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
because it's a new object on each call
assumption
still, it may be part of createSelector
, but what I understand is that we're saying, "if you pass any object into a selector we won't let you cache things" when it seems like it could be valid
like what if the dependents are based on a property in that object, or unrelated to them at all?
I think this might occur when we pass things like formatting info to the selectors and stuff like that where the last step in the selector is to do some specific twiddling based on the passed option bag
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assumption
object literals are by definition new every time used (like the options bag you suggested).
The reason we can't use them is because there is no standard way to create a unique key for objects. for an ordered list of primitives its simple (args.join()
).
If this ends up being a need then we can allow for custom cacheKey generation...but i'd rather stay away for this first iteration because YAGNI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can drop the "complex" part as even the empty object qualifies here. Maybe "You can only pass primitives as arguments to a cached selector"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dropped the word complex in the latest update.
let currCache = cache; | ||
forEach( sortedDependentsArray, dependent => { | ||
if ( ! currCache.has( dependent ) ) { | ||
currCache.set( dependent, new MixedMap() ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this line may mutate cache
but it doesn't look like it from here. this is confusing to me and relates to my comment above in MixedMap
I think what's happening is that we are using currCache
more as a cursor than as an object and we're moving that cursor through cache
. if this is happening, then I think we can rearrange the code to more explicitly reflect that reality
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love input on how to do that! I have no idea 🤔
I like the word cursor
for what I'm doing with currCache, its more descriptive
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
using cursor
as the name would help I think.
this is really a kind of insertion on a prefix tree where the dependents are the atoms forming the nodes. things could potentially be more clear simply with the proper messaging around them…
const upsert = ( cache, value ) => {
if ( ! cache.has( value ) ) {
cache.set( value, new MixedMap() )
}
return cache.get( value );
}
// insert dependents into cache tree
sortedDependentsArray.reduce( upsert, cache )
maybe even something along these lines…
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samouri did you see this last comment as it pertains to the naming of the cursor
? (it eliminates it, for example)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I do! I think its brilliant! I'm just changing the name to insertIfAbsent
but otherwise keeping the logic intact.
jest.mock( 'lib/warn', () => jest.fn() ); | ||
|
||
describe( 'index', () => { | ||
describe( '#createCachedSelector', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that the root of this file will already appear as create-cached-selector
so this two-level descent may report in the tests as create-cached-selector/index/#createCachedSelector
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getSitePosts( reduxState, 2916284 ); | ||
|
||
expect( selector.mock.calls.length ).toBe( 1 ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A good partner-test here might be one to verify that this gets called twice when the arguments are different. This test has a high risk of false positives on the behavior of the function.
test( 'should call selector when making non-cached calls', () => {
getSitePosts( reduxState, 2916284 )
getSitePosts( reduxState, 2916284 + 1 )
expect( selector.mock.calls.length ).toBe( 2 );
} )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the current test named that should return the expected value of differing arguments
does this.
I like your test naming better though, I'll switch
getSitePosts( state, 1, [] ); | ||
|
||
expect( warn.mock.calls.length ).toBe( 3 ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as per my earlier comment, I question the validity of this constraint
const getDependents = state => ( { posts: state.posts } ); | ||
const selector = ( { posts }, siteId ) => filter( posts, { siteId } ); | ||
|
||
const getSitePosts = createCachedSelector( selector, getDependents ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wouldn't it make more sense to pass getDependents
first since they are logically prior to the selector?
this isn't a big deal either way, but something feels odd about this ordering
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you wholeheartedly. I only picked this order to match createSelector's api.
Since we are diverging anyway, may as well diverge a bit more ;).
@samouri what do you need to get this out the door? I think getting it in soon will be a big boon for us |
The main question I'd been pondering was how to handle primitives. For now I think its safest to disallow them completely. We may realize that we need to allow primitives at some point, but until then I'd rather explicitly not support it. I also have a gut feeling that we'll be able to work around the restriction by having selectors grab items higher up in state instead. So the changes i've made based on feedback are:
Two things that could happen in in future prs is allowing for custom cacheKeys and re-enabling primitives. |
client/lib/tree-select/index.js
Outdated
cursor.set( key, selector( dependents, ...args ) ); | ||
} | ||
|
||
return cursor.get( key ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here you already know the return value (it's the one that you just inserted into the map), so you can avoid the extra map lookup here.
Selectors are called zillion times on every possible Redux dispatch, so even a tiny optimization like this is very likely to be detectable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it'll require an extra line to assign the value to a variable. I'll do that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One more line for Jake, one less function call for the CPU. Looks like a good tradeoff 😜
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe. we probably shouldn't make too many assumptions about this though due to JS engine optimization. probably less CPU work but not definitely. maybe not significant but probably not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dmsnell I'm pretty sure that the call to Map.prototype.get
would be actually executed by the engine and that it takes more than zero CPU cycles ;)
client/lib/tree-select/index.js
Outdated
*/ | ||
function insertDependentKey( map, key, currentIndex, arr ) { | ||
const NewMap = currentIndex === arr.length - 1 ? Map : WeakMap; | ||
return insertIfAbsent( map, key, new NewMap() ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Create the new map only when you're really going to need it. Most of them will be thrown away.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thats a good point 👍
client/lib/tree-select/index.js
Outdated
} | ||
|
||
map.set( key, value ); | ||
return map.get( key ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just return the value
without an extra map lookup.
client/lib/tree-select/index.js
Outdated
* @return {Function} Cached selector | ||
*/ | ||
export default function treeSelect( getDependents, selector ) { | ||
if ( ! isFunction( getDependents ) || ! isFunction( selector ) ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check could be done only in development mode.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, adding check
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also added a unit test for this in https://github.com/Automattic/wp-calypso/pull/20547/files#diff-e1fde4b19690dc0c18ea9cd84f272397R90
client/lib/tree-select/index.js
Outdated
* Note: Inserts WeakMaps except for the last map which will be a regular Map. | ||
*/ | ||
function insertDependentKey( map, key, currentIndex, arr ) { | ||
const NewMap = currentIndex === arr.length - 1 ? Map : WeakMap; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I figured out this function only after stepping through it in debugger. I was wondering why there is a classic Map
for the last dependent. Its results are also objects, aren't they? And then I understood that it's all offset by one and the last Map
is for string keys coming from ...args
:) Some explanatory comment would be helpful here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nailed it! I'll add in a comment explaining that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I adjusted the comment slightly. I'm having trouble coming up with a good explanation so feel free to rewrite it (or post here a better one and I'll modify it)
client/lib/tree-select/README.md
Outdated
# `treeSelect` | ||
|
||
This module exports a function which creates a cached state selector for use with the Redux global application state. It is a good idea to use this function over plain selectors whenever either the computation over state or React's rerenders are expensive. | ||
It is called `treeSelect` because it internally uses a [WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) based dependency tree in order to allow the gc to free memory without explicitly clearing the cache. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is called
treeSelect
because it internally uses a [WeakMap]
That doesn't make sense. I think we're missing the logical warrant or we probably just need to remove the reference to WeakMap
or add it later when it's not tied to tree
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
read a few more words and then I think it makes sense:
It is called
treeSelect
because it internally uses a WeakMap based dependency tree.
I can switch around the wording so that the word tree is emphasized more though. Something like:
it is called
treeSelect
because it internally uses a tree of dependencies in order to allow the gc to free memory without explicitly clearing the cache.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
read a few more words and then I think it makes sense:
I read those words and the sense never came
Something like:
Removing the WeakMap
mention as you did makes sense.
For example, imagine that our state contains post objects, each of which are assigned to a particular site. Retrieving an array of posts for a specific site would require us to filter over all of the known posts. While this would normally trigger a re-render in React, we can use `treeSelect` to create a cached version of the selector which can return a referentially equal object: | ||
|
||
```js | ||
const getDependents = state => ( { posts: state.posts } ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
while I think dependent
is more proper I find myself constantly making typos in createSelector
code because it uses dependant
with an a
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤷♂️
client/lib/tree-select/README.md
Outdated
|
||
```js | ||
const getDependents = state => ( { posts: state.posts } ); | ||
const selector = ( { posts }, siteId ) => filter( posts, { siteId } ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does this need to be updated to an array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes it does!
client/lib/tree-select/index.js
Outdated
return value; | ||
}; | ||
|
||
return cachedSelector; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why store it as a separate binding vs just returning the function expression?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the nittiest of nits :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no reason any more. beforehand it was so that we could add properties to cachedSelector with cachedSelector.cache = ...
. Since its not necessary anymore I can get rid of it
client/lib/tree-select/index.js
Outdated
// create a dependency tree for caching selector results. | ||
// this is beneficial over standard memoization techniques so that we can | ||
// garbage collect any values that are based on outdated dependents | ||
const cursor = dependents.reduce( insertDependentKey, cache ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
at this point I don't see much value in the cursor
name because we're not "cursing" around anymore.
what about just calling it something like leafCache
or leaf
or callCache
or something like that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i like leafCache
client/lib/tree-select/index.js
Outdated
// garbage collect any values that are based on outdated dependents | ||
const cursor = dependents.reduce( insertDependentKey, cache ); | ||
|
||
const key = args.join(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is something I think is still dubious. it seems like there are reasons why passing objects makes sense (like a moment time value or some kind of query object). I guess I'm fine leaving it as-is as long as we are open to adjusting as developers hit the stringable-data-type wall
this is something that getCacheKey()
can actually work around in createSelector
we're specifying a kind of relational division operation to collapse "unique" invocations
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I already called out that we may want to add it in a future pr:
Two things that could happen in in future prs is allowing for custom cacheKeys and re-enabling primitives.
for now, less is more
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome! Thanks for working on this 🚀
*/ | ||
function insertDependentKey( map, key, currentIndex, arr ) { | ||
if ( map.has( key ) ) { | ||
return map.get( key ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could shave off another lookup by only calling map.get
and checking for a falsy result.
summary
Selectors are a vital part of any redux app. We have a powerful tool for creating cached selectors already called
createSelector
.createSelector
though has a fatal flaw which reselect also has. Namely that it only stores a singledependent-->cache
mapping at a time. This means that a too-common case will have disastrous cache hit rates and hurt performance.reselect solves this by creating a cached selector per react component instance.
if we wanted that to translate to
createSelector
We would need to make acreateSelectorCreator
or change the current implementation of createSelector.I've already created a PR trying to modify createSelector, but we decided that be too dangerous because so much of Calypso depends on it. Instead I've opted to make a new function in this PR with a slightly altered API that I think will help developers fall into the pit of success.
proposed api
treeSelect( getDependents, selector )
:getDependents
: A function which maps overstate
and returns all of the relevant parts of state the selector needs. You should be creating an array whose elements are all the return of other selectors -- no computations allowed here. Returned dependents values must also all be objects because in the implementation they act as keys into a WeakMap.selector
: A function which takes in the same args asgetDependents
with one catch. Instead of being passed state as its first arg, it is given the results of getDependents. This forces you to declare all of your state-dependencies.example
what is good about this approach?
selector
function so that it doesn't have access to state, it forces a selector to think critically about what it depends on. It also ensures that we only do the work of retrieving dependents once instead of twice like was usually done in createSelector.References
cachePerKey
flag #20330