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 Module: Adding support for sync generators to allow async action creators #7546

Closed
wants to merge 8 commits into from
5 changes: 5 additions & 0 deletions 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 @@ -55,6 +55,7 @@
"redux-optimist": "1.0.0",
"refx": "3.0.0",
"rememo": "3.0.0",
"rungen": "0.3.2",
"showdown": "1.8.6",
"simple-html-tokenizer": "0.4.1",
"tinycolor2": "1.4.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const getMethodName = ( kind, name, prefix = 'get', usePlural = false ) =
*
* @return {Array} Entities
*/
export async function* getKindEntities( state, kind ) {
export function* getKindEntities( state, kind ) {
let entities = getEntitiesByKind( state, kind );

if ( entities && entities.length !== 0 ) {
Expand All @@ -78,7 +78,7 @@ export async function* getKindEntities( state, kind ) {
return [];
}

entities = await kindConfig.loadEntities();
entities = yield kindConfig.loadEntities();
yield addEntities( entities );

return entities;
Expand Down
24 changes: 12 additions & 12 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ import { getKindEntities } from './entities';
* Requests categories from the REST API, yielding action objects on request
* progress.
*/
export async function* getCategories() {
const categories = await apiRequest( { path: '/wp/v2/categories?per_page=-1' } );
export function* getCategories() {
const categories = yield apiRequest( { path: '/wp/v2/categories?per_page=-1' } );
Copy link
Member

@aduth aduth Jun 26, 2018

Choose a reason for hiding this comment

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

So I'm a bit confused about our intended usage here. In this case, the promise returned by apiRequest does not return an action object, yet we're yielding it the same as we would for any other action. I suppose as implemented, it's up to the runtime to determine whether it "looks" like an action to decide whether it should be dispatched? Not always clear at a glance whether the thing being yielded is an action or not.

I guess in my mind, I had imagined something more like:

const categories = yield { type: 'API_REQUEST', path: '/wp/v2/categories?per_page=-1' };

i.e. always yielding a proper action to which we can attach specific behaviors (through some API to add controls to rungen dynamically). I think this could also help rein in control to prevent abuse of promises by limiting the control flows exposed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

const categories = yield { type: 'API_REQUEST', path: '/wp/v2/categories?per_page=-1' };

I think that's the goal, I won't consider these "actions" but "effects". I didn't want to introduce new effects on the initial implementation.

Also, I tend to think we shouldn't couple the data module to API requests. I'd prefer something more generic:

yield { type: 'CALL', function: apiRequest, args: { path: '/wp/v2/categories?per_page=-1' } }

I think it's possible to keep the data module REST API agnostic and still allow you to do what you propose, but this requires having the ability to provide custom controls to the rungen runtime when calling registerStore, not sure how I feel about it. I tend to prefer starting small

Copy link
Member

Choose a reason for hiding this comment

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

Also, I tend to think we shouldn't couple the data module to API requests. I'd prefer something more generic:

I know this isn't a democracy, but I agree 😆

Keeping the module API-agnostic means testing/mocking is easier and it's easy to allow people to use their own functions to intercept/modify certain calls if wanted. Win/win in my mind.

Copy link
Member

Choose a reason for hiding this comment

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

We've already promoted resolvers as being the swappable layer of abstraction, so do we really have to go the next step further and also make its yielded actions generic?

Copy link
Member

Choose a reason for hiding this comment

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

I suppose not, but I find there's a lot of flexibility in this approach.

If I wanted to test the generic approach, it would be easier, for instance.

I suppose I don't see the harm in it but I see benefits.

I'm not sure what the "abuse of promises" would be as mentioned earlier, but I'm generally in favour of us giving ourselves lots of flexibility. I know flexibility is another word for footgun, but I guess I don't see what the danger is here. 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

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

I know flexibility is another word for footgun,

This many times.

I've often pointed to the effects.js file as a prime example of flexibility gone wrong, as the file has been a failure in almost every regard: Lack of self-contained documented functions, non-obvious behaviors, callback hell, side effects which aren't side effects, implicit behaviors by reaching into state.

Copy link
Member

Choose a reason for hiding this comment

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

For what it's worth though, the proposed, saga-style pattern is more idiomatic and in my experience using something similar, never bit us or made us write confusing/dangerous code on the Add-ons project.

The effects are definitely a bit too out there. I don't think what's being proposed is as flexible or dangerous, and I think perhaps some intense code review around the first set of patches could lead to us creating strong idioms we could document and stick with.

Copy link
Member

Choose a reason for hiding this comment

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

holy cannoli.

function* foo() { yield new Promise() }

and oh how nasty this is without strong static types,
without the compiler helping make sense of it.

just had to toss that out there 😉

Copy link
Member

@aduth aduth Jul 20, 2018

Choose a reason for hiding this comment

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

Also, I tend to think we shouldn't couple the data module to API requests. I'd prefer something more generic:

In talking about this with @youknowriad today, I came to realize I misinterpreted the point here. I never meant to suggest that the data module should have awareness of how to make API interactions, but rather that it should provide the tools to allow an individual store to define its own continuation procedures for specific action types, so if the core-data store decided it needed a way to make API calls, it could define this through some API exposed on the data module (the generalized "controls" structure).

Rough idea:

registerStore( 'core', {
    // ...
    
    controls: {
        async API_REQUEST( store, next, action ) {
            next( await apiFetch( action.path ) );
        },
    }
} );

Unsure if it needs to be middleware-esque, or just return the Promise:

registerStore( 'core', {
    // ...
    
    controls: {
        API_REQUEST( action, store ) {
            return apiFetch( action.path );
        },
    }
} );

Then what we have here becomes:

export function* getCategories() {
	const categories = yield { type: 'API_REQUEST', path: '/wp/v2/categories?per_page=-1' };
	// OR: yield apiRequest( { path: '/wp/v2/categories?per_page=-1' } );
	//     ... where `apiRequest` is a locally-defined action creator
	yield receiveTerms( 'categories', categories );
}

It's a small difference, but intentionally restricts what's possible in the action creators alone to avoid the footgun effect of allowing unrestricted promises, encouraging instead a common set of well-defined continuation conditions. Further, it's more consistent in that the only thing that's yielded are action objects.

yield receiveTerms( 'categories', categories );
}

/**
* Requests authors from the REST API.
*/
export async function* getAuthors() {
const users = await apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } );
export function* getAuthors() {
const users = yield apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } );
yield receiveUserQuery( 'authors', users );
}

Expand All @@ -44,13 +44,13 @@ export async function* getAuthors() {
* @param {string} name Entity name.
* @param {number} key Record's key
*/
export async function* getEntityRecord( state, kind, name, key ) {
const entities = yield* await getKindEntities( state, kind );
export function* getEntityRecord( state, kind, name, key ) {
const entities = yield getKindEntities( state, kind );
const entity = find( entities, { kind, name } );
if ( ! entity ) {
return;
}
const record = await apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } );
const record = yield apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } );
yield receiveEntityRecords( kind, name, record );
}

Expand All @@ -61,20 +61,20 @@ export async function* getEntityRecord( state, kind, name, key ) {
* @param {string} kind Entity kind.
* @param {string} name Entity name.
*/
export async function* getEntityRecords( state, kind, name ) {
const entities = yield* await getKindEntities( state, kind );
export function* getEntityRecords( state, kind, name ) {
const entities = yield getKindEntities( state, kind );
const entity = find( entities, { kind, name } );
if ( ! entity ) {
return;
}
const records = await apiRequest( { path: `${ entity.baseUrl }?context=edit` } );
const records = yield apiRequest( { path: `${ entity.baseUrl }?context=edit` } );
yield receiveEntityRecords( kind, name, Object.values( records ) );
}

/**
* Requests theme supports data from the index.
*/
export async function* getThemeSupports() {
const index = await apiRequest( { path: '/' } );
export function* getThemeSupports() {
const index = yield apiRequest( { path: '/' } );
yield receiveThemeSupportsFromIndex( index );
}
11 changes: 6 additions & 5 deletions packages/core-data/src/test/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,27 @@ describe( 'getKindEntities', () => {
} );
} );

it( 'shouldn\'t do anything if the entities have already been resolved', async () => {
it( 'shouldn\'t do anything if the entities have already been resolved', () => {
const state = {
entities: { config: [ { kind: 'postType' } ] },
};
const fulfillment = getKindEntities( state, 'postType' );
const done = ( await fulfillment.next() ).done;
const done = fulfillment.next().done;
expect( done ).toBe( true );
} );

it( 'shouldn\'t do anything if there no defined kind config', async () => {
it( 'shouldn\'t do anything if there no defined kind config', () => {
const state = { entities: { config: [] } };
const fulfillment = getKindEntities( state, 'unknownKind' );
const done = ( await fulfillment.next() ).done;
const done = fulfillment.next().done;
expect( done ).toBe( true );
} );

it( 'should fetch and add the entities', async () => {
const state = { entities: { config: [] } };
const fulfillment = getKindEntities( state, 'postType' );
const received = ( await fulfillment.next() ).value;
const receivedEntities = await fulfillment.next().value;
const received = ( fulfillment.next( receivedEntities ) ).value;
expect( received ).toEqual( addEntities( [ {
baseUrl: '/wp/v2/posts',
kind: 'postType',
Expand Down
47 changes: 10 additions & 37 deletions packages/core-data/src/test/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe( 'getCategories', () => {

it( 'yields with requested terms', async () => {
const fulfillment = getCategories();
const received = ( await fulfillment.next() ).value;
const categories = await fulfillment.next().value;
const received = fulfillment.next( categories ).value;
expect( received ).toEqual( receiveTerms( 'categories', CATEGORIES ) );
} );
} );
Expand All @@ -43,39 +44,16 @@ describe( 'getEntityRecord', () => {
if ( options.path === '/wp/v2/types/post?context=edit' ) {
return Promise.resolve( POST_TYPE );
}
if ( options.path === '/wp/v2/posts/10?context=edit' ) {
return Promise.resolve( POST );
}
if ( options.path === '/wp/v2/types?context=edit' ) {
return Promise.resolve( POST_TYPES );
}
} );
} );

it( 'yields with requested post type', async () => {
const state = {
entities: {
config: [
{ name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' },
],
},
};
const fulfillment = getEntityRecord( state, 'root', 'postType', 'post' );
const received = ( await fulfillment.next() ).value;
const fulfillment = getEntityRecord( {}, 'root', 'postType', 'post' );
fulfillment.next(); // Trigger the getKindEntities generator
const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value;
const received = await fulfillment.next( records ).value;
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', POST_TYPE ) );
} );

it( 'loads the kind entities and yields with requested post type', async () => {
const fulfillment = getEntityRecord( { entities: {} }, 'postType', 'post', 10 );
const receivedEntities = ( await fulfillment.next() ).value;
expect( receivedEntities ).toEqual( addEntities( [ {
baseUrl: '/wp/v2/posts',
kind: 'postType',
name: 'post',
} ] ) );
const received = ( await fulfillment.next() ).value;
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', POST ) );
} );
} );

describe( 'getEntityRecords', () => {
Expand All @@ -93,15 +71,10 @@ describe( 'getEntityRecords', () => {
} );

it( 'yields with requested post type', async () => {
const state = {
entities: {
config: [
{ name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' },
],
},
};
const fulfillment = getEntityRecords( state, 'root', 'postType' );
const received = ( await fulfillment.next() ).value;
const fulfillment = getEntityRecords( {}, 'root', 'postType' );
fulfillment.next(); // Trigger the getKindEntities generator
const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value;
const received = ( await fulfillment.next( records ) ).value;
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ) ) );
} );
} );
10 changes: 10 additions & 0 deletions packages/data/src/components/registry-provider/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';

const { Consumer, Provider } = createContext( null );

export const RegistryConsumer = Consumer;

export default Provider;
94 changes: 94 additions & 0 deletions packages/data/src/components/with-dispatch/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { mapValues } from 'lodash';

/**
* WordPress dependencies
*/
import {
Component,
compose,
createElement,
createHigherOrderComponent,
pure,
} from '@wordpress/element';

/**
* Internal dependencies
*/
import defaultRegistry from '../../default-registry';
import { RegistryConsumer } from '../registry-provider';

/**
* Higher-order component used to add dispatch props using registered action
* creators.
*
* @param {Object} mapDispatchToProps Object of prop names where value is a
* dispatch-bound action creator, or a
* function to be called with with the
* component's props and returning an
* action creator.
*
* @return {Component} Enhanced component with merged dispatcher props.
*/
const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent(
compose( [
pure,
( WrappedComponent ) => {
class ComponentWithDispatch extends Component {
constructor( props ) {
super( ...arguments );

this.proxyProps = {};
this.setProxyProps( props );
}

componentDidUpdate() {
this.setProxyProps( this.props );
}

proxyDispatch( propName, ...args ) {
// Original dispatcher is a pre-bound (dispatching) action creator.
const dispatch = this.props.registry ? this.props.registry.dispatch : defaultRegistry.dispatch;
mapDispatchToProps( dispatch, this.props.ownProps )[ propName ]( ...args );
}

setProxyProps( props ) {
// Assign as instance property so that in reconciling subsequent
// renders, the assigned prop values are referentially equal.
const dispatch = props.registry ? props.registry.dispatch : defaultRegistry.dispatch;
const propsToDispatchers = mapDispatchToProps( dispatch, props.ownProps );
this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => {
// Prebind with prop name so we have reference to the original
// dispatcher to invoke. Track between re-renders to avoid
// creating new function references every render.
if ( this.proxyProps.hasOwnProperty( propName ) ) {
return this.proxyProps[ propName ];
}

return this.proxyDispatch.bind( this, propName );
} );
}

render() {
return <WrappedComponent { ...this.props.ownProps } { ...this.proxyProps } />;
}
}

return ( ownProps ) => (
<RegistryConsumer>
{ ( registry ) => (
<ComponentWithDispatch
ownProps={ ownProps }
registry={ registry }
/>
) }
</RegistryConsumer>
);
},
] ),
'withDispatch'
);

export default withDispatch;
69 changes: 69 additions & 0 deletions packages/data/src/components/with-dispatch/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';

/**
* WordPress dependencies
*/
import { createElement } from '@wordpress/element';

/**
* Internal dependencies
*/
import withDispatch from '../';
import { createRegistry } from '../../../registry';
import RegistryProvider from '../../registry-provider';

describe( 'withDispatch', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
} );

it( 'passes the relevant data to the component', () => {
const store = registry.registerStore( 'counter', {
reducer: ( state = 0, action ) => {
if ( action.type === 'increment' ) {
return state + action.count;
}
return state;
},
actions: {
increment: ( count = 1 ) => ( { type: 'increment', count } ),
},
} );

const Component = withDispatch( ( _dispatch, ownProps ) => {
const { count } = ownProps;

return {
increment: () => _dispatch( 'counter' ).increment( count ),
};
} )( ( props ) => <button onClick={ props.increment } /> );

const testRenderer = TestRenderer.create(
<RegistryProvider value={ registry }>
<Component count={ 0 } />
</RegistryProvider>
);
const testInstance = testRenderer.root;

const incrementBeforeSetProps = testInstance.findByType( 'button' ).props.onClick;

// Verify that dispatch respects props at the time of being invoked by
// changing props after the initial mount.
testRenderer.update(
<RegistryProvider value={ registry }>
<Component count={ 2 } />
</RegistryProvider>
);

// Function value reference should not have changed in props update.
expect( testInstance.findByType( 'button' ).props.onClick ).toBe( incrementBeforeSetProps );

incrementBeforeSetProps();

expect( store.getState() ).toBe( 2 );
} );
} );
Loading