Skip to content
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

Merged
merged 20 commits into from
Mar 20, 2018
Merged

Conversation

youknowriad
Copy link
Contributor

This PR is not polished yet as it's just an exploration at the moment.
It introduces two new APIs:

  • When registering selectors we can attach side effects to each selector like this:
wp.data.registerSelectors( { 
  mySelector: {
    select: ( state, ...args ) => state.something // the real selector
    effect: ( ...args ) => { // do something with the args }
  }
} );
  • a new Higher Order Component you can use to resolve data (run selectors and their side effects)
wp.data.withData( ( resolve ) => ( {
  categories: resolve( 'core' ).getCategories()
} ) );

the withData behaves closely to the withSelect HoC but also runs the side effects attached to each selector. The other difference is that the select function passed as arg to the withSelect HoC returns the real result of the selector when called while the resolve function passed as arg to the withData 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 the categories bloc to test it.

@youknowriad youknowriad added the [Feature] Extensibility The ability to extend blocks or the editing experience label Feb 23, 2018
@youknowriad youknowriad self-assigned this Feb 23, 2018
@youknowriad youknowriad requested review from gziolo and aduth February 23, 2018 15:35
@aduth
Copy link
Member

aduth commented Feb 23, 2018

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 undefined from a selector if and only if data needs to be requested. Alternatively, we could make this part of the resolver registration API:

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 registerResolvers could track the promises returned by fulfill (keyed by arguments passed to selector), attaching any future fulfillment requests via Promise#then. Roughly:

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:

  • How does the withState mapping get called again after resolver has been fulfilled?
  • How does data get refreshed? (Does it need to be?)

@youknowriad youknowriad force-pushed the try/selector-side-effects branch from 73c3599 to 4528ea6 Compare February 28, 2018 08:34
@youknowriad
Copy link
Contributor Author

youknowriad commented Feb 28, 2018

Update the PR and implemented something close to what's proposed by @aduth
There's a different in how isFulfilled work. Instead of providing the result of the selector as an arg, I'm providing the state and the args. The idea is that sometimes we want to always return an array from the selector even if the state is still undefined (My example getCategories matches this use-case).

Also, I was able to avoid running the same request over and over again while it's inflight by using memize for the fulfill funciton. That way if the args are equivalent we avoid calling the method over and over again.

The issue with this could be the memory because the cache will keep growing that's why I have a todo in my code, I'm proposing adding an API like this to memize cc @aduth :

memoizedFunction.clearCacheItem( ...args )

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
Copy link
Member

@aduth aduth Mar 1, 2018

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

Copy link
Member

@aduth aduth Mar 1, 2018

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 example getCategories 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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

getCategories: {
isFulfilled: ( state ) => state !== undefined,
fulfill: () => {
wp.apiRequest( { path: '/wp/v2/categories' } ).then( categories => {
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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 );
Copy link
Member

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?

Copy link
Contributor Author

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.

@youknowriad
Copy link
Contributor Author

After a discussion with @gziolo around the need of isFulfilled, I updated this PR to avoid it entirely and just rely on the fact that fulfill is memoized which also makes the implementation so simple.

@gziolo
Copy link
Member

gziolo commented Mar 5, 2018

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 data module. I'm not quite sure what is the best way to tackle it, but speaking myself we should look for alternative solutions here.

We also need to keep in mind that fulfill needs to be able to reset its state when it errors, so it could re-try to fetch the data. This is where memoization can get tricky. We should explore it further. I don't know how it compares with the current implementation of withApiData though. Maybe it isn't it an issue at the moment.

/**
* Module Constants
*/
const MODULE_KEY = 'core';
Copy link
Member

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.

Copy link
Contributor Author

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
Copy link
Member

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 👍

*/
const MODULE_KEY = 'core';

const store = registerReducer( MODULE_KEY, reducer );
Copy link
Member

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?

Copy link
Contributor Author

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 ;)

registerSelectors( MODULE_KEY, { getCategories } );
registerResolvers( MODULE_KEY, {
getCategories: {
fulfill: () => {
Copy link
Member

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() {

Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

@gziolo gziolo Mar 6, 2018

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.

Copy link
Member

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;
}

@@ -0,0 +1,13 @@
const reducer = ( state, action ) => {
Copy link
Member

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 ) } )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we spreading?

Copy link
Contributor Author

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;
Copy link
Member

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 ];
Copy link
Member

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 );
Copy link
Member

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?

@@ -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.
Copy link
Member

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 😬

Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

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).

@youknowriad
Copy link
Contributor Author

Let's discuss this approach, if you all think, this approach is good enough, I'm happy to consolidate, add some unit tests...

@youknowriad youknowriad force-pushed the try/selector-side-effects branch from d3f6e3d to d741b82 Compare March 6, 2018 08:17
@@ -204,8 +205,8 @@ class CategoriesBlock extends Component {
}
}

export default withAPIData( () => {
export default withSelect( ( select ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

@gziolo
Copy link
Member

gziolo commented Mar 6, 2018

I like the public API (minus fulfill layer :trollface:), so you definitely have my vote to proceed 👍 We can discuss at the Core JS meeting today.

@aduth
Copy link
Member

aduth commented Mar 12, 2018

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 data module, and it dawned on me that as implemented, we will pass the arguments of the selector, but not state to the resolver. Is that what we'd expect, or should we also pass state to align directly with the signature of the selector?

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 fulfill. An idea to achieve the former pretty easily is to apply a filter for the selector result, to which resolvers could simply hook. This seemed like an easy way to expose filterability of selectors. But the more I thought about it, the more I was worried about how this could be misused, or cause unpredictability of selector results. The one area I thought it could be useful - overriding the isEditedPostDirty of editor to account for edit-post's hasActiveMetaBoxes (so the save button is always active) - made this illustration more real. We'd not want to override the selector for all usage, just specifically for what edit-post is using it for. Ideally we'd have some easy pattern for composing selectors from other stores, so we could have a custom selector for edit-post which extends the result of isEditedPostDirty. Technically we could just call select from within the selector, but it's not clear whether this is the cleanest approach.

I wondered too about how we'd want to suggest reusing action creators in the callback of the resolver's promise. In our core-data example, we dispatch directly without an action creator. But I don't think this is the usage we'd want to promote, if we're otherwise suggesting dispatching via defined action creators as the main mechanism for manipulating state. We could, as in the example above, call dispatch to invoke a registered action creator. Another option is exposing action creators on the store instances itself, so we could call store.actions.setPrice via the return value of registerStore. It would be nice if we could do some sort of promise resolution to an action creator, but as defined currently in the example this wouldn't be very easy to achieve. We'd need to move the action creators out of the inline registerStore call to reuse:

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 registerStore options object). In the above case, if the return value of a resolver is a promise which itself resolves to an action, we'd infer to dispatch that action object.

@@ -0,0 +1,34 @@
/**
Copy link
Member

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.

resolvers: {
getCategories() {
wp.apiRequest( { path: '/wp/v2/categories' } ).then( ( categories ) => {
store.dispatch( {
Copy link
Member

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.

Copy link
Member

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.

@aduth aduth force-pushed the try/selector-side-effects branch from 70f09dc to c60b808 Compare March 16, 2018 01:56
@aduth aduth mentioned this pull request Mar 16, 2018
3 tasks
@aduth aduth force-pushed the try/selector-side-effects branch 2 times, most recently from 9ebbbd0 to 76c0755 Compare March 16, 2018 16:17
@aduth
Copy link
Member

aduth commented Mar 16, 2018

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 async / await (ES2017), so this is certainly going to be a new concept for many, but one which I think lends to very readable resolvers once understood:

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:

  • A function returning undefined
  • A function returning an action object
  • A function returning an array of action objects
  • A function returning a Promise resolving to an action object
  • A function returning a Promise resolving to an array of action objects
  • A generator function yielding action objects
  • A generator function yielding Promises resolving to action objects
  • An async generator function yielding resolved action objects

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 isRequestingCategories selector to set a standard for how we can architect requesting state, since I tend to dislike the verbose START / SUCCESS / FAILURE action type pairings. First in ffb0760 with a requested state, then later as part of refactoring to generic terms dbdf02c. The basic idea is that we know a fetch is in progress if the current data state is unassigned and we know that the request has at one point in time been initiated.

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.

@aduth aduth force-pushed the try/selector-side-effects branch from 76c0755 to f1261b6 Compare March 20, 2018 00:50
@aduth
Copy link
Member

aduth commented Mar 20, 2018

@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 isActionLike is indeed duck typing, it aligns with what Redux imposes as a requirement for an action (string type). The idea of supporting other yield-ables isn't certain, and I don't think would be too difficult to identify. Certainly not worth muddying the API with saga-like method vocabulary, in my opinion.

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 withAPIData use and stress-testing with complex paginated resources like a functioning author/category picker), I'm going to merge this in its current form.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Extensibility The ability to extend blocks or the editing experience
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants