From 1949e3a9c00f556146700a383a1e3accdc12fdb8 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 7 Aug 2018 05:32:49 -0400 Subject: [PATCH] Data: Add redux-routine package for synchronous generator flow (#8096) * Packages: Add package `redux-routine` * Packages: Throw rejected promise as error to generator * Data: Add support for controls via redux-routine * Core Data: Partially reimplement resolvers as controls * Data: Port controls as data plugin * Data: Revert deprecation of async generator * Docs: Add note about plugin opt-in for controls * ReduxRoutine: Improve isGenerator accuracy * Fix redux-routine unit tests --- lib/client-assets.php | 20 ++- package-lock.json | 31 +++- package.json | 1 + packages/data/README.md | 93 ++++++++--- packages/data/package.json | 3 +- packages/data/src/plugins/controls/index.js | 30 ++++ packages/data/src/plugins/index.js | 1 + packages/data/src/registry.js | 14 +- packages/redux-routine/.npmrc | 1 + packages/redux-routine/README.md | 108 ++++++++++++ packages/redux-routine/package.json | 30 ++++ packages/redux-routine/src/cast-error.js | 14 ++ packages/redux-routine/src/index.js | 53 ++++++ packages/redux-routine/src/is-generator.js | 15 ++ packages/redux-routine/src/test/cast-error.js | 18 ++ packages/redux-routine/src/test/index.js | 158 ++++++++++++++++++ .../redux-routine/src/test/is-generator.js | 37 ++++ webpack.config.js | 2 + 18 files changed, 592 insertions(+), 37 deletions(-) create mode 100644 packages/data/src/plugins/controls/index.js 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/cast-error.js 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/cast-error.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/lib/client-assets.php b/lib/client-assets.php index 0dfce73ab9f28..bcf6027e3877a 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 ); @@ -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 );', '} )()', ) ) ); @@ -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 + ); 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 8ec6cb6767be4..27462f0101fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3403,7 +3403,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" }, @@ -3603,6 +3604,34 @@ "postcss": "^6.0.16" } }, + "@wordpress/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", "dev": true, diff --git a/package.json b/package.json index 62887c73785e8..2db6f80991f59 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/data/README.md b/packages/data/README.md index 0fb8e9a3e61d7..02a08b12b31c9 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,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` + +_**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. 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/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 e34ac42a08103..6e6ff59a0609a 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -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. * @@ -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. * @@ -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 ); } 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..9671643aa325f --- /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/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 new file mode 100644 index 0000000000000..fce78e18a7519 --- /dev/null +++ b/packages/redux-routine/src/index.js @@ -0,0 +1,53 @@ +/** + * 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 + * 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 ), + ( error ) => action.throw( castError( error ) ), + ); + } 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..70ce6002f1a10 --- /dev/null +++ b/packages/redux-routine/src/is-generator.js @@ -0,0 +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 && + object[ Symbol.toStringTag ] === 'Generator' + ); +} 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 new file mode 100644 index 0000000000000..fe8d40ba9ecbb --- /dev/null +++ b/packages/redux-routine/src/test/index.js @@ -0,0 +1,158 @@ +/** + * 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(); + + // Promise resolution occurs on next tick. + process.nextTick( () => { + expect( store.getState() ).toBe( 1 ); + done(); + } ); + } ); + + 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, + } ); + 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(); + + 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..f872989dc0443 --- /dev/null +++ b/packages/redux-routine/src/test/is-generator.js @@ -0,0 +1,37 @@ +/** + * 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 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* () {}() ); + + 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 ) => ( {