-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 examplegetCategories
matches 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do we signal to the original consumer that the value is now ready after this completes?
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use the createStore
API? Should resolvers be integrated into this API?
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: Function property shorthand is more compact than arrow function:
fulfill: () => {
fulfill() {
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 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
fulfill
implementation
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are we spreading?
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -62,7 +62,7 @@ import TinyMCE from 'tinymce'; | |||
|
|||
#### 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -204,8 +205,8 @@ class CategoriesBlock extends Component { | |||
} | |||
} | |||
|
|||
export default withAPIData( () => { | |||
export default withSelect( ( select ) => { |
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 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
withData
behaves closely to thewithSelect
HoC but also runs the side effects attached to each selector. The other difference is that theselect
function passed as arg to thewithSelect
HoC returns the real result of the selector when called while theresolve
function passed as arg to thewithData
returns 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-data
module. This module register a selector to fetch categories and is being used in thecategories
bloc to test it.