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

Data: Update register behaviors to merge with existing set #9210

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 69 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,75 @@ const SaleButton = withDispatch( ( dispatch, ownProps ) => {
// <SaleButton>Start Sale!</SaleButton>
```

## 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.
Expand Down
57 changes: 48 additions & 9 deletions packages/data/src/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
mapValues,
overEvery,
get,
isEmpty,
} from 'lodash';

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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 );
}

/**
Expand Down
172 changes: 159 additions & 13 deletions packages/data/src/test/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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( () => {
Expand Down Expand Up @@ -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();
} );
} );

Expand Down