diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 53faeeddbeba6a..f3d28d4c555f25 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -5,6 +5,8 @@ ### New Features - Enabled thunks by default for all stores and removed the `__experimentalUseThunks` flag. +- Store the resolution errors in store metadata and expose them using `hasResolutionFailed` the `getResolutionError` meta-selectors ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). +- Expose the resolution status (undefined, resolving, finished, error) via the `getResolutionState` meta-selector ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). ## 6.2.1 (2022-02-10) diff --git a/packages/data/src/redux-store/index.js b/packages/data/src/redux-store/index.js index 2c7abbc7ff1d16..f17cc1bb8c460e 100644 --- a/packages/data/src/redux-store/index.js +++ b/packages/data/src/redux-store/index.js @@ -330,20 +330,35 @@ function mapResolveSelectors( selectors, store ) { 'getCachedResolvers', ] ), ( selector, selectorName ) => ( ...args ) => - new Promise( ( resolve ) => { + new Promise( ( resolve, reject ) => { const hasFinished = () => selectors.hasFinishedResolution( selectorName, args ); + const finalize = ( result ) => { + const hasFailed = selectors.hasResolutionFailed( + selectorName, + args + ); + if ( hasFailed ) { + const error = selectors.getResolutionError( + selectorName, + args + ); + reject( error ); + } else { + resolve( result ); + } + }; const getResult = () => selector.apply( null, args ); // trigger the selector (to trigger the resolver) const result = getResult(); if ( hasFinished() ) { - return resolve( result ); + return finalize( result ); } const unsubscribe = store.subscribe( () => { if ( hasFinished() ) { unsubscribe(); - resolve( getResult() ); + finalize( getResult() ); } } ); } ) @@ -413,15 +428,28 @@ function mapResolvers( resolvers, selectors, store, resolversCache ) { store.dispatch( metadataActions.startResolution( selectorName, args ) ); - await fulfillResolver( - store, - mappedResolvers, - selectorName, - ...args - ); - store.dispatch( - metadataActions.finishResolution( selectorName, args ) - ); + try { + await fulfillResolver( + store, + mappedResolvers, + selectorName, + ...args + ); + store.dispatch( + metadataActions.finishResolution( + selectorName, + args + ) + ); + } catch ( error ) { + store.dispatch( + metadataActions.failResolution( + selectorName, + args, + error + ) + ); + } } ); } diff --git a/packages/data/src/redux-store/metadata/actions.js b/packages/data/src/redux-store/metadata/actions.js index 01e4bbbb97424e..56ba0b4bb581df 100644 --- a/packages/data/src/redux-store/metadata/actions.js +++ b/packages/data/src/redux-store/metadata/actions.js @@ -32,13 +32,32 @@ export function finishResolution( selectorName, args ) { }; } +/** + * Returns an action object used in signalling that selector resolution has + * failed. + * + * @param {string} selectorName Name of selector for which resolver triggered. + * @param {unknown[]} args Arguments to associate for uniqueness. + * @param {Error|unknown} error The error that caused the failure. + * + * @return {{ type: 'FAIL_RESOLUTION', selectorName: string, args: unknown[], error: Error|unknown }} Action object. + */ +export function failResolution( selectorName, args, error ) { + return { + type: 'FAIL_RESOLUTION', + selectorName, + args, + error, + }; +} + /** * Returns an action object used in signalling that a batch of selector resolutions has * started. * - * @param {string} selectorName Name of selector for which resolver triggered. + * @param {string} selectorName Name of selector for which resolver triggered. * @param {unknown[][]} args Array of arguments to associate for uniqueness, each item - * is associated to a resolution. + * is associated to a resolution. * * @return {{ type: 'START_RESOLUTIONS', selectorName: string, args: unknown[][] }} Action object. */ @@ -54,9 +73,9 @@ export function startResolutions( selectorName, args ) { * Returns an action object used in signalling that a batch of selector resolutions has * completed. * - * @param {string} selectorName Name of selector for which resolver triggered. + * @param {string} selectorName Name of selector for which resolver triggered. * @param {unknown[][]} args Array of arguments to associate for uniqueness, each item - * is associated to a resolution. + * is associated to a resolution. * * @return {{ type: 'FINISH_RESOLUTIONS', selectorName: string, args: unknown[][] }} Action object. */ @@ -68,6 +87,26 @@ export function finishResolutions( selectorName, args ) { }; } +/** + * Returns an action object used in signalling that a batch of selector resolutions has + * completed and at least one of them has failed. + * + * @param {string} selectorName Name of selector for which resolver triggered. + * @param {unknown[]} args Array of arguments to associate for uniqueness, each item + * is associated to a resolution. + * @param {(Error|unknown)[]} errors Array of errors to associate for uniqueness, each item + * is associated to a resolution. + * @return {{ type: 'FAIL_RESOLUTIONS', selectorName: string, args: unknown[], errors: Array }} Action object. + */ +export function failResolutions( selectorName, args, errors ) { + return { + type: 'FAIL_RESOLUTIONS', + selectorName, + args, + errors, + }; +} + /** * Returns an action object used in signalling that we should invalidate the resolution cache. * diff --git a/packages/data/src/redux-store/metadata/reducer.ts b/packages/data/src/redux-store/metadata/reducer.ts index ae40e7f0a275bc..fd9aeaa343115e 100644 --- a/packages/data/src/redux-store/metadata/reducer.ts +++ b/packages/data/src/redux-store/metadata/reducer.ts @@ -13,15 +13,23 @@ import { selectorArgsToStateKey, onSubKey } from './utils'; type Action = | ReturnType< typeof import('./actions').startResolution > | ReturnType< typeof import('./actions').finishResolution > + | ReturnType< typeof import('./actions').failResolution > | ReturnType< typeof import('./actions').startResolutions > | ReturnType< typeof import('./actions').finishResolutions > + | ReturnType< typeof import('./actions').failResolutions > | ReturnType< typeof import('./actions').invalidateResolution > | ReturnType< typeof import('./actions').invalidateResolutionForStore > | ReturnType< typeof import('./actions').invalidateResolutionForStoreSelector >; -export type State = EquivalentKeyMap< unknown[] | unknown, boolean >; +type StateKey = unknown[] | unknown; +export type StateValue = + | { status: 'resolving' | 'finished' } + | { status: 'error'; error: Error | unknown }; + +export type Status = StateValue[ 'status' ]; +export type State = EquivalentKeyMap< StateKey, StateValue >; /** * Reducer function returning next state for selector resolution of @@ -34,23 +42,64 @@ const subKeysIsResolved: Reducer< Record< string, State >, Action > = onSubKey< Action >( 'selectorName' )( ( state = new EquivalentKeyMap(), action: Action ) => { switch ( action.type ) { - case 'START_RESOLUTION': + case 'START_RESOLUTION': { + const nextState = new EquivalentKeyMap( state ); + nextState.set( selectorArgsToStateKey( action.args ), { + status: 'resolving', + } ); + return nextState; + } case 'FINISH_RESOLUTION': { - const isStarting = action.type === 'START_RESOLUTION'; const nextState = new EquivalentKeyMap( state ); - nextState.set( selectorArgsToStateKey( action.args ), isStarting ); + nextState.set( selectorArgsToStateKey( action.args ), { + status: 'finished', + } ); + return nextState; + } + case 'FAIL_RESOLUTION': { + const nextState = new EquivalentKeyMap( state ); + nextState.set( selectorArgsToStateKey( action.args ), { + status: 'error', + error: action.error, + } ); + return nextState; + } + case 'START_RESOLUTIONS': { + const nextState = new EquivalentKeyMap( state ); + for ( const resolutionArgs of action.args ) { + nextState.set( selectorArgsToStateKey( resolutionArgs ), { + status: 'resolving', + } ); + } return nextState; } - case 'START_RESOLUTIONS': case 'FINISH_RESOLUTIONS': { - const isStarting = action.type === 'START_RESOLUTIONS'; const nextState = new EquivalentKeyMap( state ); for ( const resolutionArgs of action.args ) { + nextState.set( selectorArgsToStateKey( resolutionArgs ), { + status: 'finished', + } ); + } + return nextState; + } + case 'FAIL_RESOLUTIONS': { + const nextState = new EquivalentKeyMap( state ); + action.args.forEach( ( resolutionArgs, idx ) => { + const resolutionState: StateValue = { + status: 'error', + error: undefined, + }; + + const error = action.errors[ idx ]; + if ( error ) { + resolutionState.error = error; + } + nextState.set( - selectorArgsToStateKey( resolutionArgs ), - isStarting + selectorArgsToStateKey( resolutionArgs as unknown[] ), + resolutionState ); - } + } ); return nextState; } case 'INVALIDATE_RESOLUTION': { @@ -82,8 +131,10 @@ const isResolved = ( state: Record< string, State > = {}, action: Action ) => { : state; case 'START_RESOLUTION': case 'FINISH_RESOLUTION': + case 'FAIL_RESOLUTION': case 'START_RESOLUTIONS': case 'FINISH_RESOLUTIONS': + case 'FAIL_RESOLUTIONS': case 'INVALIDATE_RESOLUTION': return subKeysIsResolved( state, action ); } diff --git a/packages/data/src/redux-store/metadata/selectors.js b/packages/data/src/redux-store/metadata/selectors.js index ca5e1d8d8404d5..5f05374d866cc6 100644 --- a/packages/data/src/redux-store/metadata/selectors.js +++ b/packages/data/src/redux-store/metadata/selectors.js @@ -9,9 +9,11 @@ import { get } from 'lodash'; import { selectorArgsToStateKey } from './utils'; /** @typedef {Record} State */ +/** @typedef {import('./reducer').StateValue} StateValue */ +/** @typedef {import('./reducer').Status} Status */ /** - * Returns the raw `isResolving` value for a given selector name, + * Returns the raw resolution state value for a given selector name, * and arguments set. May be undefined if the selector has never been resolved * or not resolved for the given set of arguments, otherwise true or false for * resolution started and completed respectively. @@ -20,17 +22,35 @@ import { selectorArgsToStateKey } from './utils'; * @param {string} selectorName Selector name. * @param {unknown[]?} args Arguments passed to selector. * - * @return {boolean | undefined} isResolving value. + * @return {StateValue|undefined} isResolving value. */ -export function getIsResolving( state, selectorName, args ) { +export function getResolutionState( state, selectorName, args ) { const map = get( state, [ selectorName ] ); if ( ! map ) { - return undefined; + return; } return map.get( selectorArgsToStateKey( args ) ); } +/** + * Returns the raw `isResolving` value for a given selector name, + * and arguments set. May be undefined if the selector has never been resolved + * or not resolved for the given set of arguments, otherwise true or false for + * resolution started and completed respectively. + * + * @param {State} state Data state. + * @param {string} selectorName Selector name. + * @param {unknown[]?} args Arguments passed to selector. + * + * @return {boolean | undefined} isResolving value. + */ +export function getIsResolving( state, selectorName, args ) { + const resolutionState = getResolutionState( state, selectorName, args ); + + return resolutionState && resolutionState.status === 'resolving'; +} + /** * Returns true if resolution has already been triggered for a given * selector name, and arguments set. @@ -42,7 +62,7 @@ export function getIsResolving( state, selectorName, args ) { * @return {boolean} Whether resolution has been triggered. */ export function hasStartedResolution( state, selectorName, args ) { - return getIsResolving( state, selectorName, args ) !== undefined; + return getResolutionState( state, selectorName, args ) !== undefined; } /** @@ -56,7 +76,38 @@ export function hasStartedResolution( state, selectorName, args ) { * @return {boolean} Whether resolution has completed. */ export function hasFinishedResolution( state, selectorName, args ) { - return getIsResolving( state, selectorName, args ) === false; + const status = getResolutionState( state, selectorName, args )?.status; + return status === 'finished' || status === 'error'; +} + +/** + * Returns true if resolution has failed for a given selector + * name, and arguments set. + * + * @param {State} state Data state. + * @param {string} selectorName Selector name. + * @param {unknown[]?} args Arguments passed to selector. + * + * @return {boolean} Has resolution failed + */ +export function hasResolutionFailed( state, selectorName, args ) { + return getResolutionState( state, selectorName, args )?.status === 'error'; +} + +/** + * Returns the resolution error for a given selector name, and arguments set. + * Note it may be of an Error type, but may also be null, undefined, or anything else + * that can be `throw`-n. + * + * @param {State} state Data state. + * @param {string} selectorName Selector name. + * @param {unknown[]?} args Arguments passed to selector. + * + * @return {Error|unknown} Last resolution error + */ +export function getResolutionError( state, selectorName, args ) { + const resolutionState = getResolutionState( state, selectorName, args ); + return resolutionState?.status === 'error' ? resolutionState.error : null; } /** @@ -70,7 +121,9 @@ export function hasFinishedResolution( state, selectorName, args ) { * @return {boolean} Whether resolution is in progress. */ export function isResolving( state, selectorName, args ) { - return getIsResolving( state, selectorName, args ) === true; + return ( + getResolutionState( state, selectorName, args )?.status === 'resolving' + ); } /** diff --git a/packages/data/src/redux-store/metadata/test/reducer.js b/packages/data/src/redux-store/metadata/test/reducer.js index 8656d6f106e58c..374cddff29f71d 100644 --- a/packages/data/src/redux-store/metadata/test/reducer.js +++ b/packages/data/src/redux-store/metadata/test/reducer.js @@ -23,8 +23,10 @@ describe( 'reducer', () => { args: [], } ); - // { test: { getFoo: EquivalentKeyMap( [] => true ) } } - expect( state.getFoo.get( [] ) ).toBe( true ); + // { test: { getFoo: EquivalentKeyMap( [] => status: 'resolving } ) } } + expect( state.getFoo.get( [] ) ).toEqual( { + status: 'resolving', + } ); } ); it( 'should return with finished resolution', () => { @@ -39,8 +41,10 @@ describe( 'reducer', () => { args: [], } ); - // { test: { getFoo: EquivalentKeyMap( [] => false ) } } - expect( state.getFoo.get( [] ) ).toBe( false ); + // { test: { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) } } + expect( state.getFoo.get( [] ) ).toEqual( { + status: 'finished', + } ); } ); it( 'should remove invalidations', () => { @@ -81,9 +85,13 @@ describe( 'reducer', () => { args: [ 'block' ], } ); - // { getFoo: EquivalentKeyMap( [] => false ) } - expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( true ); + // { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) } + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'finished', + } ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'resolving', + } ); } ); it( @@ -123,8 +131,10 @@ describe( 'reducer', () => { } ); expect( state.getBar ).toBeUndefined(); - // { getFoo: EquivalentKeyMap( [] => false ) } - expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); + // { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) } + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'finished', + } ); } ); @@ -134,14 +144,18 @@ describe( 'reducer', () => { selectorName: 'getFoo', args: [ 1, undefined ], } ); - expect( started.getFoo.get( [ 1 ] ) ).toBe( true ); + expect( started.getFoo.get( [ 1 ] ) ).toEqual( { + status: 'resolving', + } ); const finished = reducer( started, { type: 'FINISH_RESOLUTION', selectorName: 'getFoo', args: [ 1, undefined, undefined ], } ); - expect( finished.getFoo.get( [ 1 ] ) ).toBe( false ); + expect( finished.getFoo.get( [ 1 ] ) ).toEqual( { + status: 'finished', + } ); } ); } ); @@ -153,8 +167,12 @@ describe( 'reducer', () => { args: [ [ 'post' ], [ 'block' ] ], } ); - expect( state.getFoo.get( [ 'post' ] ) ).toBe( true ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( true ); + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'resolving', + } ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'resolving', + } ); } ); it( 'should return with finished resolutions', () => { @@ -169,8 +187,12 @@ describe( 'reducer', () => { args: [ [ 'post' ], [ 'block' ] ], } ); - expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( false ); + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'finished', + } ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'finished', + } ); } ); it( 'should remove invalidations', () => { @@ -191,7 +213,9 @@ describe( 'reducer', () => { } ); expect( state.getFoo.get( [ 'post' ] ) ).toBe( undefined ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( false ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'finished', + } ); } ); it( 'different arguments should not conflict', () => { @@ -211,8 +235,12 @@ describe( 'reducer', () => { args: [ [ 'block' ] ], } ); - expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( true ); + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'finished', + } ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'resolving', + } ); } ); it( @@ -252,8 +280,12 @@ describe( 'reducer', () => { } ); expect( state.getBar ).toBeUndefined(); - expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); - expect( state.getFoo.get( [ 'block' ] ) ).toBe( false ); + expect( state.getFoo.get( [ 'post' ] ) ).toEqual( { + status: 'finished', + } ); + expect( state.getFoo.get( [ 'block' ] ) ).toEqual( { + status: 'finished', + } ); } ); @@ -266,8 +298,12 @@ describe( 'reducer', () => { [ 2, undefined, undefined ], ], } ); - expect( started.getFoo.get( [ 1 ] ) ).toBe( true ); - expect( started.getFoo.get( [ 2 ] ) ).toBe( true ); + expect( started.getFoo.get( [ 1 ] ) ).toEqual( { + status: 'resolving', + } ); + expect( started.getFoo.get( [ 2 ] ) ).toEqual( { + status: 'resolving', + } ); const finished = reducer( started, { type: 'FINISH_RESOLUTIONS', @@ -277,8 +313,12 @@ describe( 'reducer', () => { [ 2, undefined ], ], } ); - expect( finished.getFoo.get( [ 1 ] ) ).toBe( false ); - expect( finished.getFoo.get( [ 2 ] ) ).toBe( false ); + expect( finished.getFoo.get( [ 1 ] ) ).toEqual( { + status: 'finished', + } ); + expect( finished.getFoo.get( [ 2 ] ) ).toEqual( { + status: 'finished', + } ); } ); } ); } ); diff --git a/packages/data/src/redux-store/metadata/test/selectors.js b/packages/data/src/redux-store/metadata/test/selectors.js index 6d119ca2ae99e6..c2c7ed8d03eb63 100644 --- a/packages/data/src/redux-store/metadata/test/selectors.js +++ b/packages/data/src/redux-store/metadata/test/selectors.js @@ -1,117 +1,327 @@ /** - * External dependencies + * WordPress dependencies */ -import EquivalentKeyMap from 'equivalent-key-map'; +import { createRegistry } from '@wordpress/data'; -/** - * Internal dependencies - */ -import { - getIsResolving, - hasStartedResolution, - hasFinishedResolution, - isResolving, -} from '../selectors'; +jest.useRealTimers(); +const testStore = { + reducer: ( state = null, action ) => { + if ( action.type === 'RECEIVE' ) { + return action.items; + } + + return state; + }, + selectors: { + getFoo: ( state ) => state, + }, +}; + +async function resolve( registry, selector ) { + try { + await registry.resolveSelect( 'store' )[ selector ](); + } catch ( e ) {} +} describe( 'getIsResolving', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + registry.registerStore( 'testStore', testStore ); + } ); + it( 'should return undefined if no state by reducerKey, selectorName', () => { - const state = {}; - const result = getIsResolving( state, 'getFoo', [] ); + const result = registry + .select( 'testStore' ) + .getIsResolving( 'getFoo', [] ); expect( result ).toBe( undefined ); } ); it( 'should return undefined if state by reducerKey, selectorName, but not args', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - const result = getIsResolving( state, 'getFoo', [ 'bar' ] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const result = registry + .select( 'testStore' ) + .getIsResolving( 'getFoo', [ 'bar' ] ); expect( result ).toBe( undefined ); } ); it( 'should return value by reducerKey, selectorName', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - const result = getIsResolving( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const result = registry + .select( 'testStore' ) + .getIsResolving( 'getFoo', [] ); expect( result ).toBe( true ); } ); it( 'should normalize args ard return the right value', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - expect( getIsResolving( state, 'getFoo' ) ).toBe( true ); - expect( getIsResolving( state, 'getFoo', [ undefined ] ) ).toBe( true ); - expect( - getIsResolving( state, 'getFoo', [ undefined, undefined ] ) - ).toBe( true ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const { getIsResolving } = registry.select( 'testStore' ); + + expect( getIsResolving( 'getFoo' ) ).toBe( true ); + expect( getIsResolving( 'getFoo', [ undefined ] ) ).toBe( true ); + expect( getIsResolving( 'getFoo', [ undefined, undefined ] ) ).toBe( + true + ); } ); } ); describe( 'hasStartedResolution', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + registry.registerStore( 'testStore', testStore ); + } ); + it( 'returns false if not has started', () => { - const state = {}; - const result = hasStartedResolution( state, 'getFoo', [] ); + const result = registry + .select( 'testStore' ) + .hasStartedResolution( 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has started', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - const result = hasStartedResolution( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const { hasStartedResolution } = registry.select( 'testStore' ); + const result = hasStartedResolution( 'getFoo', [] ); expect( result ).toBe( true ); } ); } ); describe( 'hasFinishedResolution', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + registry.registerStore( 'testStore', testStore ); + } ); + it( 'returns false if not has finished', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - const result = hasFinishedResolution( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const { hasFinishedResolution } = registry.select( 'testStore' ); + const result = hasFinishedResolution( 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has finished', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), - }; - const result = hasFinishedResolution( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] ); + const { hasFinishedResolution } = registry.select( 'testStore' ); + const result = hasFinishedResolution( 'getFoo', [] ); expect( result ).toBe( true ); } ); } ); describe( 'isResolving', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + registry.registerStore( 'testStore', testStore ); + } ); + it( 'returns false if not has started', () => { - const state = {}; - const result = isResolving( state, 'getFoo', [] ); + const { isResolving } = registry.select( 'testStore' ); + const result = isResolving( 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns false if has finished', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), - }; - const result = isResolving( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] ); + const { isResolving } = registry.select( 'testStore' ); + const result = isResolving( 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has started but not finished', () => { - const state = { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }; - const result = isResolving( state, 'getFoo', [] ); + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const { isResolving } = registry.select( 'testStore' ); + const result = isResolving( 'getFoo', [] ); expect( result ).toBe( true ); } ); } ); + +describe( 'hasResolutionFailed', () => { + let registry; + + beforeEach( () => { + registry = createRegistry(); + } ); + + it( 'returns false if the resolution has succeeded', async () => { + registry.registerStore( 'store', { + reducer: ( state = null, action ) => { + if ( action.type === 'RECEIVE' ) { + return action.items; + } + + return state; + }, + selectors: { + getFoo: ( state ) => state, + }, + resolvers: { + getFoo: () => {}, + }, + } ); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeFalsy(); + + registry.select( 'store' ).getFoo(); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeFalsy(); + } ); + + it( 'returns true if the resolution has failed', async () => { + registry.registerStore( 'store', { + reducer: ( state = null, action ) => { + if ( action.type === 'RECEIVE' ) { + return action.items; + } + + return state; + }, + selectors: { + getFoo: ( state ) => state, + }, + resolvers: { + getFoo: () => { + throw new Error( 'cannot fetch items' ); + }, + }, + } ); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeFalsy(); + + await resolve( registry, 'getFoo' ); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeTruthy(); + } ); + + it( 'returns true if the resolution has failed even if the error is falsy', async () => { + registry.registerStore( 'store', { + reducer: ( state = null, action ) => { + if ( action.type === 'RECEIVE' ) { + return action.items; + } + + return state; + }, + selectors: { + getFoo: ( state ) => state, + }, + resolvers: { + getFoo: () => { + throw null; + }, + }, + } ); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeFalsy(); + + await resolve( registry, 'getFoo' ); + + expect( + registry.select( 'store' ).hasResolutionFailed( 'getFoo' ) + ).toBeTruthy(); + } ); +} ); + +describe( 'getResolutionError', () => { + let registry; + let shouldFail; + + beforeEach( () => { + shouldFail = false; + registry = createRegistry(); + + registry.registerStore( 'store', { + reducer: ( state = null, action ) => { + if ( action.type === 'RECEIVE' ) { + return action.items; + } + + return state; + }, + selectors: { + getFoo: ( state ) => state, + }, + resolvers: { + getFoo: () => { + if ( shouldFail ) { + throw new Error( 'cannot fetch items' ); + } + }, + }, + } ); + } ); + + it( 'returns undefined if the resolution has succeeded', async () => { + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + + registry.select( 'store' ).getFoo(); + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + } ); + + it( 'returns error if the resolution has failed', async () => { + shouldFail = true; + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + + await resolve( registry, 'getFoo' ); + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ).toString() + ).toBe( 'Error: cannot fetch items' ); + } ); + + it( 'returns undefined if the failed resolution succeeded after retry', async () => { + shouldFail = true; + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + + await resolve( registry, 'getFoo' ); + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeTruthy(); + + registry.dispatch( 'store' ).invalidateResolution( 'getFoo', [] ); + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + + shouldFail = false; + registry.select( 'store' ).getFoo(); + + expect( + registry.select( 'store' ).getResolutionError( 'getFoo' ) + ).toBeFalsy(); + } ); +} ); diff --git a/packages/data/src/redux-store/test/index.js b/packages/data/src/redux-store/test/index.js index 9cefbe37b120a3..cfe14a6a1b5955 100644 --- a/packages/data/src/redux-store/test/index.js +++ b/packages/data/src/redux-store/test/index.js @@ -235,3 +235,45 @@ describe( 'controls', () => { } ); } ); } ); + +describe( 'resolveSelect', () => { + let registry; + let shouldFail; + + beforeEach( () => { + shouldFail = false; + registry = createRegistry(); + + registry.registerStore( 'store', { + reducer: ( state = null ) => { + return state; + }, + selectors: { + getItems: () => 'items', + }, + resolvers: { + getItems: () => { + if ( shouldFail ) { + throw new Error( 'cannot fetch items' ); + } + }, + }, + } ); + } ); + + it( 'resolves when the resolution succeeded', async () => { + shouldFail = false; + const promise = registry.resolveSelect( 'store' ).getItems(); + jest.runAllTimers(); + await expect( promise ).resolves.toEqual( 'items' ); + } ); + + it( 'rejects when the resolution failed', async () => { + shouldFail = true; + const promise = registry.resolveSelect( 'store' ).getItems(); + jest.runAllTimers(); + await expect( promise ).rejects.toEqual( + new Error( 'cannot fetch items' ) + ); + } ); +} ); diff --git a/packages/data/src/resolvers-cache-middleware.js b/packages/data/src/resolvers-cache-middleware.js index 2a1350a8bd6b98..5b71239b0f0089 100644 --- a/packages/data/src/resolvers-cache-middleware.js +++ b/packages/data/src/resolvers-cache-middleware.js @@ -38,10 +38,11 @@ const createResolversCacheMiddleware = ( registry, reducerKey ) => () => ( } resolversByArgs.forEach( ( value, args ) => { // resolversByArgs is the map Map([ args ] => boolean) storing the cache resolution status for a given selector. - // If the value is false it means this resolver has finished its resolution which means we need to invalidate it, - // if it's true it means it's inflight and the invalidation is not necessary. + // If the value is "finished" or "error" it means this resolver has finished its resolution which means we need + // to invalidate it, if it's true it means it's inflight and the invalidation is not necessary. if ( - value !== false || + ( value?.status !== 'finished' && + value?.status !== 'error' ) || ! resolver.shouldInvalidate( action, ...args ) ) { return;