-
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
Try a modular "required data" approach for fresh API data. #22961
Conversation
This is the culmination of several iterations on a general-purpose data requirement approach which endeavors to provide data to React components in the simplest, most declarative way possible. It also endeavors to be easy to hook in new APIs, endpoints, and data selectors. For the endpoints, selectors, and React Components, it becomes possible to use them cross-platform by implementing only the API layer below it in each environment. (e.g. using `data-layer` on Calypso and `withApiData`, etc. on wp-admin.
This improves the testability of the registry by making all module globals optional argument defaults.
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.
Very cool stuff Kevin. I was able to follow along mostly with the code, but am hopeful some authors of similar modules ( like @dmsnell ) can give this a more thorough review, because it is definitely over my head in places 😆
I do think this would be a great PR to do a demo of during a team hangout if you are up for it.
I did perform the test, and here are a few take-aways for me:
The freshness feature is quite powerful, and it can solve some open issues we have in Store around the order list not refreshing. But it does appear to keep polling perpetually. For example, I opened up a product edit screen, then went and read some p2s, and saw this upon return in the network tab:
Freshness is awesome, and makes sense in many cases, but I fear if we have a page where we query multiple endpoints ( thinking the dashboard for example ) this could get pretty chatty. I suppose we could limit freshness to a few key endpoints, but it is also something I could see unsuspecting developers ( cough me ) using incorrectly and creating a bunch of API traffic. With our history of issues with the Jetpack Proxy endpoint and timeouts, this is a concern for me.
In my test, I added a product image, and updated the description. It was magical to see the image appear on the edit page, and subsequently in the product listing page. The product description did not refresh on the edit page. Guessing there is some tinymce funkyness going on there.
I will continue to play with it some more, but wanted to chime in with my findings thus far.
|
||
function mapApiToProps( apiClient, ownProps, state ) { | ||
const { siteId, productId } = ownProps; | ||
const apiProduct = apiClient.selectors.getProduct( productId, { freshness: 30 * SECOND } ); |
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 so I'm following along correctly, this is saying, get a product from the API, if the local copy in the state tree is older than 30 seconds?
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 read the FULL pr description more carefully and answered my question.
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.
Freshness is awesome, and makes sense in many cases, but I fear if we have a page where we query multiple endpoints ( thinking the dashboard for example ) this could get pretty chatty.
Definitely. In future PRs, I plan to add a feature that will stop fetching all data when the user is inactive. I'm also open to other suggestions.
it is also something I could see unsuspecting developers ( cough me ) using incorrectly and creating a bunch of API traffic.
I'm not sure how to police our own developers on this, or if we should. There are plenty of use cases where fairly frequent polling might make sense. Maybe a minimum freshness that can be enforced as a part of the API settings? (in state/wc-api for this case) Or maybe we could set a minimum freshness setting on each endpoint, so if there's an endpoint that should be polled more or less often, we could set that.
The product description did not refresh on the edit page. Guessing there is some tinymce funkyness going on there.
I saw the same behavior. I think our TinyMCE editor component is probably dropping the ball here.
Thanks for the initial review, Timmy! I'd be happy to demo 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'm not totally clear on what should be a 3rd-party "generic" handler, vs what is WC specific, vs what is needed in Calypso - is there still the idea that part of this would be an external module?
<QueryJetpackPlugins siteIds={ [ siteId ] } /> | ||
{ children } | ||
</div> | ||
<ApiClientProvider apiReduxRoot={ apiReduxRoot }> |
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 this prop could just be root
?
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 have a problem with it, but do you think it would be descriptive enough?
|
||
function mapApiToProps( apiClient, ownProps, state ) { | ||
const { siteId, productId } = ownProps; | ||
const apiProduct = apiClient.selectors.getProduct( productId, { freshness: 30 * SECOND } ); |
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 from below why this is .selectors.getProduct
, but that word has a meaning now (of selecting data out of an existing state) – maybe this could be .data.getProduct
?
And state
here is the global state, correct?
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 definitely up for changes to naming for this. In fact, if there's nothing else that we need from apiClient
other than the selectors, maybe we can just pass that in instead.
}; | ||
} | ||
|
||
export default withApiClient( 'woocommerce', mapApiToProps, 'siteId' )( ProductForm ); |
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 arguments here are confusing, what do you think about converting it to an object, so they can be named?
{
type: 'woocommerce',
mapApiToProps,
cacheKey: 'siteId', // or keyProp?
}
(I know I tweaked the names from your function, but I think these are more clear)
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, the argument list is getting a bit long there. I'm open to some options here. The api type and the key definitely belong together, although mapApiToProps
itself belongs to the component. I'd like to have some further discussion, for sure.
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 api type and the key definitely belong together, although mapApiToProps itself belongs to the component
what does this mean? Are you thinking more like an (options, callback) pattern like this? I can see that working well:
export default withApiClient( {
type: 'woocommerce',
cacheKey: 'siteId', // or keyProp?
}, mapApiToProps )( ProductForm );
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 could work. I think keyProp
would work fine for what we want. So, this object would be the specific client identifier.
@@ -0,0 +1,41 @@ | |||
|
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.
Nitpick, this is an extra space, and some of these files are missing the @format
header
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.
Thanks!
export function requestedAction( siteKey, endpoint, ids ) { | ||
return { | ||
type: WOOCOMMERCE_API_CLIENT_REQUESTED, | ||
apiType: 'woocommerce', |
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 this be hardcoded here? Are you making this open to any API endpoint namespace, or is this just for the WC endpoints? (following that, should the actions use a different prefix?)
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.
Oops, you're totally right. apiType
should be an argument.
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.
And also, the actions should totally use a different prefix. I'm only prefixing them with WOOCOMMERCE_
for now while they exist under client/extensions/woocommerce
.
const _apiClients = new Map(); | ||
|
||
// TODO: Unit test this. | ||
export function registerApiType( name, apiType, apiTypes = _apiTypes ) { |
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 exactly is apiType? It sounds like it should be a string name, but I see above it's the createWcApi
object. There's a lot of apiType*
in this file, and maybe these could have better names - some documentation might help with figuring out clearer names.
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, there's ambiguity here. Maybe the string should be apiName
?
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.
name
is fine, but if apiType
is actually a collection of "stuff", it's not a type itself - the type is "woocommerce" (in my reading). This argument could be … a client
, or handlers
… I don't think you need to prefix everything with API, either -- that's obvious from the file itself, and function names (which since they're used externally, can keep the 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.
So, the apiType
object would represent everything one needs to use the WooCommerce API, but it wouldn't be keyed to a specific site, so what would you recommend naming that?
Yes, I think so. I'm just including it all here for now so we can discuss it in one place and get it stable before adding another npm dependency. |
After our discussion, I'm leaning towards going ahead and moving the majority of this code into a couple of new npm packages now, rather than later:
It would mean updating version numbers more often for both of them in the calypso package.json, but it also means we could try out the code in other places like |
@coderkevin any thoughts regarding this pr/solution WRT the work done in WordPress/gutenberg#5253 |
Gutenberg's data module in general may be an interesting look, and particularly its treatment of selector resolvers, which were designed in such a way to allow substitute resolving behavior, promoting selectors as the declarative interface by which a developer expresses their data needs. The approach is a bit more simplistic than what's being explored here, having no built-in notion of data freshness or re-requests, though these may be added later. There's more discussion at WordPress/gutenberg#5219 . This topic seems to be a popular topic of late, with Dan Abramov's JSConf Iceland talk covering a similar approach with "React Suspense" and promised fetchers. Apologies I've not yet had an opportunity to take a deeper dive into the approach proposed here. |
Here are some similarities to what I've been building in my still-unnamed
Some notable differences…
that is, mine is even less coupled than the data layer currently is (see the way that my const resourceMap = ( state, { siteId, postId } ) => ( {
request: {
postIsLiked: ids.postLike( siteId, postId )
},
perform: {
likePost: [
ids.postLike( siteId, postId ), // like your cache name, see repo for explanation
() => types.likePost( siteId, postId )
]
}
} ) looking forward to seeing this in a self-contained library with a minimal usage example! |
This branch needs a rebase. Is this PR still required? |
Closing due to inactivity - please reopen if necessary |
Note: I am looking for feedback on this PR, for the ease of use, the structure of the code, and even the names of things. This is certainly not the final state of the code, but it is functional and complete enough to share with others and talk about.
This is the culmination of several iterations on a general-purpose data
requirement approach which endeavors to provide data to React components
in the simplest, most declarative way possible. It also endeavors to be
easy to hook in new APIs, endpoints, and data selectors. For the
endpoints, selectors, and React Components, it becomes possible to use
them on multiple platforms by implementing only the API layer below it in each
environment. (e.g. using
data-layer
on Calypso andwithApiData
, etc.on wp-admin.)
Goals:
To Test:
localStorage.setItem( 'debug', 'rest-api-client:*' );
Leave it open to watch logging.Major pieces of the code:
registry
: RegistersapiType
s (e.g. 'woocommerce'). Holds a module global list ofapi-client
objects that can be looked up viagetApiClient
.api-client
: Handles a single type of api for a given site, identified by asiteKey
(for Calypso this is a numeric site id, but for wp-admin this will likely be the base url of the site (e.g.mysubdomain.example.com
orexample.com/mymultisite2
)ApiClientProvider
: Maps the root API State to Redux, and triggers requirement updates whenever it changes.WithApiClient
: This is a HOC that provides selectors that both retrieve data from state and establish a component's data requirements. This is what the application developer sees.apiType
: An object that defines endpoints and selectors. This is what the API developer sees.methods
: Provided toapiType
s on creation. These are platform-specific (e.g. Calypso or wp-admin).This code exhibits the full-circle path of fetching a product for the product edit page. This is what it does:
product-form
component:apiClient.selectors.getProduct( productId, { freshness: 30 * SECOND } );
with-api-client
maps the selector data to props (usingmapStateToProps
), and also updates the requirements information for that particular data within the correspondingApiClient
code. The act of updating requirements triggers an update cycle within theApiClient
.setTimeout
.ApiClientProvider
is connected to all API State, and fires off an update cycle to re-evaluate data requirements and reset the timer.product-form
is reduxconnect
-ed throughwith-api-client
and also responds to the change in state for the new API data. It re-renders and reflects the new data.Things that can trigger a requirements update cycle: