diff --git a/data/index.js b/data/index.js index 691033b38d1f2d..d3e24f7132e895 100644 --- a/data/index.js +++ b/data/index.js @@ -9,7 +9,7 @@ import memoize from 'memize'; /** * WordPress dependencies */ -import { Component, createHigherOrderComponent } from '@wordpress/element'; +import { Component, createHigherOrderComponent, pure, compose } from '@wordpress/element'; /** * Internal dependencies @@ -241,67 +241,69 @@ export function dispatch( reducerKey ) { * * @return {Component} Enhanced component with merged state data props. */ -export const withSelect = ( mapStateToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { - return class ComponentWithSelect extends Component { - constructor() { - super( ...arguments ); +export const withSelect = ( mapStateToProps ) => createHigherOrderComponent( + compose( [ + pure, + ( WrappedComponent ) => { + return class ComponentWithSelect extends Component { + constructor() { + super( ...arguments ); - this.runSelection = this.runSelection.bind( this ); + this.runSelection = this.runSelection.bind( this ); - this.state = {}; - } - - shouldComponentUpdate( nextProps, nextState ) { - return ! isShallowEqual( nextProps, this.props ) || ! isShallowEqual( nextState, this.state ); - } + this.state = {}; + } - componentWillMount() { - this.subscribe(); + componentWillMount() { + this.subscribe(); - // Populate initial state. - this.runSelection(); - } + // Populate initial state. + this.runSelection(); + } - componentWillReceiveProps( nextProps ) { - if ( ! isShallowEqual( nextProps, this.props ) ) { - this.runSelection( nextProps ); - } - } + componentWillReceiveProps( nextProps ) { + if ( ! isShallowEqual( nextProps, this.props ) ) { + this.runSelection( nextProps ); + } + } - componentWillUnmount() { - this.unsubscribe(); + componentWillUnmount() { + this.unsubscribe(); - // While above unsubscribe avoids future listener calls, callbacks - // are snapshotted before being invoked, so if unmounting occurs - // during a previous callback, we need to explicitly track and - // avoid the `runSelection` that is scheduled to occur. - this.isUnmounting = true; - } + // While above unsubscribe avoids future listener calls, callbacks + // are snapshotted before being invoked, so if unmounting occurs + // during a previous callback, we need to explicitly track and + // avoid the `runSelection` that is scheduled to occur. + this.isUnmounting = true; + } - subscribe() { - this.unsubscribe = subscribe( this.runSelection ); - } + subscribe() { + this.unsubscribe = subscribe( this.runSelection ); + } - runSelection( props = this.props ) { - if ( this.isUnmounting ) { - return; - } + runSelection( props = this.props ) { + if ( this.isUnmounting ) { + return; + } - const { mergeProps } = this.state; - const nextMergeProps = mapStateToProps( select, props ) || {}; + const { mergeProps } = this.state; + const nextMergeProps = mapStateToProps( select, props ) || {}; - if ( ! isShallowEqual( nextMergeProps, mergeProps ) ) { - this.setState( { - mergeProps: nextMergeProps, - } ); - } - } + if ( ! isShallowEqual( nextMergeProps, mergeProps ) ) { + this.setState( { + mergeProps: nextMergeProps, + } ); + } + } - render() { - return ; - } - }; -}, 'withSelect' ); + render() { + return ; + } + }; + }, + ] ), + 'withSelect' +); /** * Higher-order component used to add dispatch props using registered action @@ -315,48 +317,54 @@ export const withSelect = ( mapStateToProps ) => createHigherOrderComponent( ( W * * @return {Component} Enhanced component with merged dispatcher props. */ -export const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { - return class ComponentWithDispatch extends Component { - constructor() { - super( ...arguments ); - - this.proxyProps = {}; - } - - componentWillMount() { - this.setProxyProps( this.props ); - } +export const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( + compose( [ + pure, + ( WrappedComponent ) => { + return class ComponentWithDispatch extends Component { + constructor() { + super( ...arguments ); + + this.proxyProps = {}; + } - componentWillUpdate( nextProps ) { - this.setProxyProps( nextProps ); - } + componentWillMount() { + this.setProxyProps( this.props ); + } - proxyDispatch( propName, ...args ) { - // Original dispatcher is a pre-bound (dispatching) action creator. - mapDispatchToProps( dispatch, this.props )[ propName ]( ...args ); - } + componentWillUpdate( nextProps ) { + this.setProxyProps( nextProps ); + } - setProxyProps( props ) { - // Assign as instance property so that in reconciling subsequent - // renders, the assigned prop values are referentially equal. - const propsToDispatchers = mapDispatchToProps( dispatch, props ); - 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 ]; + proxyDispatch( propName, ...args ) { + // Original dispatcher is a pre-bound (dispatching) action creator. + mapDispatchToProps( dispatch, this.props )[ propName ]( ...args ); } - return this.proxyDispatch.bind( this, propName ); - } ); - } + setProxyProps( props ) { + // Assign as instance property so that in reconciling subsequent + // renders, the assigned prop values are referentially equal. + const propsToDispatchers = mapDispatchToProps( dispatch, props ); + 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 ; - } - }; -}, 'withDispatch' ); + render() { + return ; + } + }; + }, + ] ), + 'withDispatch' +); /** * Returns true if the given argument appears to be a dispatchable action. diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 7d5da4e893d4b3..e50825ff706dc8 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -705,5 +705,5 @@ export default compose( }; } ), withFilters( 'editor.BlockListBlock' ), - withHoverAreas + withHoverAreas, )( BlockListBlock ); diff --git a/element/index.js b/element/index.js index ff524b1b7645f1..350fff547a76d7 100644 --- a/element/index.js +++ b/element/index.js @@ -17,6 +17,7 @@ import { isString, upperFirst, } from 'lodash'; +import isShallowEqual from 'shallowequal'; /** * Internal dependencies @@ -209,3 +210,34 @@ export function RawHTML( { children, ...props } ) { ...props, } ); } + +/** + * Given a component returns the enhanced component augmented with a component + * only rerendering when its props/state change + * + * @param {Function} mapComponentToEnhancedComponent Function mapping component + * to enhanced component. + * @param {string} modifierName Seed name from which to + * generated display name. + * + * @return {WPComponent} Component class with generated display name assigned. + */ +export const pure = createHigherOrderComponent( ( Wrapped ) => { + if ( Wrapped.prototype instanceof Component ) { + return class extends Wrapped { + shouldComponentUpdate( nextProps, nextState ) { + return ! isShallowEqual( nextProps, this.props ) || ! isShallowEqual( nextState, this.state ); + } + }; + } + + return class extends Component { + shouldComponentUpdate( nextProps ) { + return ! isShallowEqual( nextProps, this.props ); + } + + render() { + return ; + } + }; +}, 'pure' ); diff --git a/element/test/index.js b/element/test/index.js index daf61f07033c2f..80d06d55452920 100644 --- a/element/test/index.js +++ b/element/test/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; /** * Internal dependencies @@ -14,6 +14,7 @@ import { renderToString, switchChildrenNodeName, RawHTML, + pure, } from '../'; describe( 'element', () => { @@ -212,4 +213,48 @@ describe( 'element', () => { expect( element.prop( 'children' ) ).toBe( undefined ); } ); } ); + + describe( 'pure', () => { + it( 'functional component should rerender only when props change', () => { + let i = 0; + const MyComp = pure( () => { + return

{ ++i }

; + } ); + const wrapper = mount( ); + wrapper.update(); // Updating with same props doesn't rerender + expect( wrapper.html() ).toBe( '

1

' ); + wrapper.setProps( { prop: 'a' } ); // New prop should trigger a rerender + expect( wrapper.html() ).toBe( '

2

' ); + wrapper.setProps( { prop: 'a' } ); // Keeping the same prop value should not rerender + expect( wrapper.html() ).toBe( '

2

' ); + wrapper.setProps( { prop: 'b' } ); // Changing the prop value should rerender + expect( wrapper.html() ).toBe( '

3

' ); + } ); + + it( 'class component should rerender if the props or state change', () => { + let i = 0; + const MyComp = pure( class extends Component { + constructor() { + super( ...arguments ); + this.state = {}; + } + render() { + return

{ ++i }

; + } + } ); + const wrapper = mount( ); + wrapper.update(); // Updating with same props doesn't rerender + expect( wrapper.html() ).toBe( '

1

' ); + wrapper.setProps( { prop: 'a' } ); // New prop should trigger a rerender + expect( wrapper.html() ).toBe( '

2

' ); + wrapper.setProps( { prop: 'a' } ); // Keeping the same prop value should not rerender + expect( wrapper.html() ).toBe( '

2

' ); + wrapper.setProps( { prop: 'b' } ); // Changing the prop value should rerender + expect( wrapper.html() ).toBe( '

3

' ); + wrapper.setState( { state: 'a' } ); // New state value should trigger a rerender + expect( wrapper.html() ).toBe( '

4

' ); + wrapper.setState( { state: 'a' } ); // Keeping the same state value should not trigger a rerender + expect( wrapper.html() ).toBe( '

4

' ); + } ); + } ); } );