Try Selector Side Effects and withData HoC#5219
Conversation
|
Had a chat with Riad about this earlier. In summary, if it's at all possible, I would love if this data resolution was transparent within existing APIs, at least as far the data consumer is concerned. Some rough pseudo-code for how I might imagine this working: registerSelectors( 'core/core-data', {
getCategories( state ) {
return state.categories,
},
} );
registerResolvers( 'core/core-data', {
getCategories() {
return wp.apiRequest( { path: '/wp/v2/categories' } )
.then( ( categories ) => {
dispatch( 'core/core-data' ).receiveCategories( categories );
} );
},
} );
// Will automatically call resolver:
select( 'core/core-data' ).getCategories();To avoid unnecessarily fetching data on repeated calls to the selector, we could either infer that data exists by having a convention on returning registerResolvers( 'core/core-data', {
getCategories: {
isFulfilled: ( selectorResult ) => selectorResult !== undefined,
fulfill() {
return wp.apiRequest( { path: '/wp/v2/categories' } )
.then( ( categories ) => {
dispatch( 'core/core-data' ).receiveCategories( categories );
} );
},
},
} );We would need also to avoid duplicate fulfillment requests while one is already in-flight. I'm not sure exactly how this would look, but I might imagine the wrapper applied to the original selector by const promises = {};
if ( ! isFulfilled ) {
const key = serialize( args );
if ( ! promises[ key ] ) {
promises[ key ] = fulfill();
}
}
return promises[ key ].then( () => /* ...trigger another selector call? */ );A few challenges I've not yet been able to work through:
|
73c3599 to
4528ea6
Compare
|
Update the PR and implemented something close to what's proposed by @aduth Also, I was able to avoid running the same request over and over again while it's inflight by using The issue with this could be the memory because the cache will keep growing that's why I have a
to remove an entry from the cache and it will be used once the selector is fulfilled. |
data/index.js
Outdated
| } | ||
| const isFulfilled = resolver.isFulfilled( stores[ reducerKey ].getState(), ...args ); | ||
| if ( isFulfilled ) { | ||
| // TODO: clear memize cache for these sepecific args |
There was a problem hiding this comment.
I don't think we need to do anything special here. If fulfill returns a promise, we can just then to it because it works, whether or not the request has already been completed.
See example: http://jsbin.com/qetusew/edit?html,js,console
There was a problem hiding this comment.
I guess it becomes a question of whether, if we want separate isFulfilled and fulfill, whether we need to keep around the cached value for fulfill, since we'd expect future calls to isFulfilled to return true.
Where this could be useful is if we established a convention of the selector or state returning a value indicating that it is not fulfilled: null or, more likely undefined, since we may want to express meaningful emptiness via null.
Edit: I see this is what you meant in your comment by:
The idea is that sometimes we want to always return an array from the selector even if the state is still
undefined(My examplegetCategoriesmatches this use-case).
My intuition is that we don't want to do this. If it's not available, it shouldn't return an array. A convention around this could also be a useful signal to the components to know whether they need to display some loading indicator, rather than having to pass this as a separate prop.
There was a problem hiding this comment.
The idea is that we don't want to call fulfill if it's already in-flight or fulfilled.
1- If it's already fulfilled, we have a dedicated API for this isFulfilled
2- If it's already in flights, we need to cache a value saying that fulfilled has already been called, we can do so by storing a map [ args ] => promise or we can just rely on a memoization library doing exactly that, avoid calling a function again if the args are the same.
I preferred the second option using memize because it seemed simpler and memize is already well optimized.
Now, when the selector is fulfilled, we may want to clear the memoized value (or the cached promise) because it's not necessary anymore. That's what my TODO comment is about.
There was a problem hiding this comment.
But I agree that returning a promise from fulfill is a good convention to have even if we don't use right now, it could be useful later for other purposes.
core-data/index.js
Outdated
| getCategories: { | ||
| isFulfilled: ( state ) => state !== undefined, | ||
| fulfill: () => { | ||
| wp.apiRequest( { path: '/wp/v2/categories' } ).then( categories => { |
There was a problem hiding this comment.
I'm curious how this might fit into the larger story of reducing these dependencies to the API directly, where fulfillment is merely a set of signals, or could otherwise be swapped with a specific implementation.
- Could they return actions which are observed and answered by the implementing interface?
- Could an implementing interface easily replace these resolvers with their own if they so choose?
There was a problem hiding this comment.
So the question is whether this core-data module I'm adding here is considered the data layer of Gutenberg in which case it should rely on swappable API layer or Should we consider this module as the adapter it-self in which case it should be entirely replaced by another implementation. Think my-custom-data module replacing core-data when Gutenberg is used in other contexts.
Honestly, I don't know the answer here, I'm still on the fence.
There was a problem hiding this comment.
I see it as the later, this module as the adapter with the whole thing being replaced.
So I could create my own data module which defines getCategories() and does whatever it needs and returns the data Gutenberg needs.
I think another API layer on top assumes too much on where the data might be coming from.
data/index.js
Outdated
| if ( isFulfilled ) { | ||
| // TODO: clear memize cache for these sepecific args | ||
| } else { | ||
| resolver.fulfill( ...args ); |
There was a problem hiding this comment.
How do we signal to the original consumer that the value is now ready after this completes?
There was a problem hiding this comment.
Since the selector is state based, the resolver will update the state at somepoint which will trigger a subscribe and rerender the consumer.
|
After a discussion with @gziolo around the need of |
|
I discussed this idea with @youknowriad a little bit. In general, I love the direction and the proposed way of registering resolvers. It's all great and we should definitely continue this work to further unify data access. My only concern is this part: isFulfilled: ( state ) => state !== undefined,I would prefer to have it auto-handled by the We also need to keep in mind that |
core-data/index.js
Outdated
| /** | ||
| * Module Constants | ||
| */ | ||
| const MODULE_KEY = 'core'; |
There was a problem hiding this comment.
We need to establish some conventions around module names, both internally and as recommendations for plugins.
Elsewhere we prefix with a core/ namespace and align the remainder of the name with the top-level folder. Here, that would mean core/core-data.
Obviously there's some ugliness in the repetition here. @mtias mentioned this at one point as well. My first thought was that we could drop the namespace (or make optional) for core modules, similar to what we do with blocks.
But then for plugins, we need to make certain that they are namespaced. But for many plugins, they may only have a single store. What are they to call their stores? yoast/yoast ?
While verbose, at least the core namespace enables plugin authors to avoid their own, while avoiding potential name conflicts.
Whatever we decide, we should also be mindful of consistency with other "namespace + name" patterns.
There was a problem hiding this comment.
I think we could have a special case here because this core-data module will contain all "core" data, it could even be included in the data module itself if we didn't want to keep the data module generic. But happy to follow any consensus here.
| @@ -2,7 +2,8 @@ | |||
| * WordPress dependencies | |||
There was a problem hiding this comment.
I love how this file has been refactored 👍
core-data/index.js
Outdated
| */ | ||
| const MODULE_KEY = 'core'; | ||
|
|
||
| const store = registerReducer( MODULE_KEY, reducer ); |
There was a problem hiding this comment.
Should we use the createStore API? Should resolvers be integrated into this API?
There was a problem hiding this comment.
True, it was not there yet when I started the PR ;)
core-data/index.js
Outdated
| registerSelectors( MODULE_KEY, { getCategories } ); | ||
| registerResolvers( MODULE_KEY, { | ||
| getCategories: { | ||
| fulfill: () => { |
There was a problem hiding this comment.
Minor: Function property shorthand is more compact than arrow function:
fulfill: () => {
fulfill() {There was a problem hiding this comment.
Does fulfill need to be a property of the object? Are we anticipating more properties? Couldn't the function just be the value of the getCategories key?
There was a problem hiding this comment.
Are we anticipating more properties?
I thought about this and I think we should keep it as an object to allow extra properties in the future. I can think of a TTL property for example.
There was a problem hiding this comment.
There a few alternative ways to do it if we need to:
- function when no additional properties or object otherwise - type detection in the background
- higher-order function which adds additional behavior
- return value from the
fulfillimplementation
Just saying that we don't need to default to the object. I'd be in favor of starting with the function and change it if we find it too limiting.
There was a problem hiding this comment.
Yeah, I think setting it as a function is still forward-compatible with the patterns @gziolo outlined, and a nice simple usage when you don't need more complex options.
function normalizeResolver( resolver ) {
if ( typeof resolver === 'function' ) {
return { fulfill: resolver };
}
return resolver;
}
core-data/reducer.js
Outdated
| @@ -0,0 +1,13 @@ | |||
| const reducer = ( state, action ) => { | |||
There was a problem hiding this comment.
- Maybe fine as a demo / proof-of-concept, but this reducer shape certainly won't scale to accommodate more data.
- We should want some documentation when we do flesh it out.
data/index.js
Outdated
| export function registerResolvers( reducerKey, newResolvers ) { | ||
| resolvers[ reducerKey ] = mapValues( | ||
| newResolvers, | ||
| ( resolver ) => ( { ...resolver, fulfill: memize( resolver.fulfill ) } ) |
There was a problem hiding this comment.
isFulfilled :) or Right I removed it :P. I guess we could leave to accommodate future options but I'll just remove, it's easy enough to add later.
data/index.js
Outdated
| } | ||
| resolver.fulfill( ...args ); | ||
|
|
||
| return result; |
There was a problem hiding this comment.
If both logic paths return result;, maybe it'd be simple to move fulfill() into the condition above as negated?
if ( resolver ) {
resolver.fulfill( ...args );
}
return result;
data/index.js
Outdated
| return selectors[ reducerKey ]; | ||
| const createResolver = ( selector, key ) => ( ...args ) => { | ||
| const result = selector( ...args ); | ||
| const resolver = resolvers[ reducerKey ] && resolvers[ reducerKey ][ key ]; |
There was a problem hiding this comment.
Minor: Good use case for _.get†
const resolver = get( resolvers, [ reducerKey, key ] );† in that it arguably helps readability
data/index.js
Outdated
| return result; | ||
| }; | ||
|
|
||
| return mapValues( selectors[ reducerKey ], createResolver ); |
There was a problem hiding this comment.
This seems particularly heavy for an operation which we're assuming to occur very frequently (select). Could the selectors be augmented once as part of registerResolvers instead?
docs/coding-guidelines.md
Outdated
| #### WordPress Dependencies | ||
|
|
||
| To encourage reusability between features, our JavaScript is split into domain-specific modules which [`export`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) one or more functions or objects. In the Gutenberg project, we've distinguished these modules under top-level directories `blocks`, `components`, `editor`, `edit-post`, `element`, `data` and `i18n`. These each serve an independent purpose, and often code is shared between them. For example, in order to localize its text, editor code will need to include functions from the `i18n` module. | ||
| To encourage reusability between features, our JavaScript is split into domain-specific modules which [`export`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) one or more functions or objects. In the Gutenberg project, we've distinguished these modules under top-level directories `blocks`, `components`, `editor`, `edit-post`, `element`, `data`, `core-data` and `i18n`. These each serve an independent purpose, and often code is shared between them. For example, in order to localize its text, editor code will need to include functions from the `i18n` module. |
There was a problem hiding this comment.
We should just eliminate these lists of top-level folders where they're not strictly necessary. This is already inaccurate because it doesn't include viewport. A maintenance nightmare 😬
There was a problem hiding this comment.
It might be a good idea to move all modules to a subfolder and include README there and reference it everywhere we want to leave a note about other modules. I think it would be more useful than that. We should consider that when we start publishing modules to npm to have everything grouped together. This would also add a clean separation between production code and everything else.
There was a problem hiding this comment.
@gziolo I like it, that's how I do it in GCF https://github.com/youknowriad/gcf/tree/master/scripts
There was a problem hiding this comment.
Yes, we need to consolidate each of these horrendous lists of modules. Something like a Lerna approach of folder structure would be fine. I was even thinking in the immediate-term just a file modules with newline separated list, and everything that depends on it must read from it (single place to update).
|
Let's discuss this approach, if you all think, this approach is good enough, I'm happy to consolidate, add some unit tests... |
d3f6e3d to
d741b82
Compare
| } | ||
|
|
||
| export default withAPIData( () => { | ||
| export default withSelect( ( select ) => { |
|
I like the public API (minus |
985e80f to
70f09dc
Compare
|
I was iterating on this some today. I simplified the registration to assign the resolver function directly as the object property's value, as discussed in #5219 (comment). I was starting to look at updating the documentation for the registerStore( 'my-shop', {
// ...
selectors: {
getPrice( state, item ) {
const { prices, discountPercent } = state;
const price = prices[ item ];
return price * ( 1 - ( 0.01 * discountPercent ) );
},
},
resolvers: {
getPrice( /* state? , */ item ) {
return apiRequest( {
path: '/wp/v2/prices/' + item,
} ).then( ( price ) => {
dispatch( 'my-shop' ).setPrice( item, price );
} );
},
},
} );To implement this, we'd need to either apply to the selector after state has been pre-applied as an argument, or manually pass state into I wondered too about how we'd want to suggest reusing action creators in the callback of the resolver's promise. In our function setPrice( item, price ) {
return {
type: 'SET_PRICE',
item,
price,
};
}
registerStore( 'my-shop', {
// ...
actions: {
setPrice,
},
resolvers: {
getPrice( /* state? , */ item ) {
return apiRequest( {
path: '/wp/v2/prices/' + item,
} ).then( ( price ) => {
return setPrice( item, price );
} );
},
},
} );Which is maybe not so bad, since outside of the most simplest examples, we'd probably want to define action creators as standalone functions anyways (not inlined to the |
| @@ -0,0 +1,34 @@ | |||
| /** | |||
There was a problem hiding this comment.
core-data still misses README file.
core-data/index.js
Outdated
| resolvers: { | ||
| getCategories() { | ||
| wp.apiRequest( { path: '/wp/v2/categories' } ).then( ( categories ) => { | ||
| store.dispatch( { |
There was a problem hiding this comment.
In this case we don't even need to specify an action creator as it seems it shouldn't be executed in other places. However, it might make sense to always create a corresponding action creator to make it easier to extend. If someone wants to override this resolver, they will need to have a simple way to dispatch it anyways.
There was a problem hiding this comment.
However, it might make sense to always create a corresponding action creator to make it easier to extend.
I also think it sets a good precedent for those who learn by copying referencing core code.
70f09dc to
c60b808
Compare
9ebbbd0 to
76c0755
Compare
|
I've made quite a few revisions here, but I think it's in a good shape to merge now. I experimented with leveraging async iterators to simplify resolver implementations. These are a new feature ratified as part of the ES2018 specification. As yet, we've not even made use of export async function* getCategories() {
yield setRequested( 'terms', 'categories' );
const categories = await apiRequest( { path: '/wp/v2/categories' } );
yield receiveTerms( 'categories', categories );
}Of course, it's not necessary to write resolvers this way. Via internal normalization, they're very flexible in the shape they can take:
So a more "traditional" implementation might look like: export function getCategories() {
return apiRequest( { path: '/wp/v2/categories' } ).then( ( categories ) => {
return receiveTerms( 'categories', categories );
} );
}As can be seen in the above snippets, I've also refactored the categories implementation to operate on terms generally, since while we should probably want to expose these higher-level selectors, the need for underlying terms management will ensure consistency with future additions (e.g. tags, custom taxonomies). I included an Documentation has been added for Core Data, and resolvers example added to the data module's README.md . As noted in #5219 (comment), I still want to make further improvements here, but will do so separately. A potential conflict here is the aligning of names between resolvers and selectors, which can occasionally cause variable naming conflicts. |
…s in each selector call
Later refactoring to introduce new options still possible by overloading / normalizing value
It may be that in a more recent version of Babel (Babel 7?) this would be satisfied by allowing `babel-plugin-transform-runtime` to load in the context of tests.
76c0755 to
f1261b6
Compare
|
@youknowriad had raised some reluctance about the use of async generators, notably for (a) potentially blocking future enhancements to support yielding more than just dispatching actions and (b) being confusing for developers. I feel fairly comfortable that future enhancements, if any were to exist, would not be incompatible with this implementation. While I agree that generators in general are an unfamiliar concept, and further with async generators being a brand new feature (ES2018) are even further unknown. It may be that while generators are not inherently complex, they are so unlikely to be encountered such that seeing them in use here may have the potential for confusion. That said, when understood they make for an elegant API for asynchronous dispatching, and are totally optional for developers to leverage in resolvers. With Riad's blessing and a desire to start iterating on the further enhancements here (porting existing |
This PR is not polished yet as it's just an exploration at the moment.
It introduces two new APIs:
the
withDatabehaves closely to thewithSelectHoC but also runs the side effects attached to each selector. The other difference is that theselectfunction passed as arg to thewithSelectHoC returns the real result of the selector when called while theresolvefunction passed as arg to thewithDatareturns just a descriptor (or resolver) used to select the data an trigger the side effect when needed.An example implementation has been tried in the newly introduced
core-datamodule. This module register a selector to fetch categories and is being used in thecategoriesbloc to test it.