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

wp.data: Split store implementation out from registry. #10289

Merged
merged 14 commits into from
Nov 2, 2018
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
4 changes: 4 additions & 0 deletions docs/reference/deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility fo
- The following editor store actions have been removed: `createNotice`, `removeNotice`, `createSuccessNotice`, `createInfoNotice`, `createErrorNotice`, `createWarningNotice`. Use the equivalent actions by the same name from the `@wordpress/notices` module.
- The id prop of wp.nux.DotTip has been removed. Please use the tipId prop instead.
- `wp.blocks.isValidBlock` has been removed. Please use `wp.blocks.isValidBlockContent` instead but keep in mind that the order of params has changed.
- `wp.data` `registry.registerReducer` has been deprecated. Use `registry.registerStore` instead.
- `wp.data` `registry.registerSelectors` has been deprecated. Use `registry.registerStore` instead.
- `wp.data` `registry.registerActions` has been deprecated. Use `registry.registerStore` instead.
- `wp.data` `registry.registerResolvers` has been deprecated. Use `registry.registerStore` instead.

## 4.3.0

Expand Down
1 change: 1 addition & 0 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ function gutenberg_register_scripts_and_styles() {
array(
'lodash',
'wp-compose',
'wp-deprecated',
'wp-element',
'wp-is-shallow-equal',
'wp-polyfill',
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 3.1.0 (Unreleased)

### New Features

- `registry.registerGenericStore` has been added to support integration with existing data systems.

### Deprecations

- `registry.registerReducer` has been deprecated. Use `registry.registerStore` instead.
- `registry.registerSelectors` has been deprecated. Use `registry.registerStore` instead.
- `registry.registerActions` has been deprecated. Use `registry.registerStore` instead.
- `registry.registerResolvers` has been deprecated. Use `registry.registerStore` instead.

## 3.0.1 (2018-10-30)

### Internal
Expand Down
87 changes: 87 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,93 @@ const SaleButton = withDispatch( ( dispatch, ownProps ) => {
// <SaleButton>Start Sale!</SaleButton>
```

## Generic Stores

The `@wordpress/data` module offers a more advanced and generic interface for the purposes of integrating other data systems and situations where more direct control over a data system is needed. In this case, a data store will need to be implemented outside of `@wordpress/data` and then plugged in via three functions:

- `getSelectors()`: Returns an object of selector functions, pre-mapped to the store.
- `getActions()`: Returns an object of action functions, pre-mapped to the store.
- `subscribe( listener: Function )`: Registers a function called any time the value of state changes.
- Behaves as Redux [`subscribe`](https://redux.js.org/api-reference/store#subscribe(listener))
with the following differences:
- Doesn't have to implement an unsubscribe, since the registry never uses it.
- Only has to support one listener (the registry).

By implementing the above interface for your custom store, you gain the benefits of using the registry and the `withSelect` and `withDispatch` higher order components in your application code. This provides seamless integration with existing and alternative data systems.

Integrating an existing redux store with its own reducers, store enhancers and middleware can be accomplished as follows:

_Example:_

```js
import existingSelectors from './existing-app/selectors';
import existingActions from './existing-app/actions';
import createStore from './existing-app/store';

const reduxStore = createStore();

const mappedSelectors = existingSelectors.map( ( selector ) => {
return ( ...args ) => selector( reduxStore.getState(), ...args );
} );

const mappedActions = existingActions.map( ( action ) => {
return actions.map( ( action ) => {
return ( ...args ) => reduxStore.dispatch( action( ...args ) );
} );
} );

const genericStore = {
getSelectors() {
return mappedSelectors;
},
getActions() {
return mappedActions;
},
subscribe: reduxStore.subscribe;
};

registry.registerGenericStore( 'existing-app', genericStore );
```

It is also possible to implement a completely custom store from scratch:

_Example:_

```js
function createCustomStore() {
let storeChanged = () => {};
const prices = { hammer: 7.50 };

const selectors = {
getPrice( itemName ): {
return prices[ itemName ];
},
};

const actions = {
setPrice( itemName, price ): {
prices[ itemName ] = price;
storeChanged();
},
};

return {
getSelectors() {
return selectors;
},
getActions() {
return actions;
},
subscribe( listener ) {
storeChanged = listener;
}
};
}

registry.registerGenericStore( 'custom-data', createCustomStore() );
```


## 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
1 change: 1 addition & 0 deletions packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@babel/runtime": "^7.0.0",
"@wordpress/compose": "file:../compose",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/element": "file:../element",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/redux-routine": "file:../redux-routine",
Expand Down
1 change: 1 addition & 0 deletions packages/data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { combineReducers };
export const select = defaultRegistry.select;
export const dispatch = defaultRegistry.dispatch;
export const subscribe = defaultRegistry.subscribe;
export const registerGenericStore = defaultRegistry.registerGenericStore;
export const registerStore = defaultRegistry.registerStore;
export const registerReducer = defaultRegistry.registerReducer;
export const registerActions = defaultRegistry.registerActions;
Expand Down
229 changes: 229 additions & 0 deletions packages/data/src/namespace-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* External dependencies
*/
import { createStore, applyMiddleware } from 'redux';
import {
flowRight,
get,
mapValues,
} from 'lodash';

/**
* Internal dependencies
*/
import promise from './promise-middleware';
import createResolversCacheMiddleware from './resolvers-cache-middleware';

/**
* Creates a namespace object with a store derived from the reducer given.
*
* @param {string} key Identifying string used for namespace and redex dev tools.
* @param {Object} options Contains reducer, actions, selectors, and resolvers.
* @param {Object} registry Temporary registry reference, required for namespace updates.
*
* @return {Object} Store Object.
*/
export default function createNamespace( key, options, registry ) {
// TODO: After register[Reducer|Actions|Selectors|Resolvers] are deprecated and removed,
// this function can be greatly simplified because it should no longer be called to modify
// a namespace, but only to create one, and only once for each namespace.

// TODO: After removing `registry.namespaces`and making stores immutable after create,
// reducer, store, actinos, selectors, and resolvers can all be removed from here.
let {
reducer,
store,
actions,
selectors,
resolvers,
} = registry.namespaces[ key ] || {};

if ( options.reducer ) {
reducer = options.reducer;
store = createReduxStore( reducer, key, registry );
}
if ( options.actions ) {
if ( ! store ) {
throw new TypeError( 'Cannot specify actions when no reducer is present' );
}
actions = mapActions( options.actions, store );
}
if ( options.selectors ) {
if ( ! store ) {
throw new TypeError( 'Cannot specify selectors when no reducer is present' );
}
selectors = mapSelectors( options.selectors, store );
}
if ( options.resolvers ) {
const fulfillment = getCoreDataFulfillment( registry, key );
const result = mapResolvers( options.resolvers, selectors, fulfillment, store );
resolvers = result.resolvers;
selectors = result.selectors;
}

const getSelectors = () => selectors;
const getActions = () => actions;

// Customize subscribe behavior to call listeners only on effective change,
// not on every dispatch.
const subscribe = store && function( listener ) {
let lastState = store.getState();
store.subscribe( () => {
const state = store.getState();
const hasChanged = state !== lastState;
lastState = state;

if ( hasChanged ) {
listener();
}
} );
};

return {
reducer,
store,
actions,
selectors,
resolvers,
getSelectors,
getActions,
subscribe,
};
}

/**
* Creates a redux store for a namespace.
*
* @param {Function} reducer Root reducer for redux store.
* @param {string} key Part of the state shape to register the
* selectors for.
* @param {Object} registry Registry reference, for resolver enhancer support.
* @return {Object} Newly created redux store.
*/
function createReduxStore( reducer, key, registry ) {
const enhancers = [
applyMiddleware( createResolversCacheMiddleware( registry, key ), promise ),
];
if ( typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ) {
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: key, instanceId: key } ) );
}

return createStore( reducer, flowRight( enhancers ) );
}

/**
* Maps selectors to a redux store.
*
* @param {Object} selectors Selectors to register. Keys will be used as the
* public facing API. Selectors will get passed the
* state as first argument.
* @param {Object} store The redux store to which the selectors should be mapped.
* @return {Object} Selectors mapped to the redux store provided.
*/
function mapSelectors( selectors, store ) {
const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args );
return mapValues( selectors, createStateSelector );
}

/**
* Maps actions to dispatch from a given store.
*
* @param {Object} actions Actions to register.
* @param {Object} store The redux store to which the actions should be mapped.
* @return {Object} Actions mapped to the redux store provided.
*/
function mapActions( actions, store ) {
const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) );
return mapValues( actions, createBoundAction );
}

/**
* Returns resolvers with matched selectors for a given namespace.
* 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.
*
* @param {Object} resolvers Resolvers to register.
* @param {Object} selectors The current selectors to be modified.
* @param {Object} fulfillment Fulfillment implementation functions.
* @param {Object} store The redux store to which the resolvers should be mapped.
* @return {Object} An object containing updated selectors and resolvers.
*/
function mapResolvers( resolvers, selectors, fulfillment, store ) {
const mapSelector = ( selector, selectorName ) => {
const resolver = resolvers[ selectorName ];
if ( ! resolver ) {
return selector;
}

return ( ...args ) => {
async function fulfillSelector() {
const state = store.getState();
if ( typeof resolver.isFulfilled === 'function' && resolver.isFulfilled( state, ...args ) ) {
return;
}

if ( fulfillment.hasStarted( selectorName, args ) ) {
return;
}

fulfillment.start( selectorName, args );
await fulfillment.fulfill( selectorName, ...args );
fulfillment.finish( selectorName, args );
}

fulfillSelector( ...args );
return selector( ...args );
};
};

const mappedResolvers = mapValues( resolvers, ( resolver ) => {
const { fulfill: resolverFulfill = resolver } = resolver;
return { ...resolver, fulfill: resolverFulfill };
} );

return {
resolvers: mappedResolvers,
selectors: mapValues( selectors, mapSelector ),
};
}

/**
* Bundles up fulfillment functions for resolvers.
* @param {Object} registry Registry reference, for fulfilling via resolvers
* @param {string} key Part of the state shape to register the
* selectors for.
* @return {Object} An object providing fulfillment functions.
*/
function getCoreDataFulfillment( registry, key ) {
const { hasStartedResolution } = registry.select( 'core/data' );
const { startResolution, finishResolution } = registry.dispatch( 'core/data' );

return {
hasStarted: ( ...args ) => hasStartedResolution( key, ...args ),
start: ( ...args ) => startResolution( key, ...args ),
finish: ( ...args ) => finishResolution( key, ...args ),
fulfill: ( ...args ) => fulfillWithRegistry( registry, key, ...args ),
};
}

/**
* Calls a resolver given arguments
*
* @param {Object} registry Registry reference, for fulfilling via resolvers
* @param {string} key Part of the state shape to register the
* selectors for.
* @param {string} selectorName Selector name to fulfill.
* @param {Array} args Selector Arguments.
*/
async function fulfillWithRegistry( registry, key, selectorName, ...args ) {
const namespace = registry.stores[ key ];
const resolver = get( namespace, [ 'resolvers', selectorName ] );
if ( ! resolver ) {
return;
}

const action = resolver.fulfill( ...args );
if ( action ) {
await namespace.store.dispatch( action );
}
}
1 change: 1 addition & 0 deletions packages/data/src/plugins/persistence/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe( 'persistence', () => {

// Since the exposed `registerStore` is a proxying function, mimic
// intercept of original call by adding an initial plugin.
// TODO: Remove the `use` function in favor of `registerGenericStore`
registry = createRegistry()
.use( ( originalRegistry ) => {
originalRegisterStore = jest.spyOn( originalRegistry, 'registerStore' );
Expand Down
Loading