From e9f38ae151e687e69e5a2703a64b8c21a42a90ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20=28Greg=29=20Zi=C3=B3=C5=82kowski?= Date: Thu, 18 Jan 2018 09:12:51 +0100 Subject: [PATCH] Components: Force update when new filter added or removed when using withFilters HOC (#4428) --- components/higher-order/with-filters/index.js | 42 +++++- .../higher-order/with-filters/test/index.js | 139 +++++++++++++++++- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/components/higher-order/with-filters/index.js b/components/higher-order/with-filters/index.js index 56ab88a7b75c7..8220c87933885 100644 --- a/components/higher-order/with-filters/index.js +++ b/components/higher-order/with-filters/index.js @@ -1,25 +1,63 @@ +/** + * External dependencies + */ +import { debounce, uniqueId } from 'lodash'; + /** * WordPress dependencies */ import { Component, getWrapperDisplayName } from '@wordpress/element'; -import { applyFilters } from '@wordpress/hooks'; +import { addAction, applyFilters, removeAction } from '@wordpress/hooks'; + +const ANIMATION_FRAME_PERIOD = 16; /** * Creates a higher-order component which adds filtering capability to the wrapped component. * Filters get applied when the original component is about to be mounted. + * When a filter is added or removed that matches the hook name, the wrapped component re-renders. * - * @param {String} hookName Hook name exposed to be used by filters. + * @param {string} hookName Hook name exposed to be used by filters. * * @returns {Function} Higher-order component factory. */ export default function withFilters( hookName ) { return ( OriginalComponent ) => { class FilteredComponent extends Component { + /** @inheritdoc */ constructor( props ) { super( props ); + + this.onHooksUpdated = this.onHooksUpdated.bind( this ); this.Component = applyFilters( hookName, OriginalComponent ); + this.namespace = uniqueId( 'core/with-filters/component-' ); + this.throttledForceUpdate = debounce( () => { + this.Component = applyFilters( hookName, OriginalComponent ); + this.forceUpdate(); + }, ANIMATION_FRAME_PERIOD ); + + addAction( 'hookRemoved', this.namespace, this.onHooksUpdated ); + addAction( 'hookAdded', this.namespace, this.onHooksUpdated ); + } + + /** @inheritdoc */ + componentWillUnmount() { + this.throttledForceUpdate.cancel(); + removeAction( 'hookRemoved', this.namespace ); + removeAction( 'hookAdded', this.namespace ); + } + + /** + * When a filter is added or removed for the matching hook name, the wrapped component should re-render. + * + * @param {string} updatedHookName Name of the hook that was updated. + */ + onHooksUpdated( updatedHookName ) { + if ( updatedHookName === hookName ) { + this.throttledForceUpdate(); + } } + /** @inheritdoc */ render() { return ; } diff --git a/components/higher-order/with-filters/test/index.js b/components/higher-order/with-filters/test/index.js index c7931c43d593f..2c01d0ecf9883 100644 --- a/components/higher-order/with-filters/test/index.js +++ b/components/higher-order/with-filters/test/index.js @@ -1,29 +1,33 @@ /** * External dependencies */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; /** * WordPress dependencies */ -import { addFilter, removeAllFilters } from '@wordpress/hooks'; +import { addFilter, removeAllFilters, removeFilter } from '@wordpress/hooks'; /** * Internal dependencies */ -import withFilters from '../'; +import withFilters from '..'; describe( 'withFilters', () => { + let wrapper; + const hookName = 'EnhancedComponent'; const MyComponent = () =>
My component
; afterEach( () => { + wrapper.unmount(); removeAllFilters( hookName ); } ); it( 'should display original component when no filters applied', () => { const EnhancedComponent = withFilters( hookName )( MyComponent ); - const wrapper = shallow( ); + + wrapper = shallow( ); expect( wrapper.html() ).toBe( '
My component
' ); } ); @@ -37,7 +41,7 @@ describe( 'withFilters', () => { ); const EnhancedComponent = withFilters( hookName )( MyComponent ); - const wrapper = shallow( ); + wrapper = shallow( ); expect( wrapper.html() ).toBe( '
Overridden component
' ); } ); @@ -56,8 +60,131 @@ describe( 'withFilters', () => { ); const EnhancedComponent = withFilters( hookName )( MyComponent ); - const wrapper = shallow( ); + wrapper = shallow( ); expect( wrapper.html() ).toBe( '
My component
Composed component
' ); } ); + + it( 'should re-render component once when new filter added after component was mounted', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return
Spied component
; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + + wrapper = mount( ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy-1', + FilteredComponent => () => ( +
+ +
+ ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( wrapper.html() ).toBe( '
Spied component
' ); + } ); + + it( 'should re-render component once when two filters added in the same animation frame', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return
Spied component
; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + wrapper = mount( ); + + spy.mockClear(); + + addFilter( + hookName, + 'test/enhanced-component-spy-1', + FilteredComponent => () => ( +
+ +
+ ), + ); + addFilter( + hookName, + 'test/enhanced-component-spy-2', + FilteredComponent => () => ( +
+ +
+ ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( wrapper.html() ).toBe( '
Spied component
' ); + } ); + + it( 'should re-render component twice when new filter added and removed in two different animation frames', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return
Spied component
; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + wrapper = mount( ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy', + FilteredComponent => () => ( +
+ +
+ ), + ); + jest.runAllTimers(); + + removeFilter( + hookName, + 'test/enhanced-component-spy', + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( wrapper.html() ).toBe( '
Spied component
' ); + } ); + + it( 'should re-render both components once each when one filter added', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return
Spied component
; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + const CombinedComponents = () => ( +
+ + +
+ ); + wrapper = mount( ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy-1', + FilteredComponent => () => ( +
+ +
+ ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( wrapper.html() ).toBe( '
Spied component
Spied component
' ); + } ); } );