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: Add redux-routine package for synchronous generator flow #8096

Merged
merged 9 commits into from
Aug 7, 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
20 changes: 18 additions & 2 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,14 @@ function gutenberg_register_scripts_and_styles() {
wp_register_script(
'wp-data',
gutenberg_url( 'build/data/index.js' ),
array( 'wp-deprecated', 'wp-element', 'wp-compose', 'wp-is-shallow-equal', 'lodash' ),
array(
'wp-deprecated',
'wp-element',
'wp-compose',
'wp-is-shallow-equal',
'lodash',
'wp-redux-routine',
),
filemtime( gutenberg_dir_path() . 'build/data/index.js' ),
true
);
Expand All @@ -222,7 +229,9 @@ function gutenberg_register_scripts_and_styles() {
' localStorage[ storageKey ] = localStorage[ oldStorageKey ];',
' delete localStorage[ oldStorageKey ];',
' }',
' wp.data.use( wp.data.plugins.persistence, { storageKey: storageKey } );',
' wp.data',
' .use( wp.data.plugins.persistence, { storageKey: storageKey } )',
' .use( wp.data.plugins.controls );',
'} )()',
) )
);
Expand Down Expand Up @@ -268,6 +277,13 @@ function gutenberg_register_scripts_and_styles() {
filemtime( gutenberg_dir_path() . 'build/shortcode/index.js' ),
true
);
wp_register_script(
'wp-redux-routine',
gutenberg_url( 'build/redux-routine/index.js' ),
array(),
filemtime( gutenberg_dir_path() . 'build/redux-routine/index.js' ),
true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I passed this argument for consistency with how we register other scripts, but it's not obvious to me why we need $in_footer assigned for these registered scripts.

);
wp_add_inline_script( 'wp-utils', 'var originalUtils = window.wp && window.wp.utils ? window.wp.utils : {};', 'before' );
wp_add_inline_script( 'wp-utils', 'for ( var key in originalUtils ) wp.utils[ key ] = originalUtils[ key ];' );
wp_register_script(
Expand Down
31 changes: 30 additions & 1 deletion package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@wordpress/keycodes": "file:packages/keycodes",
"@wordpress/nux": "file:packages/nux",
"@wordpress/plugins": "file:packages/plugins",
"@wordpress/redux-routine": "file:packages/redux-routine",
"@wordpress/shortcode": "file:packages/shortcode",
"@wordpress/url": "file:packages/url",
"@wordpress/viewport": "file:packages/viewport",
Expand Down
93 changes: 67 additions & 26 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,38 @@ npm install @wordpress/data --save
Use the `registerStore` function to add your own store to the centralized data registry. This function accepts two arguments: a name to identify the module, and an object with values describing how your state is represented, modified, and accessed. At a minimum, you must provide a reducer function describing the shape of your state and how it changes in response to actions dispatched to the store.

```js
const { data, fetch } = wp;
const { registerStore, dispatch } = data;
const { data, apiFetch } = wp;
const { registerStore } = data;

const DEFAULT_STATE = {
prices: {},
discountPercent: 0,
};

const actions = {
setPrice( item, price ) {
return {
type: 'SET_PRICE',
item,
price,
};
},

startSale( discountPercent ) {
return {
type: 'START_SALE',
discountPercent,
};
},

fetchFromAPI( path ) {
return {
type: 'FETCH_FROM_API',
path,
};
},
};

registerStore( 'my-shop', {
reducer( state = DEFAULT_STATE, action ) {
switch ( action.type ) {
Expand All @@ -47,21 +71,7 @@ registerStore( 'my-shop', {
return state;
},

actions: {
setPrice( item, price ) {
return {
type: 'SET_PRICE',
item,
price,
};
},
startSale( discountPercent ) {
return {
type: 'START_SALE',
discountPercent,
};
},
},
actions,

selectors: {
getPrice( state, item ) {
Expand All @@ -72,21 +82,22 @@ registerStore( 'my-shop', {
},
},

controls: {
FETCH_FROM_API( action ) {
return apiFetch( { path: action.path } );
},
},

resolvers: {
async getPrice( state, item ) {
const price = await apiFetch( { path: '/wp/v2/prices/' + item } );
dispatch( 'my-shop' ).setPrice( item, price );
* getPrice( state, item ) {
const path = '/wp/v2/prices/' + item;
const price = yield actions.fetchFromAPI( path );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What do you think about the inconsistency of the actions behavior?

  • actions with controls returning something defined by the control
  • actions without controls returning the action itself or undefined?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you suggesting we should promote yield actions.setPrice( item, price ) for consistent way of causing the dispatch?

I'm not really sure there's an inconsistency here, in that the actions.fetchFromAPI itself is still just a plain action object; it's the act of yielding it which transforms its result into being assigned into the variable or return statement.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just thinking that maybe fetchFromAPI is not really an action and might be declared separately. controls.fetchFromAPI

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I see it, the semantic purpose for an action is to express an intent. To that end, we traditionally consider its use in the context of dispatching within the store, but I don't see it as being fundamentally different from what we're proposing with controls, where the intent is processed as defined by the continuation procedure via the middleware.

It kinda speaks back to my thoughts at #8096 (comment), where there are multiple things going on here: namely the handling of generator and the potentially-asynchronous continuation. I'm led to think that they are complementary for the purposes we're using them for, but also that it leads to open questions on:

  • Is it okay to have a generator action creator which doesn't cause any asynchronous continuation to occur?
  • Is it okay for a control to be used without an attached asynchronous behavior (i.e. returns synchronously to assign / return on the yield)?

Both seem like implementation details that the action creator needn't be concerned with. It could be asynchronous, or it could not. From the developer's perspective, it's important that it's consistent in how it's used: yielding can assign a value, whether that's assigned asynchronously or not. It's a nice bonus that it provides a solution for a common use-case (multi-dispatch).

Thinking on how this is at all different from effects, the one thing that stood out to me is that we quickly turned to effects as they were the only option to do either asynchronous or multi-dispatch for a while. And once something was converted to an effect, it became that much more convenient to stay in the effect handler to perform the myriad of behaviors associated with an action. By contrast, with the routines / controls, it establishes a simple and obvious pattern to temporarily escape out of the flow from within the action creator itself in an isolated fashion.

return actions.setPrice( item, price );
},
},
} );
```

A [**reducer**](https://redux.js.org/docs/basics/Reducers.html) is a function accepting the previous `state` and `action` as arguments and returns an updated `state` value.

The **`actions`** object should describe all [action creators](https://redux.js.org/glossary#action-creator) available for your store. An action creator is a function that optionally accepts arguments and returns an action object to dispatch to the registered reducer. _Dispatching actions is the primary mechanism for making changes to your state._

The **`selectors`** object includes a set of functions for accessing and deriving state values. A selector is a function which accepts state and optional arguments and returns some value from state. _Calling selectors is the primary mechanism for retrieving data from your state_, and serve as a useful abstraction over the raw data which is typically more susceptible to change and less readily usable as a [normalized object](https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape#designing-a-normalized-state).

The return value of `registerStore` is a [Redux-like store object](https://redux.js.org/docs/basics/Store.html) with the following methods:

- `store.getState()`: Returns the state value of the registered reducer
Expand All @@ -96,6 +107,36 @@ The return value of `registerStore` is a [Redux-like store object](https://redux
- `store.dispatch( action: Object )`: Given an action object, calls the registered reducer and updates the state value.
- _Redux parallel:_ [`dispatch`](https://redux.js.org/api-reference/store#dispatch(action))

## Options

### `reducer`

A [**reducer**](https://redux.js.org/docs/basics/Reducers.html) is a function accepting the previous `state` and `action` as arguments and returns an updated `state` value.

### `actions`

The **`actions`** object should describe all [action creators](https://redux.js.org/glossary#action-creator) available for your store. An action creator is a function that optionally accepts arguments and returns an action object to dispatch to the registered reducer. _Dispatching actions is the primary mechanism for making changes to your state._

### `selectors`

The **`selectors`** object includes a set of functions for accessing and deriving state values. A selector is a function which accepts state and optional arguments and returns some value from state. _Calling selectors is the primary mechanism for retrieving data from your state_, and serve as a useful abstraction over the raw data which is typically more susceptible to change and less readily usable as a [normalized object](https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape#designing-a-normalized-state).

### `resolvers`

A **resolver** is a side-effect for a selector. If your selector result may need to be fulfilled from an external source, you can define a resolver such that the first time the selector is called, the fulfillment behavior is effected.

The `resolvers` option should be passed as an object where each key is the name of the selector to act upon, the value a function which receives the same arguments passed to the selector. It can then dispatch as necessary to fulfill the requirements of the selector, taking advantage of the fact that most data consumers will subscribe to subsequent state changes (by `subscribe` or `withSelect`).

### `controls`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably add mention of this requiring the controls plugin to be used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in a675f49


_**Note:** Controls are an opt-in feature, enabled via `use` (the [Plugins API](https://github.com/WordPress/gutenberg/tree/master/packages/data/src/plugins))._

A **control** defines the execution flow behavior associated with a specific action type. This can be particularly useful in implementing asynchronous data flows for your store. By defining your action creator or resolvers as a generator which yields specific controlled action types, the execution will proceed as defined by the control handler.

The `controls` option should be passed as an object where each key is the name of the action type to act upon, the value a function which receives the original action object. It should returns either a promise which is to resolve when evaluation of the action should continue, or a value. The value or resolved promise value is assigned on the return value of the yield assignment. If the control handler returns undefined, the execution is not continued.

Refer to the [documentation of `@wordpress/redux-routine`](https://github.com/WordPress/gutenberg/tree/master/packages/redux-routine/) for more information.

## Data Access and Manipulation

It is very rare that you should access store methods directly. Instead, the following suite of functions and higher-order components is provided for the most common data access and manipulation needs.
Expand Down
3 changes: 2 additions & 1 deletion packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/element": "file:../element",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"equivalent-key-map": "^0.2.1",
"@wordpress/redux-routine": "file:../redux-routine",
"equivalent-key-map": "^0.2.0",
"lodash": "^4.17.10",
"redux": "^3.7.2"
},
Expand Down
30 changes: 30 additions & 0 deletions packages/data/src/plugins/controls/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { applyMiddleware } from 'redux';

/**
* WordPress dependencies
*/
import createMiddleware from '@wordpress/redux-routine';

export default function( registry ) {
return {
registerStore( reducerKey, options ) {
const store = registry.registerStore( reducerKey, options );

if ( options.controls ) {
const middleware = createMiddleware( options.controls );
const enhancer = applyMiddleware( middleware );
const createStore = () => store;

Object.assign(
store,
enhancer( createStore )( options.reducer )
);
}

return store;
},
};
}
1 change: 1 addition & 0 deletions packages/data/src/plugins/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as controls } from './controls';
export { default as persistence } from './persistence';
14 changes: 7 additions & 7 deletions packages/data/src/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import {
*/
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
*/
import dataStore from './store';
import { persistence } from './plugins';

/**
* An isolated orchestrator of store registrations.
*
Expand All @@ -37,12 +43,6 @@ import deprecated from '@wordpress/deprecated';
* @typedef {WPDataPlugin}
*/

/**
* Internal dependencies
*/
import dataStore from './store';
import { persistence } from './plugins';

/**
* Returns true if the given argument appears to be a dispatchable action.
*
Expand Down Expand Up @@ -230,7 +230,7 @@ export function createRegistry( storeConfigs = {} ) {
}

for await ( const maybeAction of fulfillment ) {
// Dispatch if it quacks like an action.
// Dispatch if it quacks like an action.
if ( isActionLike( maybeAction ) ) {
store.dispatch( maybeAction );
}
Expand Down
1 change: 1 addition & 0 deletions packages/redux-routine/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
Loading