Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@ The `resolvers` option should be passed as an object where each key is the name

Resolvers, in combination with [thunks](https://github.com/WordPress/gutenberg/blob/trunk/docs/how-to-guides/thunks.md#thunks-can-be-async), can be used to implement asynchronous data flows for your store.

##### Object-form resolvers

A resolver can be defined as an object with a `fulfill` method and optional `isFulfilled` and `shouldInvalidate` methods:

```js
resolvers: {
getPage: {
fulfill: ( id ) => async ( { dispatch } ) => {
const data = await fetchPage( id );
dispatch( { type: 'RECEIVE_PAGE', id, data } );
},
isFulfilled: ( state, id ) => !! state.pages[ id ],
shouldInvalidate: ( action, id ) => action.type === 'INVALIDATE_PAGE' && action.id === id,
},
},
Comment on lines +130 to +139
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't technically a fulfill a resolver itself -getPage and then isFulfilled can be added as its object methods, or do folks have to use this pattern? A similar to how shouldInvalidate works.

Mostly, asking because the latter pattern is more common in core and probably what AI or copy-paste will be used for.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's right, it can be either a { fulfill, isFulfilled, shouldInvalidate } object, or a fullfill function with two optional fields. It will be always normalized internally to the object form.

I added a notice about it to the docs, and also documented shouldInvalidate, which is another optional resolver extension.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, @jsnajdr!

```

A resolver can also be a plain function with `isFulfilled` or `shouldInvalidate` assigned as properties. It will be normalized into the `{ fulfill, isFulfilled, shouldInvalidate }` object form internally.

**`isFulfilled( state, ...args )`** lets you override the `hasFinishedResolution` meta selector for a given set of args. Normally, a resolver is skipped only when the resolution metadata records that it has already run for those args. With `isFulfilled`, you can look at the actual store state and decide that the data is already there: for example because it was loaded by a different resolver or preloaded server-side. When `isFulfilled` returns `true`, the `fulfill` method is not called and no resolution metadata is written.

**`shouldInvalidate( action, ...args )`** is called on every dispatched action for each set of args that has already been resolved. If it returns `true`, the resolution for those args is invalidated, causing the resolver to run again on the next selector call. This is useful for automatically re-fetching data when a related action signals that the cached result may be stale.

#### `controls` (deprecated)

To handle asynchronous data flows, it is recommended to use [thunks](https://github.com/WordPress/gutenberg/blob/trunk/docs/how-to-guides/thunks.md#thunks-can-be-async) instead of `controls`.
Expand Down
29 changes: 17 additions & 12 deletions packages/data/src/redux-store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ export default function createReduxStore< State, Actions, Selectors >(
// a promise that resolves when the resolution is finished.
const bindResolveSelector = mapResolveSelector(
store,
resolvers,
boundMetadataSelectors
);

Expand Down Expand Up @@ -633,12 +634,14 @@ function instantiateReduxStore(
* Maps selectors to functions that return a resolution promise for them.
*
* @param store The redux store the selectors are bound to.
* @param resolvers The normalized resolvers for the store.
* @param boundMetadataSelectors The bound metadata selectors.
*
* @return Function that maps selectors to resolvers.
*/
function mapResolveSelector(
store: ReduxStore,
resolvers: Record< string, NormalizedResolver >,
boundMetadataSelectors: Record< string, SelectorLike >
) {
return ( selector: SelectorLike, selectorName: string ) => {
Expand All @@ -650,10 +653,15 @@ function mapResolveSelector(

return ( ...args: unknown[] ) =>
new Promise( ( resolve, reject ) => {
const resolver = resolvers[ selectorName ];
const hasFinished = () => {
return boundMetadataSelectors.hasFinishedResolution(
selectorName,
args
return (
boundMetadataSelectors.hasFinishedResolution(
selectorName,
args
) ||
( typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( store.getState(), ...args ) )
Comment thread
Mamaduka marked this conversation as resolved.
);
};
const finalize = ( result: unknown ) => {
Expand Down Expand Up @@ -789,7 +797,9 @@ function mapSelectorWithResolver(
function fulfillSelector( args: unknown[] ): void {
if (
resolversCache.isRunning( selectorName, args ) ||
boundMetadataSelectors.hasStartedResolution( selectorName, args )
boundMetadataSelectors.hasStartedResolution( selectorName, args ) ||
( typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( store.getState(), ...args ) )
Comment thread
Mamaduka marked this conversation as resolved.
) {
return;
}
Expand All @@ -802,14 +812,9 @@ function mapSelectorWithResolver(
metadataActions.startResolution( selectorName, args )
);
try {
const isFulfilled =
typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( store.getState(), ...args );
if ( ! isFulfilled ) {
const action = resolver.fulfill( ...args );
if ( action ) {
await store.dispatch( action );
}
const action = resolver.fulfill( ...args );
if ( action ) {
await store.dispatch( action );
}
store.dispatch(
metadataActions.finishResolution( selectorName, args )
Expand Down
76 changes: 32 additions & 44 deletions packages/data/src/redux-store/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,7 @@ describe( 'resolveSelect', () => {
},
} );

const promise = registry.resolveSelect( 'demo' ).getItems();
const result = await promise;
const result = await registry.resolveSelect( 'demo' ).getItems();
expect( result ).toEqual( [ 'item' ] );
} );

Expand All @@ -332,15 +331,13 @@ describe( 'resolveSelect', () => {
},
} );

const promise1 = registry.resolveSelect( 'demo' ).getPage( 1 );
const result1 = await promise1;
const result1 = await registry.resolveSelect( 'demo' ).getPage( 1 );
expect( result1 ).toEqual( {
title: 'Page 1',
content: 'Content 1',
} );

const promise2 = registry.resolveSelect( 'demo' ).getPage( 2 );
const result2 = await promise2;
const result2 = await registry.resolveSelect( 'demo' ).getPage( 2 );
expect( result2 ).toEqual( {
title: 'Page 2',
content: 'Content 2',
Expand All @@ -350,53 +347,41 @@ describe( 'resolveSelect', () => {
expect( fulfilledResolver ).not.toHaveBeenCalled();
} );

it( 'calls resolver when isFulfilled returns false', async () => {
const fulfill = jest.fn().mockImplementation( () => ( {
type: 'SET_DATA',
data: 'resolved data',
} ) );
const isFulfilled = jest.fn( ( state ) => state.hasData );
it( 'does not change Redux state when isFulfilled returns true', async () => {
const fulfill = jest.fn();
const isFulfilled = () => true;

registry.registerStore( 'demo', {
reducer: ( state = { hasData: false }, action ) => {
if ( action.type === 'SET_DATA' ) {
return { hasData: true, data: action.data };
}
return state;
},
reducer: ( state = { items: [ 'item' ] } ) => state,
selectors: {
getData: ( state ) => state.data,
getItems: ( state ) => state.items,
},
resolvers: {
getData: { fulfill, isFulfilled },
getItems: { fulfill, isFulfilled },
},
} );

const promise = registry.resolveSelect( 'demo' ).getData();
const result = await promise;
const listener = jest.fn();
const unsubscribe = registry.subscribe( listener );

// Initial state has hasData: false, so resolver should be called
expect( isFulfilled ).toHaveBeenCalledTimes( 1 );
expect( fulfill ).toHaveBeenCalledTimes( 1 );
expect( result ).toBe( 'resolved data' );
// Call the selector — isFulfilled is true, so no resolution should happen.
registry.select( 'demo' ).getItems();

// Subsequent call should use cached result, not calling isFulfilled or fulfill again
const promise2 = registry.resolveSelect( 'demo' ).getData();
const result2 = await promise2;
expect( result2 ).toBe( 'resolved data' );
// isFulfilled is only called once since resolution is already marked as finished
expect( isFulfilled ).toHaveBeenCalledTimes( 1 );
expect( fulfill ).toHaveBeenCalledTimes( 1 ); // Still only called once
// Wait long enough for any setTimeout(0) resolver to have fired.
await new Promise( ( resolve ) => setTimeout( resolve, 10 ) );

expect( fulfill ).not.toHaveBeenCalled();
expect( listener ).not.toHaveBeenCalled();

unsubscribe();
} );

it( 'marks resolution as failed when isFulfilled throws an error', async () => {
it( 'calls resolver when isFulfilled returns false', async () => {
const fulfill = jest.fn().mockImplementation( () => ( {
type: 'SET_DATA',
data: 'resolved data',
} ) );
const isFulfilled = jest.fn( () => {
throw new Error( 'isFulfilled error' );
} );
const isFulfilled = jest.fn( ( state ) => state.hasData );

registry.registerStore( 'demo', {
reducer: ( state = { hasData: false }, action ) => {
Expand All @@ -413,14 +398,17 @@ describe( 'resolveSelect', () => {
},
} );

const promise = registry.resolveSelect( 'demo' ).getData();
await expect( promise ).rejects.toThrow( 'isFulfilled error' );
const result = await registry.resolveSelect( 'demo' ).getData();

expect( isFulfilled ).toHaveBeenCalledTimes( 1 );
expect( fulfill ).not.toHaveBeenCalled();
expect(
registry.select( 'demo' ).hasResolutionFailed( 'getData' )
).toBe( true );
// Initial state has hasData: false, so resolver should be called
expect( fulfill ).toHaveBeenCalledTimes( 1 );
expect( result ).toBe( 'resolved data' );

// Subsequent call should use cached result, not calling `fulfill` again
const result2 = await registry.resolveSelect( 'demo' ).getData();
expect( result2 ).toBe( 'resolved data' );
// `fulfill` is only called once since resolution is already marked as finished
expect( fulfill ).toHaveBeenCalledTimes( 1 );
} );
} );

Expand Down
Loading