Skip to content

Commit

Permalink
Components: Optimize withFilters, avoiding per-instance bindings (#13231
Browse files Browse the repository at this point in the history
)

* Components: Use single hook delegator for withFilters

* Component: Share component definition for withFilters instances

* Components: Use slash for withFilters subgrouping

* Components: Assign FilteredComponent only once renderer constructed

* Component: Handle withFilters forced update through delegator

Ensures that FilteredComponent definition is only computed once per animation frame debounce
  • Loading branch information
aduth authored and youknowriad committed Mar 6, 2019
1 parent 7fdfd3b commit f0fc3a2
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 32 deletions.
7 changes: 7 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 7.1.0 (Unreleased)

### Improvements

- `withFilters` has been optimized to avoid binding hook handlers for each mounted instance of the component, instead using a single centralized hook delegator.
- `withFilters` has been optimized to reuse a single shared component definition for all filtered instances of the component.

## 7.0.5 (2019-01-03)

## 7.0.4 (2018-12-12)
Expand Down
113 changes: 81 additions & 32 deletions packages/components/src/higher-order/with-filters/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { debounce, uniqueId } from 'lodash';
import { debounce, without } from 'lodash';

/**
* WordPress dependencies
Expand All @@ -24,45 +24,94 @@ const ANIMATION_FRAME_PERIOD = 16;
*/
export default function withFilters( hookName ) {
return createHigherOrderComponent( ( OriginalComponent ) => {
return 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 );
const namespace = 'core/with-filters/' + hookName;

/**
* The component definition with current filters applied. Each instance
* reuse this shared reference as an optimization to avoid excessive
* calls to `applyFilters` when many instances exist.
*
* @type {?Component}
*/
let FilteredComponent;

/**
* Initializes the FilteredComponent variable once, if not already
* assigned. Subsequent calls are effectively a noop.
*/
function ensureFilteredComponent() {
if ( FilteredComponent === undefined ) {
FilteredComponent = applyFilters( hookName, OriginalComponent );
}
}

/** @inheritdoc */
componentWillUnmount() {
this.throttledForceUpdate.cancel();
removeAction( 'hookRemoved', this.namespace );
removeAction( 'hookAdded', this.namespace );
class FilteredComponentRenderer extends Component {
constructor() {
super( ...arguments );

ensureFilteredComponent();
}

componentDidMount() {
FilteredComponentRenderer.instances.push( this );

// If there were previously no mounted instances for components
// filtered on this hook, add the hook handler.
if ( FilteredComponentRenderer.instances.length === 1 ) {
addAction( 'hookRemoved', namespace, onHooksUpdated );
addAction( 'hookAdded', namespace, onHooksUpdated );
}
}

/**
* 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();
componentWillUnmount() {
FilteredComponentRenderer.instances = without(
FilteredComponentRenderer.instances,
this
);

// If this was the last of the mounted components filtered on
// this hook, remove the hook handler.
if ( FilteredComponentRenderer.instances.length === 0 ) {
removeAction( 'hookRemoved', namespace );
removeAction( 'hookAdded', namespace );
}
}

/** @inheritdoc */
render() {
return <this.Component { ...this.props } />;
return <FilteredComponent { ...this.props } />;
}
}

FilteredComponentRenderer.instances = [];

/**
* Updates the FilteredComponent definition, forcing a render for each
* mounted instance. This occurs a maximum of once per animation frame.
*/
const throttledForceUpdate = debounce( () => {
// Recreate the filtered component, only after delay so that it's
// computed once, even if many filters added.
FilteredComponent = applyFilters( hookName, OriginalComponent );

// Force each instance to render.
FilteredComponentRenderer.instances.forEach( ( instance ) => {
instance.forceUpdate();
} );
}, ANIMATION_FRAME_PERIOD );

/**
* When a filter is added or removed for the matching hook name, each
* mounted instance should re-render with the new filters having been
* applied to the original component.
*
* @param {string} updatedHookName Name of the hook that was updated.
*/
function onHooksUpdated( updatedHookName ) {
if ( updatedHookName === hookName ) {
throttledForceUpdate();
}
};
}

return FilteredComponentRenderer;
}, 'withFilters' );
}

0 comments on commit f0fc3a2

Please sign in to comment.