diff --git a/packages/data/README.md b/packages/data/README.md index 76dfccdc5d46f0..d42bfb328c2662 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -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, + }, +}, +``` + +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`. diff --git a/packages/data/src/redux-store/index.ts b/packages/data/src/redux-store/index.ts index 6a4eebcb96b4b7..57399de50ac7f0 100644 --- a/packages/data/src/redux-store/index.ts +++ b/packages/data/src/redux-store/index.ts @@ -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 ); @@ -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 ) => { @@ -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 ) ) ); }; const finalize = ( result: unknown ) => { @@ -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 ) ) ) { return; } @@ -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 ) diff --git a/packages/data/src/redux-store/test/index.js b/packages/data/src/redux-store/test/index.js index e220fbd3cbe5c6..cac5e9411893cc 100644 --- a/packages/data/src/redux-store/test/index.js +++ b/packages/data/src/redux-store/test/index.js @@ -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' ] ); } ); @@ -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', @@ -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 ) => { @@ -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 ); } ); } );