From 2d3412f5e244bcdfd0311eebdcc975f31ff76d58 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 20 Jul 2018 17:09:44 -0400 Subject: [PATCH 1/9] Packages: Add package `redux-routine` --- package-lock.json | 3 + package.json | 1 + packages/redux-routine/.npmrc | 1 + packages/redux-routine/README.md | 108 ++++++++++++++++ packages/redux-routine/package.json | 30 +++++ packages/redux-routine/src/index.js | 51 ++++++++ packages/redux-routine/src/is-generator.js | 10 ++ packages/redux-routine/src/test/index.js | 118 ++++++++++++++++++ .../redux-routine/src/test/is-generator.js | 25 ++++ webpack.config.js | 2 + 10 files changed, 349 insertions(+) create mode 100644 packages/redux-routine/.npmrc create mode 100644 packages/redux-routine/README.md create mode 100644 packages/redux-routine/package.json create mode 100644 packages/redux-routine/src/index.js create mode 100644 packages/redux-routine/src/is-generator.js create mode 100644 packages/redux-routine/src/test/index.js create mode 100644 packages/redux-routine/src/test/is-generator.js diff --git a/package-lock.json b/package-lock.json index e6aa4b05a5715..0c7a8922a2e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3601,6 +3601,9 @@ "postcss": "^6.0.16" } }, + "@wordpress/redux-routine": { + "version": "file:packages/redux-routine" + }, "@wordpress/scripts": { "version": "file:packages/scripts", "dev": true, diff --git a/package.json b/package.json index 8c3a70e589dd6..2dbed5327ffa5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/redux-routine/.npmrc b/packages/redux-routine/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/redux-routine/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/redux-routine/README.md b/packages/redux-routine/README.md new file mode 100644 index 0000000000000..6201f0d4d2f78 --- /dev/null +++ b/packages/redux-routine/README.md @@ -0,0 +1,108 @@ +# @wordpress/redux-routine + +Redux middleware for generator coroutines. + +## Installation + +Install Node if you do not already have it available. + +Install the module to your project using `npm`: + +```bash +npm install @wordpress/redux-routine +``` + +`@wordpress/redux-routine` leverages both Promises and Generators, two modern features of the JavaScript language. If you need to support older browsers (Internet Explorer 11 or earlier), you will need to provide your own polyfills. + +## Usage + +The default export of `@wordpress/redux-routine` is a function which, given an object of control handlers, returns a Redux middleware function. + +For example, consider a common case where we need to issue a network request. We can define the network request as a control handler when creating our middleware. + +```js +import { combineReducers, createStore, applyMiddleware } from 'redux'; +import createRoutineMiddleware from '@wordpress/redux-routine'; + +const middleware = createRoutineMiddleware( { + async FETCH_JSON( action ) { + const response = await window.fetch( action.url ); + return response.json(); + }, +} ); + +function temperature( state = null, action ) { + switch ( action.type ) { + case 'SET_TEMPERATURE': + return action.temperature; + } + + return state; +} + +const reducer = combineReducers( { temperature } ); + +const store = createStore( reducer, applyMiddleware( middleware ) ); + +function* retrieveTemperature() { + const result = yield { type: 'FETCH_JSON', url: 'https://' }; + return { type: 'SET_TEMPERATURE', temperature: result.value }; +} + +store.dispatch( retrieveTemperature() ); +``` + +In this example, when we dispatch `retrieveTemperature`, it will trigger the control handler to take effect, issuing the network request and assigning the result into the `result` variable. Only once the +request has completed does the action creator procede to return the `SET_TEMPERATURE` action type. + +## API + +### `createMiddleware( controls: ?Object )` + +Create a Redux middleware, given an object of controls where each key is an action type for which to act upon, the value a function which 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. + +## Motivation + +`@wordpress/redux-routine` shares many of the same motivations as other similar generator-based Redux side effects solutions, including `redux-saga`. Where it differs is in being less opinionated by virtue of its minimalism. It includes no default controls, offers no tooling around splitting logic flows, and does not include any error handling out of the box. This is intended in promoting approachability to developers who seek to bring asynchronous or conditional continuation flows to their applications without a steep learning curve. + +The primary motivations include, among others: + +- **Testability**: Since an action creator yields plain action objects, the behavior of their resolution can be easily substituted in tests. +- **Controlled flexibility**: Control flows can be implemented without sacrificing the expressiveness and intentionality of an action type. Other solutions like thunks or promises promote ultimate flexibility, but at the expense of maintainability manifested through deep coupling between action types and incidental implementation. +- A **common domain language** for expressing data flows: Since controls are centrally defined, it requires the conscious decision on the part of a development team to decide when and how new control handlers are added. + +## Testing + +Since your action creators will return an iterable generator of plain action objects, they are trivial to test. + +Consider again our above example: + +```js +function* retrieveTemperature() { + const result = yield { type: 'FETCH_JSON', url: 'https://' }; + return { type: 'SET_TEMPERATURE', temperature: result.value }; +} +``` + +A test case (using Node's `assert` built-in module) may be written as: + +```js +import { deepEqual } from 'assert'; + +const action = retrieveTemperature(); + +deepEqual( action.next().value, { + type: 'FETCH_JSON', + url: 'https://', +} ); + +const jsonResult = { value: 10 }; +deepEqual( action.next( jsonResult ).value, { + type: 'SET_TEMPERATURE', + temperature: 10, +} ); +``` + +If your action creator does not assign the yielded result into a variable, you can also use `Array.from` to create an array from the result of the action creator. + +

Code is Poetry.

diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json new file mode 100644 index 0000000000000..c1dfc06917561 --- /dev/null +++ b/packages/redux-routine/package.json @@ -0,0 +1,30 @@ +{ + "name": "@wordpress/redux-routine", + "version": "1.0.0", + "description": "Redux middleware for generator coroutines", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "redux", + "middleware", + "coroutine" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/redux-routine/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": {}, + "devDependencies": { + "redux": "^4.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/redux-routine/src/index.js b/packages/redux-routine/src/index.js new file mode 100644 index 0000000000000..983acfcf59498 --- /dev/null +++ b/packages/redux-routine/src/index.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import isGenerator from './is-generator'; + +/** + * Creates a Redux middleware, given an object of controls where each key is an + * action type for which to act upon, the value a function which 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. + * + * @param {Object} controls Object of control handlers. + * + * @return {Function} Redux middleware function. + */ +export default function createMiddleware( controls = {} ) { + return ( store ) => ( next ) => ( action ) => { + if ( ! isGenerator( action ) ) { + return next( action ); + } + + function step( nextAction ) { + if ( ! nextAction ) { + return; + } + + const control = controls[ nextAction.type ]; + if ( typeof control === 'function' ) { + const routine = control( nextAction ); + + if ( routine instanceof Promise ) { + // Async control routine awaits resolution. + routine.then( ( result ) => { + step( action.next( result ).value ); + } ); + } else if ( routine !== undefined ) { + // Sync control routine steps synchronously. + step( action.next( routine ).value ); + } + } else { + // Uncontrolled action is dispatched. + store.dispatch( nextAction ); + step( action.next().value ); + } + } + + step( action.next().value ); + }; +} diff --git a/packages/redux-routine/src/is-generator.js b/packages/redux-routine/src/is-generator.js new file mode 100644 index 0000000000000..60fd2e3bc3b73 --- /dev/null +++ b/packages/redux-routine/src/is-generator.js @@ -0,0 +1,10 @@ +/** + * Returns true if the given object is a generator, or false otherwise. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is a generator. + */ +export default function isGenerator( object ) { + return !! object && typeof object.next === 'function'; +} diff --git a/packages/redux-routine/src/test/index.js b/packages/redux-routine/src/test/index.js new file mode 100644 index 0000000000000..2c5f223d513ad --- /dev/null +++ b/packages/redux-routine/src/test/index.js @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import { createStore, applyMiddleware } from 'redux'; + +/** + * Internal dependencies + */ +import createMiddleware from '../'; + +jest.useFakeTimers(); + +describe( 'createMiddleware', () => { + function createStoreWithMiddleware( middleware ) { + const reducer = ( state = null, action ) => action.nextState || state; + return createStore( reducer, applyMiddleware( middleware ) ); + } + + it( 'should not alter dispatch flow of uncontrolled action', () => { + const middleware = createMiddleware(); + const store = createStoreWithMiddleware( middleware ); + + store.dispatch( { type: 'CHANGE', nextState: 1 } ); + + expect( store.getState() ).toBe( 1 ); + } ); + + it( 'should dispatch yielded actions', () => { + const middleware = createMiddleware(); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + yield { type: 'CHANGE', nextState: 1 }; + } + + store.dispatch( createAction() ); + + expect( store.getState() ).toBe( 1 ); + } ); + + it( 'should continue only once control condition resolves', ( done ) => { + const middleware = createMiddleware( { + WAIT: () => new Promise( ( resolve ) => setTimeout( resolve, 0 ) ), + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + yield { type: 'WAIT' }; + yield { type: 'CHANGE', nextState: 1 }; + } + + store.dispatch( createAction() ); + expect( store.getState() ).toBe( null ); + + jest.runAllTimers(); + expect( store.getState() ).toBe( null ); + + // Promise resolution occurs on next tick. + process.nextTick( () => { + expect( store.getState() ).toBe( 1 ); + done(); + } ); + } ); + + it( 'assigns sync controlled return value into yield assignment', () => { + const middleware = createMiddleware( { + RETURN_TWO: () => 2, + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + const nextState = yield { type: 'RETURN_TWO' }; + yield { type: 'CHANGE', nextState }; + } + + store.dispatch( createAction() ); + + expect( store.getState() ).toBe( 2 ); + } ); + + it( 'assigns async controlled return value into yield assignment', ( done ) => { + const middleware = createMiddleware( { + WAIT: ( action ) => new Promise( ( resolve ) => { + setTimeout( () => { + resolve( action.value ); + }, 0 ); + } ), + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + const nextState = yield { type: 'WAIT', value: 2 }; + return { type: 'CHANGE', nextState }; + } + + store.dispatch( createAction() ); + expect( store.getState() ).toBe( null ); + + jest.runAllTimers(); + expect( store.getState() ).toBe( null ); + + process.nextTick( () => { + expect( store.getState() ).toBe( 2 ); + done(); + } ); + } ); + + it( 'kills continuation if control returns undefined', () => { + const middleware = createMiddleware( { + KILL: () => {}, + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + yield { type: 'KILL' }; + return { type: 'CHANGE', nextState: 1 }; + } + + store.dispatch( createAction() ); + + expect( store.getState() ).toBe( null ); + } ); +} ); diff --git a/packages/redux-routine/src/test/is-generator.js b/packages/redux-routine/src/test/is-generator.js new file mode 100644 index 0000000000000..83ff508377470 --- /dev/null +++ b/packages/redux-routine/src/test/is-generator.js @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import isGenerator from '../is-generator'; + +describe( 'isGenerator', () => { + it( 'should return false if not a generator', () => { + [ + undefined, + null, + 10, + 'foo', + function() {}, + function* () {}, + ].forEach( ( value ) => { + expect( isGenerator( value ) ).toBe( false ); + } ); + } ); + + it( 'should return true if a generator', () => { + const value = ( function* () {}() ); + + expect( isGenerator( value ) ).toBe( true ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 53fe3b19b8c74..498954a9f4163 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -105,6 +105,7 @@ const gutenbergPackages = [ 'keycodes', 'nux', 'plugins', + 'redux-routine', 'shortcode', 'url', 'viewport', @@ -243,6 +244,7 @@ const config = { 'api-fetch', 'deprecated', 'dom-ready', + 'redux-routine', ].map( camelCaseDash ) ), new CopyWebpackPlugin( gutenbergPackages.map( ( packageName ) => ( { From 46e696f522ccefb3793e2d7712f96549c061b1e9 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Sat, 4 Aug 2018 17:48:29 -0400 Subject: [PATCH 2/9] Packages: Throw rejected promise as error to generator --- packages/redux-routine/src/cast-error.js | 14 +++++++ packages/redux-routine/src/index.js | 8 ++-- packages/redux-routine/src/test/cast-error.js | 18 ++++++++ packages/redux-routine/src/test/index.js | 42 +++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 packages/redux-routine/src/cast-error.js create mode 100644 packages/redux-routine/src/test/cast-error.js diff --git a/packages/redux-routine/src/cast-error.js b/packages/redux-routine/src/cast-error.js new file mode 100644 index 0000000000000..9bc2e22a46d40 --- /dev/null +++ b/packages/redux-routine/src/cast-error.js @@ -0,0 +1,14 @@ +/** + * Casts value as an error if it's not one. + * + * @param {*} error The value to cast. + * + * @return {Error} The cast error. + */ +export default function castError( error ) { + if ( ! ( error instanceof Error ) ) { + error = new Error( error ); + } + + return error; +} diff --git a/packages/redux-routine/src/index.js b/packages/redux-routine/src/index.js index 983acfcf59498..fce78e18a7519 100644 --- a/packages/redux-routine/src/index.js +++ b/packages/redux-routine/src/index.js @@ -2,6 +2,7 @@ * Internal dependencies */ import isGenerator from './is-generator'; +import castError from './cast-error'; /** * Creates a Redux middleware, given an object of controls where each key is an @@ -32,9 +33,10 @@ export default function createMiddleware( controls = {} ) { if ( routine instanceof Promise ) { // Async control routine awaits resolution. - routine.then( ( result ) => { - step( action.next( result ).value ); - } ); + routine.then( + ( result ) => step( action.next( result ).value ), + ( error ) => action.throw( castError( error ) ), + ); } else if ( routine !== undefined ) { // Sync control routine steps synchronously. step( action.next( routine ).value ); diff --git a/packages/redux-routine/src/test/cast-error.js b/packages/redux-routine/src/test/cast-error.js new file mode 100644 index 0000000000000..bdca1c7c202b9 --- /dev/null +++ b/packages/redux-routine/src/test/cast-error.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import castError from '../cast-error'; + +describe( 'castError', () => { + it( 'should return error verbatim', () => { + const error = new Error( 'Foo' ); + + expect( castError( error ) ).toBe( error ); + } ); + + it( 'should return string as message of error', () => { + const error = 'Foo'; + + expect( castError( error ) ).toEqual( new Error( 'Foo' ) ); + } ); +} ); diff --git a/packages/redux-routine/src/test/index.js b/packages/redux-routine/src/test/index.js index 2c5f223d513ad..5a82acd8a88fb 100644 --- a/packages/redux-routine/src/test/index.js +++ b/packages/redux-routine/src/test/index.js @@ -60,6 +60,48 @@ describe( 'createMiddleware', () => { } ); } ); + it( 'should throw if promise rejects', ( done ) => { + const middleware = createMiddleware( { + WAIT_FAIL: () => new Promise( ( resolve, reject ) => { + setTimeout( () => reject( 'Message' ), 0 ); + } ), + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + try { + yield { type: 'WAIT_FAIL' }; + } catch ( error ) { + expect( error.message ).toBe( 'Message' ); + done(); + } + } + + store.dispatch( createAction() ); + + jest.runAllTimers(); + } ); + + it( 'should throw if promise throws', ( done ) => { + const middleware = createMiddleware( { + WAIT_FAIL: () => new Promise( () => { + throw new Error( 'Message' ); + } ), + } ); + const store = createStoreWithMiddleware( middleware ); + function* createAction() { + try { + yield { type: 'WAIT_FAIL' }; + } catch ( error ) { + expect( error.message ).toBe( 'Message' ); + done(); + } + } + + store.dispatch( createAction() ); + + jest.runAllTimers(); + } ); + it( 'assigns sync controlled return value into yield assignment', () => { const middleware = createMiddleware( { RETURN_TWO: () => 2, From 4fe2e5017c8d092d745ef28268c70021c514920b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 20 Jul 2018 17:10:29 -0400 Subject: [PATCH 3/9] Data: Add support for controls via redux-routine --- lib/client-assets.php | 16 +++++- package-lock.json | 30 +++++++++++- packages/data/README.md | 91 +++++++++++++++++++++++++---------- packages/data/package.json | 3 +- packages/data/src/registry.js | 69 +++++++++++++++++--------- 5 files changed, 155 insertions(+), 54 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index 38a4c77cf1784..1cef040373ce9 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -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 ); @@ -268,6 +275,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 + ); 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( diff --git a/package-lock.json b/package-lock.json index 0c7a8922a2e7a..529625e7b3138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3401,7 +3401,8 @@ "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", - "equivalent-key-map": "^0.2.1", + "@wordpress/redux-routine": "file:packages/redux-routine", + "equivalent-key-map": "^0.2.0", "lodash": "^4.17.10", "redux": "^3.7.2" }, @@ -3602,7 +3603,32 @@ } }, "@wordpress/redux-routine": { - "version": "file:packages/redux-routine" + "version": "file:packages/redux-routine", + "dependencies": { + "js-tokens": { + "version": "4.0.0", + "bundled": true + }, + "loose-envify": { + "version": "1.4.0", + "bundled": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "redux": { + "version": "4.0.0", + "bundled": true, + "requires": { + "loose-envify": "^1.1.0", + "symbol-observable": "^1.2.0" + } + }, + "symbol-observable": { + "version": "1.2.0", + "bundled": true + } + } }, "@wordpress/scripts": { "version": "file:packages/scripts", diff --git a/packages/data/README.md b/packages/data/README.md index 46a47d6e3fdba..9a478330d4236 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -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 ) { @@ -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 ) { @@ -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 ); + 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 @@ -96,6 +107,34 @@ 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` + +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. diff --git a/packages/data/package.json b/packages/data/package.json index 6aec5ec817171..1b6f863e2e24c 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -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" }, diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index e34ac42a08103..80c04f8dc4798 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { createStore } from 'redux'; +import { createStore, applyMiddleware } from 'redux'; import { flowRight, without, @@ -13,8 +13,15 @@ import { /** * WordPress dependencies */ +import createControlsMiddleware from '@wordpress/redux-routine'; import deprecated from '@wordpress/deprecated'; +/** + * Internal dependencies + */ +import dataStore from './store'; +import { persistence } from './plugins'; + /** * An isolated orchestrator of store registrations. * @@ -37,12 +44,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. * @@ -138,13 +139,13 @@ export function createRegistry( storeConfigs = {} ) { * Registers a new sub-reducer to the global state and returns a Redux-like * store object. * - * @param {string} reducerKey Reducer key. - * @param {Object} reducer Reducer function. + * @param {string} reducerKey Reducer key. + * @param {Object} reducer Reducer function. + * @param {?Array} enhancers Optional store enhancers. * * @return {Object} Store Object. */ - function registerReducer( reducerKey, reducer ) { - const enhancers = []; + function registerReducer( reducerKey, reducer, enhancers = [] ) { if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); } @@ -223,20 +224,34 @@ export function createRegistry( storeConfigs = {} ) { let fulfillment = resolver.fulfill( state, ...args ); // Attempt to normalize fulfillment as async iterable. - fulfillment = toAsyncIterable( fulfillment ); - if ( ! isAsyncIterable( fulfillment ) ) { - finishResolution( reducerKey, selectorName, args ); - return; - } - - for await ( const maybeAction of fulfillment ) { - // Dispatch if it quacks like an action. - if ( isActionLike( maybeAction ) ) { - store.dispatch( maybeAction ); + if ( isGenerator( fulfillment ) ) { + // Override original fulfillment to trigger resolution + // finish once deferred yielded result is completed. + const originalFulfillment = fulfillment; + fulfillment = ( function* () { + yield* originalFulfillment; + finishResolution( reducerKey, selectorName, args ); + }() ); + } else { + // Attempt to normalize fulfillment as async iterable. + fulfillment = toAsyncIterable( fulfillment ); + if ( isAsyncIterable( fulfillment ) ) { + deprecated( 'Asynchronous iterable resolvers', { + alternative: 'synchronous generators with `controls` plugin', + plugin: 'Gutenberg', + version: '3.7', + } ); + + for await ( const maybeAction of fulfillment ) { + // Dispatch if it quacks like an action. + if ( isActionLike( maybeAction ) ) { + store.dispatch( maybeAction ); + } + } } - } - finishResolution( reducerKey, selectorName, args ); + finishResolution( reducerKey, selectorName, args ); + } } if ( typeof resolver.isFulfilled === 'function' ) { @@ -286,7 +301,13 @@ export function createRegistry( storeConfigs = {} ) { throw new TypeError( 'Must specify store reducer' ); } - const store = registerReducer( reducerKey, options.reducer ); + let enhancers; + if ( options.controls ) { + const controlsMiddleware = createControlsMiddleware( options.controls ); + enhancers = [ applyMiddleware( controlsMiddleware ) ]; + } + + const store = registerReducer( reducerKey, options.reducer, enhancers ); if ( options.actions ) { registerActions( reducerKey, options.actions ); From 568b48267c6ef2771603ef78e8b0e3fd36311b1c Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Sat, 4 Aug 2018 18:20:50 -0400 Subject: [PATCH 4/9] Data: Port controls as data plugin --- lib/client-assets.php | 4 ++- packages/data/src/plugins/controls/index.js | 30 +++++++++++++++++++++ packages/data/src/plugins/index.js | 1 + packages/data/src/registry.js | 19 +++++-------- 4 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 packages/data/src/plugins/controls/index.js diff --git a/lib/client-assets.php b/lib/client-assets.php index 1cef040373ce9..ef39b9a1e9ade 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -229,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 );', '} )()', ) ) ); diff --git a/packages/data/src/plugins/controls/index.js b/packages/data/src/plugins/controls/index.js new file mode 100644 index 0000000000000..308b4d44034aa --- /dev/null +++ b/packages/data/src/plugins/controls/index.js @@ -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; + }, + }; +} diff --git a/packages/data/src/plugins/index.js b/packages/data/src/plugins/index.js index 30050ad77fa62..587768f415911 100644 --- a/packages/data/src/plugins/index.js +++ b/packages/data/src/plugins/index.js @@ -1 +1,2 @@ +export { default as controls } from './controls'; export { default as persistence } from './persistence'; diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 80c04f8dc4798..bd0e2ba009ac0 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { createStore, applyMiddleware } from 'redux'; +import { createStore } from 'redux'; import { flowRight, without, @@ -13,7 +13,6 @@ import { /** * WordPress dependencies */ -import createControlsMiddleware from '@wordpress/redux-routine'; import deprecated from '@wordpress/deprecated'; /** @@ -139,13 +138,13 @@ export function createRegistry( storeConfigs = {} ) { * Registers a new sub-reducer to the global state and returns a Redux-like * store object. * - * @param {string} reducerKey Reducer key. - * @param {Object} reducer Reducer function. - * @param {?Array} enhancers Optional store enhancers. + * @param {string} reducerKey Reducer key. + * @param {Object} reducer Reducer function. * * @return {Object} Store Object. */ - function registerReducer( reducerKey, reducer, enhancers = [] ) { + function registerReducer( reducerKey, reducer ) { + const enhancers = []; if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); } @@ -301,13 +300,7 @@ export function createRegistry( storeConfigs = {} ) { throw new TypeError( 'Must specify store reducer' ); } - let enhancers; - if ( options.controls ) { - const controlsMiddleware = createControlsMiddleware( options.controls ); - enhancers = [ applyMiddleware( controlsMiddleware ) ]; - } - - const store = registerReducer( reducerKey, options.reducer, enhancers ); + const store = registerReducer( reducerKey, options.reducer ); if ( options.actions ) { registerActions( reducerKey, options.actions ); From 31b9f42c796ddce1cd288b95880caf686589c39b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 20 Jul 2018 17:16:50 -0400 Subject: [PATCH 5/9] Core Data: Partially reimplement resolvers as controls --- packages/core-data/src/actions.js | 16 ++++++++++++++++ packages/core-data/src/controls.js | 16 ++++++++++++++++ packages/core-data/src/index.js | 2 ++ packages/core-data/src/resolvers.js | 13 +++++++------ packages/core-data/src/test/resolvers.js | 22 +++++++++++----------- 5 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 packages/core-data/src/controls.js diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 31107d8daef10..3f550becca66e 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,6 +3,22 @@ */ import { castArray } from 'lodash'; +/** + * Given an apiFetch payload, returns an action object used in signalling that + * an API request should be issued. This can be used in tandem with the data + * module's `controls` option for asynchronous API request logic flows. + * + * @param {Object} options apiFetch options payload. + * + * @return {Object} Action object. + */ +export function fetchFromAPI( options ) { + return { + type: 'FETCH_FROM_API', + ...options, + }; +} + /** * Returns an action object used in signalling that terms have been received * for a given taxonomy. diff --git a/packages/core-data/src/controls.js b/packages/core-data/src/controls.js new file mode 100644 index 0000000000000..981533d5b101e --- /dev/null +++ b/packages/core-data/src/controls.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Given an action object, triggers an API request to retrieve the result from + * the given action path. + * + * @param {Object} action Fetch action object. + * + * @return {Promise} Fetch promise. + */ +export function FETCH_FROM_API( action ) { + return apiFetch( { path: action.path } ); +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 31fe114746930..ae1d4a2adece5 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -10,6 +10,7 @@ import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; +import * as controls from './controls'; import { defaultEntities, getMethodName } from './entities'; import { REDUCER_KEY } from './name'; @@ -26,6 +27,7 @@ const entitySelectors = createEntityRecordGetter( selectors ); const store = registerStore( REDUCER_KEY, { reducer, actions, + controls, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a105997bfedd6..a267257012eaf 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -12,6 +12,7 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import { + fetchFromAPI, receiveTerms, receiveUserQuery, receiveEntityRecords, @@ -23,16 +24,16 @@ import { getKindEntities } from './entities'; * Requests categories from the REST API, yielding action objects on request * progress. */ -export async function* getCategories() { - const categories = await apiFetch( { path: '/wp/v2/categories?per_page=-1' } ); +export function* getCategories() { + const categories = yield fetchFromAPI( { path: '/wp/v2/categories?per_page=-1' } ); yield receiveTerms( 'categories', categories ); } /** * Requests authors from the REST API. */ -export async function* getAuthors() { - const users = await apiFetch( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); +export function* getAuthors() { + const users = yield fetchFromAPI( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); yield receiveUserQuery( 'authors', users ); } @@ -74,7 +75,7 @@ export async function* getEntityRecords( state, kind, name ) { /** * Requests theme supports data from the index. */ -export async function* getThemeSupports() { - const index = await apiFetch( { path: '/' } ); +export function* getThemeSupports() { + const index = yield fetchFromAPI( { path: '/' } ); yield receiveThemeSupportsFromIndex( index ); } diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index c721f1ddd895e..b18772817506b 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -7,25 +7,25 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import { getCategories, getEntityRecord, getEntityRecords } from '../resolvers'; -import { receiveTerms, receiveEntityRecords, addEntities } from '../actions'; +import { + fetchFromAPI, + receiveTerms, + receiveEntityRecords, + addEntities, +} from '../actions'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); describe( 'getCategories', () => { const CATEGORIES = [ { id: 1 } ]; - beforeAll( () => { - apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/categories?per_page=-1' ) { - return Promise.resolve( CATEGORIES ); - } - } ); - } ); - it( 'yields with requested terms', async () => { const fulfillment = getCategories(); - const received = ( await fulfillment.next() ).value; - expect( received ).toEqual( receiveTerms( 'categories', CATEGORIES ) ); + let yielded; + yielded = fulfillment.next().value; + expect( yielded.type ).toBe( fetchFromAPI().type ); + yielded = fulfillment.next( CATEGORIES ).value; + expect( yielded ).toEqual( receiveTerms( 'categories', CATEGORIES ) ); } ); } ); From 5d9b7ad681f7b3b9c6c5010cc1f5dd28f4949a47 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Sat, 4 Aug 2018 19:21:01 -0400 Subject: [PATCH 6/9] Data: Revert deprecation of async generator --- packages/core-data/src/actions.js | 16 ---------- packages/core-data/src/controls.js | 16 ---------- packages/core-data/src/index.js | 2 -- packages/core-data/src/resolvers.js | 13 ++++---- packages/core-data/src/test/resolvers.js | 22 +++++++------- packages/data/src/registry.js | 38 ++++++++---------------- 6 files changed, 29 insertions(+), 78 deletions(-) delete mode 100644 packages/core-data/src/controls.js diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3f550becca66e..31107d8daef10 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,22 +3,6 @@ */ import { castArray } from 'lodash'; -/** - * Given an apiFetch payload, returns an action object used in signalling that - * an API request should be issued. This can be used in tandem with the data - * module's `controls` option for asynchronous API request logic flows. - * - * @param {Object} options apiFetch options payload. - * - * @return {Object} Action object. - */ -export function fetchFromAPI( options ) { - return { - type: 'FETCH_FROM_API', - ...options, - }; -} - /** * Returns an action object used in signalling that terms have been received * for a given taxonomy. diff --git a/packages/core-data/src/controls.js b/packages/core-data/src/controls.js deleted file mode 100644 index 981533d5b101e..0000000000000 --- a/packages/core-data/src/controls.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; - -/** - * Given an action object, triggers an API request to retrieve the result from - * the given action path. - * - * @param {Object} action Fetch action object. - * - * @return {Promise} Fetch promise. - */ -export function FETCH_FROM_API( action ) { - return apiFetch( { path: action.path } ); -} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index ae1d4a2adece5..31fe114746930 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -10,7 +10,6 @@ import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; -import * as controls from './controls'; import { defaultEntities, getMethodName } from './entities'; import { REDUCER_KEY } from './name'; @@ -27,7 +26,6 @@ const entitySelectors = createEntityRecordGetter( selectors ); const store = registerStore( REDUCER_KEY, { reducer, actions, - controls, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a267257012eaf..a105997bfedd6 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -12,7 +12,6 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import { - fetchFromAPI, receiveTerms, receiveUserQuery, receiveEntityRecords, @@ -24,16 +23,16 @@ import { getKindEntities } from './entities'; * Requests categories from the REST API, yielding action objects on request * progress. */ -export function* getCategories() { - const categories = yield fetchFromAPI( { path: '/wp/v2/categories?per_page=-1' } ); +export async function* getCategories() { + const categories = await apiFetch( { path: '/wp/v2/categories?per_page=-1' } ); yield receiveTerms( 'categories', categories ); } /** * Requests authors from the REST API. */ -export function* getAuthors() { - const users = yield fetchFromAPI( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); +export async function* getAuthors() { + const users = await apiFetch( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); yield receiveUserQuery( 'authors', users ); } @@ -75,7 +74,7 @@ export async function* getEntityRecords( state, kind, name ) { /** * Requests theme supports data from the index. */ -export function* getThemeSupports() { - const index = yield fetchFromAPI( { path: '/' } ); +export async function* getThemeSupports() { + const index = await apiFetch( { path: '/' } ); yield receiveThemeSupportsFromIndex( index ); } diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index b18772817506b..c721f1ddd895e 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -7,25 +7,25 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import { getCategories, getEntityRecord, getEntityRecords } from '../resolvers'; -import { - fetchFromAPI, - receiveTerms, - receiveEntityRecords, - addEntities, -} from '../actions'; +import { receiveTerms, receiveEntityRecords, addEntities } from '../actions'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); describe( 'getCategories', () => { const CATEGORIES = [ { id: 1 } ]; + beforeAll( () => { + apiFetch.mockImplementation( ( options ) => { + if ( options.path === '/wp/v2/categories?per_page=-1' ) { + return Promise.resolve( CATEGORIES ); + } + } ); + } ); + it( 'yields with requested terms', async () => { const fulfillment = getCategories(); - let yielded; - yielded = fulfillment.next().value; - expect( yielded.type ).toBe( fetchFromAPI().type ); - yielded = fulfillment.next( CATEGORIES ).value; - expect( yielded ).toEqual( receiveTerms( 'categories', CATEGORIES ) ); + const received = ( await fulfillment.next() ).value; + expect( received ).toEqual( receiveTerms( 'categories', CATEGORIES ) ); } ); } ); diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index bd0e2ba009ac0..6e6ff59a0609a 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -223,34 +223,20 @@ export function createRegistry( storeConfigs = {} ) { let fulfillment = resolver.fulfill( state, ...args ); // Attempt to normalize fulfillment as async iterable. - if ( isGenerator( fulfillment ) ) { - // Override original fulfillment to trigger resolution - // finish once deferred yielded result is completed. - const originalFulfillment = fulfillment; - fulfillment = ( function* () { - yield* originalFulfillment; - finishResolution( reducerKey, selectorName, args ); - }() ); - } else { - // Attempt to normalize fulfillment as async iterable. - fulfillment = toAsyncIterable( fulfillment ); - if ( isAsyncIterable( fulfillment ) ) { - deprecated( 'Asynchronous iterable resolvers', { - alternative: 'synchronous generators with `controls` plugin', - plugin: 'Gutenberg', - version: '3.7', - } ); - - for await ( const maybeAction of fulfillment ) { - // Dispatch if it quacks like an action. - if ( isActionLike( maybeAction ) ) { - store.dispatch( maybeAction ); - } - } - } - + fulfillment = toAsyncIterable( fulfillment ); + if ( ! isAsyncIterable( fulfillment ) ) { finishResolution( reducerKey, selectorName, args ); + return; } + + for await ( const maybeAction of fulfillment ) { + // Dispatch if it quacks like an action. + if ( isActionLike( maybeAction ) ) { + store.dispatch( maybeAction ); + } + } + + finishResolution( reducerKey, selectorName, args ); } if ( typeof resolver.isFulfilled === 'function' ) { From a675f4932cd52dd8015749d1c5018fdeb24dbab9 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 6 Aug 2018 16:47:21 -0400 Subject: [PATCH 7/9] Docs: Add note about plugin opt-in for controls --- packages/data/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/data/README.md b/packages/data/README.md index 9a478330d4236..e40a06d80b0b8 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -129,6 +129,8 @@ The `resolvers` option should be passed as an object where each key is the name ### `controls` +_**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. From c1f1c3570190aa8b8f76ce5707c9630e873cdf09 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 6 Aug 2018 20:33:32 -0400 Subject: [PATCH 8/9] ReduxRoutine: Improve isGenerator accuracy --- packages/redux-routine/src/is-generator.js | 7 ++++++- packages/redux-routine/src/test/is-generator.js | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/redux-routine/src/is-generator.js b/packages/redux-routine/src/is-generator.js index 60fd2e3bc3b73..f546d34cc80d9 100644 --- a/packages/redux-routine/src/is-generator.js +++ b/packages/redux-routine/src/is-generator.js @@ -1,10 +1,15 @@ /** * Returns true if the given object is a generator, or false otherwise. * + * @link https://www.ecma-international.org/ecma-262/6.0/#sec-generator-objects + * * @param {*} object Object to test. * * @return {boolean} Whether object is a generator. */ export default function isGenerator( object ) { - return !! object && typeof object.next === 'function'; + return ( + object && + object[ Symbol.toStringTag ] === 'Generator' + ); } diff --git a/packages/redux-routine/src/test/is-generator.js b/packages/redux-routine/src/test/is-generator.js index 83ff508377470..f872989dc0443 100644 --- a/packages/redux-routine/src/test/is-generator.js +++ b/packages/redux-routine/src/test/is-generator.js @@ -17,6 +17,18 @@ describe( 'isGenerator', () => { } ); } ); + it( 'should return false if an imposter!', () => { + const value = { next() {} }; + + expect( isGenerator( value ) ).toBe( false ); + } ); + + it( 'should return false if an async generator', () => { + const value = ( async function* () {}() ); + + expect( isGenerator( value ) ).toBe( false ); + } ); + it( 'should return true if a generator', () => { const value = ( function* () {}() ); From 421c3a6d9153704b1a0bc1b7d6d479355377adfa Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 7 Aug 2018 09:58:04 +0100 Subject: [PATCH 9/9] Fix redux-routine unit tests --- packages/redux-routine/package.json | 2 +- packages/redux-routine/src/is-generator.js | 2 +- packages/redux-routine/src/test/index.js | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json index c1dfc06917561..9671643aa325f 100644 --- a/packages/redux-routine/package.json +++ b/packages/redux-routine/package.json @@ -1,7 +1,7 @@ { "name": "@wordpress/redux-routine", "version": "1.0.0", - "description": "Redux middleware for generator coroutines", + "description": "Redux middleware for generator coroutines.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", "keywords": [ diff --git a/packages/redux-routine/src/is-generator.js b/packages/redux-routine/src/is-generator.js index f546d34cc80d9..70ce6002f1a10 100644 --- a/packages/redux-routine/src/is-generator.js +++ b/packages/redux-routine/src/is-generator.js @@ -9,7 +9,7 @@ */ export default function isGenerator( object ) { return ( - object && + !! object && object[ Symbol.toStringTag ] === 'Generator' ); } diff --git a/packages/redux-routine/src/test/index.js b/packages/redux-routine/src/test/index.js index 5a82acd8a88fb..fe8d40ba9ecbb 100644 --- a/packages/redux-routine/src/test/index.js +++ b/packages/redux-routine/src/test/index.js @@ -51,7 +51,6 @@ describe( 'createMiddleware', () => { expect( store.getState() ).toBe( null ); jest.runAllTimers(); - expect( store.getState() ).toBe( null ); // Promise resolution occurs on next tick. process.nextTick( () => { @@ -135,7 +134,6 @@ describe( 'createMiddleware', () => { expect( store.getState() ).toBe( null ); jest.runAllTimers(); - expect( store.getState() ).toBe( null ); process.nextTick( () => { expect( store.getState() ).toBe( 2 );