Skip to content

Commit

Permalink
Data: Add a batch function to the data module to batch actions (#34046)
Browse files Browse the repository at this point in the history
  • Loading branch information
youknowriad authored Aug 17, 2021
1 parent 5df0cd5 commit 76fe632
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 14 deletions.
4 changes: 4 additions & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Features

- Added a `batch` registry method to batch dispatch calls for performance reasons.

## 6.0.0 (2021-07-29)

### Breaking Change
Expand Down
31 changes: 30 additions & 1 deletion packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ import { RegistryProvider, createRegistry, useRegistry } from '@wordpress/data';
const registry = createRegistry( {} );

const SomeChildUsingRegistry = ( props ) => {
const registry = useRegistry( registry );
const registry = useRegistry();
// ...logic implementing the registry in other react hooks.
};

Expand Down Expand Up @@ -973,6 +973,35 @@ _Returns_

<!-- END TOKEN(Autogenerated API docs) -->

### batch

As a response of `dispatch` calls, WordPress data based applications updates the connected components (Components using `useSelect` or `withSelect`). This update happens in two steps:

- The selectors are called with the update state.
- If the selectors return values that are different than the previous (strict equality), the component rerenders.

As the application grows, this can become costful, so it's important to ensure that we avoid running both these if possible. One of these situations happen when an interaction requires multiple consisecutive `dispatch` calls in order to update the state properly. To avoid rerendering the components each time we call `dispatch`, we can wrap the sequential dispatch calls in `batch` which will ensure that the components only call selectors and rerender once at the end of the sequence.

_Usage_

```js
import { useRegistry } from '@wordpress/data';

function Component() {
const registry = useRegistry();

function callback() {
// This will only rerender the components once.
registry.batch( () => {
registry.dispatch( someStore ).someAction();
registry.dispatch( someStore ).someOtherAction();
} );
}

return <button onClick={ callback }>Click me</button>;
}
```

## Going further

- [What is WordPress Data?](https://unfoldingneurons.com/2020/what-is-wordpress-data/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { Context } from './context';
* const registry = createRegistry( {} );
*
* const SomeChildUsingRegistry = ( props ) => {
* const registry = useRegistry( registry );
* const registry = useRegistry();
* // ...logic implementing the registry in other react hooks.
* };
*
Expand Down
46 changes: 38 additions & 8 deletions packages/data/src/registry.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* External dependencies
*/
import { without, mapValues, isObject } from 'lodash';
import { mapValues, isObject, forEach } from 'lodash';

/**
* Internal dependencies
*/
import createReduxStore from './redux-store';
import createCoreDataStore from './store';
import { STORE_NAME } from './store/name';
import { createEmitter } from './utils/emitter';

/** @typedef {import('./types').WPDataStore} WPDataStore */

Expand Down Expand Up @@ -49,14 +50,14 @@ import { STORE_NAME } from './store/name';
*/
export function createRegistry( storeConfigs = {}, parent = null ) {
const stores = {};
let listeners = [];
const emitter = createEmitter();
const __experimentalListeningStores = new Set();

/**
* Global listener called for each store's update.
*/
function globalListener() {
listeners.forEach( ( listener ) => listener() );
emitter.emit();
}

/**
Expand All @@ -67,11 +68,7 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
* @return {Function} Unsubscribe function.
*/
const subscribe = ( listener ) => {
listeners.push( listener );

return () => {
listeners = without( listeners, listener );
};
return emitter.subscribe( listener );
};

/**
Expand Down Expand Up @@ -177,6 +174,30 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
if ( typeof config.subscribe !== 'function' ) {
throw new TypeError( 'config.subscribe must be a function' );
}
// Thi emitter is used to keep track of active listeners when the registry
// get paused, that way, when resumed we should be able to call all these
// pending listeners.
config.emitter = createEmitter();
const currentSubscribe = config.subscribe;
config.subscribe = ( listener ) => {
const unsubscribeFromStoreEmitter = config.emitter.subscribe(
listener
);
const unsubscribeFromRootStore = currentSubscribe( () => {
if ( config.emitter.isPaused ) {
config.emitter.emit();
return;
}
listener();
} );

return () => {
if ( unsubscribeFromRootStore ) {
unsubscribeFromRootStore();
}
unsubscribeFromStoreEmitter();
};
};
stores[ key ] = config;
config.subscribe( globalListener );
}
Expand Down Expand Up @@ -213,7 +234,16 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
return parent.__experimentalSubscribeStore( storeName, handler );
}

function batch( callback ) {
emitter.pause();
forEach( stores, ( store ) => store.emitter.pause() );
callback();
emitter.resume();
forEach( stores, ( store ) => store.emitter.resume() );
}

let registry = {
batch,
registerGenericStore,
stores,
namespaces: stores, // TODO: Deprecate/remove this.
Expand Down
34 changes: 34 additions & 0 deletions packages/data/src/test/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,40 @@ describe( 'createRegistry', () => {
} );
} );

describe( 'batch', () => {
it( 'should batch callbacks and only run the subscriber once', () => {
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
} );
const listener = jest.fn();
subscribeWithUnsubscribe( listener );

registry.batch( () => {} );
expect( listener ).not.toHaveBeenCalled();

registry.batch( () => {
store.dispatch( { type: 'dummy' } );
store.dispatch( { type: 'dummy' } );
} );
expect( listener ).toHaveBeenCalledTimes( 1 );

const listener2 = jest.fn();
// useSelect subscribes to the stores differently,
// This test ensures batching works in this case as well.
const unsubscribe = registry.__experimentalSubscribeStore(
'myAwesomeReducer',
listener2
);
registry.batch( () => {
store.dispatch( { type: 'dummy' } );
store.dispatch( { type: 'dummy' } );
} );
unsubscribe();

expect( listener2 ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'use', () => {
it( 'should pass through options object to plugin', () => {
const expectedOptions = {};
Expand Down
8 changes: 8 additions & 0 deletions packages/data/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ export interface WPDataReduxStoreConfig {
export interface WPDataRegistry {
register: ( store: WPDataStore ) => void;
}

export interface WPDataEmitter {
emit: () => void;
subscribe: ( listener: () => void ) => () => void;
pause: () => void;
resume: () => void;
isPaused: boolean;
}
46 changes: 46 additions & 0 deletions packages/data/src/utils/emitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Create an event emitter.
*
* @return {import("../types").WPDataEmitter} Emitter.
*/
export function createEmitter() {
let isPaused = false;
let isPending = false;
const listeners = new Set();
const notifyListeners = () =>
// We use Array.from to clone the listeners Set
// This ensures that we don't run a listener
// that was added as a response to another listener.
Array.from( listeners ).forEach( ( listener ) => listener() );

return {
get isPaused() {
return isPaused;
},

subscribe( listener ) {
listeners.add( listener );
return () => listeners.delete( listener );
},

pause() {
isPaused = true;
},

resume() {
isPaused = false;
if ( isPending ) {
isPending = false;
notifyListeners();
}
},

emit() {
if ( isPaused ) {
isPending = true;
return;
}
notifyListeners();
},
};
}
1 change: 1 addition & 0 deletions packages/data/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
"include": [
"src/redux-store/metadata/**/*",
"src/promise-middleware.js",
"src/utils",
]
}
13 changes: 9 additions & 4 deletions packages/rich-text/src/component/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { useRef, useLayoutEffect, useReducer } from '@wordpress/element';
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
import { useRegistry } from '@wordpress/data';

/**
* Internal dependencies
Expand Down Expand Up @@ -36,6 +37,7 @@ export function useRichText( {
__unstableBeforeSerialize,
__unstableAddInvisibleFormats,
} ) {
const registry = useRegistry();
const [ , forceRender ] = useReducer( () => ( {} ) );
const ref = useRef();

Expand Down Expand Up @@ -137,10 +139,13 @@ export function useRichText( {

// Selection must be updated first, so it is recorded in history when
// the content change happens.
onSelectionChange( start, end );
onChange( _value.current, {
__unstableFormats: formats,
__unstableText: text,
// We batch both calls to only attempty to rerender once.
registry.batch( () => {
onSelectionChange( start, end );
onChange( _value.current, {
__unstableFormats: formats,
__unstableText: text,
} );
} );
forceRender();
}
Expand Down

0 comments on commit 76fe632

Please sign in to comment.