diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 01ff341e295d5..26b9f7bb846c8 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -2,3 +2,4 @@ - Breaking: The `withRehdyration` function is removed. Use the persistence plugin instead. - Breaking: The `loadAndPersist` function is removed. Use the persistence plugin instead. +- Breaking: `registerSelectors`, `registerActions`, and `registerResolvers` will merge into the existing set of selectors, actions, and resolvers, if any exist (previously replaced all except those matching keys of the new set). This is intended to facilitate extensibility of singular function handlers. diff --git a/packages/data/README.md b/packages/data/README.md index 02a08b12b31c9..30ef15a514dfa 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -284,6 +284,75 @@ const SaleButton = withDispatch( ( dispatch, ownProps ) => { // Start Sale! ``` +## Extensibility + +By design, a registry's behaviors are intended to be extensible, enabling developers to modify or replace previously-registered behaviors. For example, a developer could substitute the default resolver behavior associated with a selector to satisfy its data requirements from an alternative source: another API, a local cache, or the file system. + +_Example:_ + +Consider the following store, which exposes a simple public interface allowing a developer to request the current temperature for a given city: + +```js +function setTemperature( city, temperature ) { + return { + type: 'SET_TEMPERATURE', + city, + temperature, + }; +} + +registerStore( 'temperature', { + reducer( state = {}, action ) { + switch ( action.type ) { + case 'SET_TEMPERATURE': + return { + ...state, + [ action.city ]: action.temperature, + }; + } + + return state; + }, + actions: { + setTemperature, + }, + selectors: { + getTemperature: ( state, city ) => state[ city ], + }, + controls: { + FETCH( action ) { + return window.fetch( action.url ).then( ( response ) => response.json() ); + }, + }, + resolvers: { + * getTemperature( state, city ) { + const url = 'https://samples.openweathermap.org/data/2.5/weather?q=' + city; + const json = yield { type: 'FETCH', url }; + yield setTemperature( city, json.main.temp ); + }, + }, +} ); +``` + +The default resolver in this case requests data from [OpenWeatherMap](https://openweathermap.org/). But this is merely an implementation detail; it could request its data from any other source while retaining the same public contract: the `getTemperature` selector. + +Consider we want to change this to request its data from [Yahoo Weather API](https://developer.yahoo.com/weather/). All that is required is that we call `registerResolvers` with a new resolver function: + +```js +registerResolvers( 'temperature', { + * getTemperature( state, city ) { + const url = ( + 'https://query.yahooapis.com/v1/public/yql?format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys&q=' + + encodeURIComponent( `select * from weather.forecast where woeid in (select woeid from geo.places(1) where text="${ city }")` ) + ); + const json = yield { type: 'FETCH', url }; + yield setTemperature( city, json.query.results.channel.item.condition[ 0 ].temp ); + }, +} ); +``` + +Selectors, actions, and resolvers can be extended by calling `registerSelectors`, `registerActions`, and `registerResolvers` respectively. + ## Comparison with Redux The data module shares many of the same [core principles](https://redux.js.org/introduction/three-principles) and [API method naming](https://redux.js.org/api-reference) of [Redux](https://redux.js.org/). In fact, it is implemented atop Redux. Where it differs is in establishing a modularization pattern for creating separate but interdependent stores, and in codifying conventions such as selector functions as the primary entry point for data access. diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 273389c556d67..1a4f98f6c4069 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -8,6 +8,7 @@ import { mapValues, overEvery, get, + isEmpty, } from 'lodash'; /** @@ -144,7 +145,15 @@ export function createRegistry( storeConfigs = {} ) { enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); } const store = createStore( reducer, flowRight( enhancers ) ); - namespaces[ reducerKey ] = { store, reducer }; + namespaces[ reducerKey ] = { + store, + config: { + reducer, + selectors: {}, + actions: {}, + resolvers: {}, + }, + }; // Customize subscribe behavior to call listeners only on effective change, // not on every dispatch. @@ -163,7 +172,8 @@ export function createRegistry( storeConfigs = {} ) { } /** - * Registers selectors for external usage. + * Registers selectors for external usage. Subsequent calls will merge new + * selectors with the existing set. * * @param {string} reducerKey Part of the state shape to register the * selectors for. @@ -172,15 +182,20 @@ export function createRegistry( storeConfigs = {} ) { * state as first argument. */ function registerSelectors( reducerKey, newSelectors ) { - const store = namespaces[ reducerKey ].store; + const { store, config } = namespaces[ reducerKey ]; + + // Merge to existing selectors (intially empty). + Object.assign( config.selectors, newSelectors ); + const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args ); - namespaces[ reducerKey ].selectors = mapValues( newSelectors, createStateSelector ); + namespaces[ reducerKey ].selectors = mapValues( config.selectors, createStateSelector ); } /** * Registers resolvers for a given reducer key. Resolvers are side effects * invoked once per argument set of a given selector call, used in ensuring - * that the data needs for the selector are satisfied. + * that the data needs for the selector are satisfied. Subsequent calls + * will merge new resolvers with the existing set. * * @param {string} reducerKey Part of the state shape to register the * resolvers for. @@ -190,16 +205,36 @@ export function createRegistry( storeConfigs = {} ) { const { hasStartedResolution } = select( 'core/data' ); const { startResolution, finishResolution } = dispatch( 'core/data' ); + // In subsequent calls, since we've already wrapped selectors, "unwrap" + // them by registering the originally-configured selectors to reset + // their behavior to the default. It should be noted that this is not + // strictly necessary since `fulfill` checks that resolution has begun + // and thus would only run the most recently-registered resolver, but + // this is a very fragile guarantee, andwould leave a lingering useless + // composed resolver function wrapping the original selector. + // + // TODO: It could be considered to refactor this as a proper select + // flow via function composition or result filtering. Any refactor + // should be conscious of performance implication of frequent select + // calls. + const { config } = namespaces[ reducerKey ]; + if ( ! isEmpty( config.resolvers[ reducerKey ] ) ) { + registerSelectors( reducerKey, config.selectors ); + } + + // Merge to existing resolvers (intially empty). + Object.assign( config.resolvers, newResolvers ); + const createResolver = ( selector, selectorName ) => { // Don't modify selector behavior if no resolver exists. - if ( ! newResolvers.hasOwnProperty( selectorName ) ) { + if ( ! config.resolvers.hasOwnProperty( selectorName ) ) { return selector; } const store = namespaces[ reducerKey ].store; // Normalize resolver shape to object. - let resolver = newResolvers[ selectorName ]; + let resolver = config.resolvers[ selectorName ]; if ( ! resolver.fulfill ) { resolver = { fulfill: resolver }; } @@ -263,9 +298,13 @@ export function createRegistry( storeConfigs = {} ) { * @param {Object} newActions Actions to register. */ function registerActions( reducerKey, newActions ) { - const store = namespaces[ reducerKey ].store; + const { store, config } = namespaces[ reducerKey ]; + + // Merge to existing actions (intially empty). + Object.assign( config.actions, newActions ); + const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) ); - namespaces[ reducerKey ].actions = mapValues( newActions, createBoundAction ); + namespaces[ reducerKey ].actions = mapValues( config.actions, createBoundAction ); } /** diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index 2e4223ca52afe..694a2e1fd5d25 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -67,6 +67,127 @@ describe( 'createRegistry', () => { } ); } ); + describe( 'registerSelectors', () => { + it( 'partially applies state as the first argument to callbacks', () => { + registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 } ) => state ); + registry.registerSelectors( 'butcher', { + getPrice: ( state, meat ) => state[ meat ], + } ); + + expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 ); + } ); + + it( 'merges into existing set on subsequent calls', () => { + registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 } ) => state ); + registry.registerSelectors( 'butcher', { + getPrice: ( state, meat ) => state[ meat ], + } ); + registry.registerSelectors( 'butcher', { + getMeats: ( state ) => Object.keys( state ), + } ); + + expect( registry.select( 'butcher' ) ).toMatchObject( { + getPrice: expect.any( Function ), + getMeats: expect.any( Function ), + } ); + expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 ); + } ); + + it( 'should replace an existing selector behavior', () => { + registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 } ) => state ); + registry.registerSelectors( 'butcher', { + getPrice: ( state, meat ) => state[ meat ], + } ); + registry.registerSelectors( 'butcher', { + getPrice: () => 0, + } ); + + expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 0 ); + } ); + } ); + + describe( 'registerActions', () => { + it( 'prebinds dispatch', () => { + const store = registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 }, action ) => { + switch ( action.type ) { + case 'sale': + return { + ...state, + [ action.meat ]: state[ action.meat ] / 2, + }; + } + + return state; + } ); + registry.registerActions( 'butcher', { + startSale: ( meat ) => ( { type: 'sale', meat } ), + } ); + + registry.dispatch( 'butcher' ).startSale( 'chicken' ); + expect( store.getState() ).toEqual( { + chicken: 2, + ribs: 6, + } ); + } ); + + it( 'merges into existing set on subsequent calls', () => { + registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 }, action ) => { + switch ( action.type ) { + case 'sale': + return { + ...state, + [ action.meat ]: state[ action.meat ] / 2, + }; + + case 'free_giveaway': + return mapValues( state, () => 0 ); + } + + return state; + } ); + registry.registerActions( 'butcher', { + startSale: ( meat ) => ( { type: 'sale', meat } ), + } ); + registry.registerActions( 'butcher', { + startFreeMeatGiveaway: () => ( { type: 'free_giveaway' } ), + } ); + + expect( registry.dispatch( 'butcher' ) ).toMatchObject( { + startSale: expect.any( Function ), + startFreeMeatGiveaway: expect.any( Function ), + } ); + } ); + + it( 'should replace an existing selector behavior', () => { + const store = registry.registerReducer( 'butcher', ( state = { ribs: 6, chicken: 4 }, action ) => { + switch ( action.type ) { + case 'sale': + return { + ...state, + [ action.meat ]: state[ action.meat ] / 2, + }; + + case 'free_giveaway': + return mapValues( state, () => 0 ); + } + + return state; + } ); + registry.registerActions( 'butcher', { + startSale: ( meat ) => ( { type: 'sale', meat } ), + } ); + registry.registerActions( 'butcher', { + startSale: () => ( { type: 'free_giveaway' } ), + } ); + + registry.dispatch( 'butcher' ).startSale(); + expect( store.getState() ).toEqual( { + chicken: 0, + ribs: 0, + } ); + } ); + } ); + describe( 'registerResolvers', () => { const unsubscribes = []; afterEach( () => { @@ -353,24 +474,49 @@ describe( 'createRegistry', () => { return promise; } ); - } ); - describe( 'select', () => { - it( 'registers multiple selectors to the public API', () => { - const store = registry.registerReducer( 'reducer1', () => 'state1' ); - const selector1 = jest.fn( () => 'result1' ); - const selector2 = jest.fn( () => 'result2' ); + it( 'should into existing set on subsequent calls', () => { + const resolver1 = jest.fn(); + const resolver2 = jest.fn(); - registry.registerSelectors( 'reducer1', { - selector1, - selector2, + registry.registerReducer( 'demo', ( state = 'OK' ) => state ); + registry.registerSelectors( 'demo', { + getValue1: ( state ) => state, + getValue2: ( state ) => state, } ); + registry.registerResolvers( 'demo', { + getValue1: resolver1, + } ); + registry.registerResolvers( 'demo', { + getValue2: resolver2, + } ); + + registry.select( 'demo' ).getValue1(); + registry.select( 'demo' ).getValue2(); + + expect( resolver1 ).toHaveBeenCalled(); + expect( resolver2 ).toHaveBeenCalled(); + } ); + + it( 'should replace an existing resolver behavior', () => { + const resolver1 = jest.fn(); + const resolver2 = jest.fn(); - expect( registry.select( 'reducer1' ).selector1() ).toEqual( 'result1' ); - expect( selector1 ).toBeCalledWith( store.getState() ); + registry.registerReducer( 'demo', ( state = 'OK' ) => state ); + registry.registerSelectors( 'demo', { + getValue: ( state ) => state, + } ); + registry.registerResolvers( 'demo', { + getValue: resolver1, + } ); + registry.registerResolvers( 'demo', { + getValue: resolver2, + } ); + + registry.select( 'demo' ).getValue(); - expect( registry.select( 'reducer1' ).selector2() ).toEqual( 'result2' ); - expect( selector2 ).toBeCalledWith( store.getState() ); + expect( resolver1 ).not.toHaveBeenCalled(); + expect( resolver2 ).toHaveBeenCalled(); } ); } );