diff --git a/babel.config.js b/babel.config.js index 6a903eff6c1d94..b4e3b3777debb4 100644 --- a/babel.config.js +++ b/babel.config.js @@ -4,6 +4,7 @@ module.exports = function( api ) { return { presets: [ '@wordpress/babel-preset-default' ], plugins: [ + '@wordpress/babel-plugin-transform-with-select', [ '@wordpress/babel-plugin-import-jsx-pragma', { diff --git a/package-lock.json b/package-lock.json index 52818081f01b44..ecf41ead2d3e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2319,6 +2319,13 @@ "lodash": "^4.17.10" } }, + "@wordpress/babel-plugin-transform-with-select": { + "version": "file:packages/babel-plugin-transform-with-select", + "dev": true, + "requires": { + "@babel/runtime": "^7.0.0" + } + }, "@wordpress/babel-preset-default": { "version": "file:packages/babel-preset-default", "dev": true, diff --git a/package.json b/package.json index 95ce65ad2682f3..25fcf72b934206 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@babel/traverse": "7.0.0", "@wordpress/babel-plugin-import-jsx-pragma": "file:packages/babel-plugin-import-jsx-pragma", "@wordpress/babel-plugin-makepot": "file:packages/babel-plugin-makepot", + "@wordpress/babel-plugin-transform-with-select": "file:packages/babel-plugin-transform-with-select", "@wordpress/babel-preset-default": "file:packages/babel-preset-default", "@wordpress/browserslist-config": "file:packages/browserslist-config", "@wordpress/custom-templated-path-webpack-plugin": "file:packages/custom-templated-path-webpack-plugin", diff --git a/packages/babel-plugin-transform-with-select/package.json b/packages/babel-plugin-transform-with-select/package.json new file mode 100644 index 00000000000000..cb099c11cf06df --- /dev/null +++ b/packages/babel-plugin-transform-with-select/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/babel-plugin-transform-with-select", + "version": "1.0.0", + "description": "WordPress Babel transform to provide withSelect reducerKeys hint.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "babel", + "plugin" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-plugin-transform-with-select/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "files": [ + "build", + "build-module" + ], + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@babel/runtime": "^7.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/babel-plugin-transform-with-select/src/index.js b/packages/babel-plugin-transform-with-select/src/index.js new file mode 100644 index 00000000000000..b4d8ebc69e54e6 --- /dev/null +++ b/packages/babel-plugin-transform-with-select/src/index.js @@ -0,0 +1,56 @@ +export default function( babel ) { + const { types: t } = babel; + + function addNamespaceToWithSelect( path, namespace ) { + let parentPath = path; + while ( ( parentPath = parentPath.parentPath ) ) { + const { node } = parentPath; + if ( node.type !== 'CallExpression' || node.callee.name !== 'withSelect' ) { + continue; + } + + let reducerKeys; + if ( node.arguments.length > 1 ) { + const reducerKeysNode = node.arguments[ 1 ]; + switch ( reducerKeysNode.type ) { + case 'ArrayExpression': + reducerKeys = reducerKeysNode.elements.reduce( ( result, element ) => { + if ( element.type === 'StringLiteral' ) { + result.push( element.value ); + } + + return result; + }, [] ); + break; + case 'StringLiteral': + reducerKeys = [ reducerKeysNode.value ]; + break; + } + + if ( reducerKeys.includes( namespace ) ) { + break; + } + + reducerKeys.push( namespace ); + reducerKeys = reducerKeys.map( ( key ) => t.stringLiteral( key ) ); + const argumentPaths = parentPath.get( 'arguments' ); + argumentPaths[ 1 ].replaceWith( t.arrayExpression( reducerKeys ) ); + } else { + parentPath.pushContainer( 'arguments', t.stringLiteral( namespace ) ); + } + + break; + } + } + + return { + visitor: { + CallExpression( path ) { + const { node } = path; + if ( node.callee.name === 'select' && node.arguments.length > 0 && node.arguments[ 0 ].type === 'StringLiteral' ) { + addNamespaceToWithSelect( path, node.arguments[ 0 ].value ); + } + }, + }, + }; +} diff --git a/packages/data/src/components/with-select/index.js b/packages/data/src/components/with-select/index.js index d1219479d3e462..b2a9b22e2939ad 100644 --- a/packages/data/src/components/with-select/index.js +++ b/packages/data/src/components/with-select/index.js @@ -14,13 +14,18 @@ import { RegistryConsumer } from '../registry-provider'; * Higher-order component used to inject state-derived props using registered * selectors. * - * @param {Function} mapSelectToProps Function called on every state change, - * expected to return object of props to - * merge with the component's own props. + * @param {Function} mapSelectToProps Function called on every + * state change, expected to + * return object of props to + * merge with the component's + * own props. + * @param {?(string|Array)} reducerKeys Optional subset of reducer + * keys on which subscribe + * callback should be called. * * @return {Component} Enhanced component with merged state data props. */ -const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { +const withSelect = ( mapSelectToProps, reducerKeys ) => createHigherOrderComponent( ( WrappedComponent ) => { /** * Default merge props. A constant value is used as the fallback since it * can be more efficiently shallow compared in case component is repeatedly @@ -137,7 +142,7 @@ const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( Wrapped } subscribe( registry ) { - this.unsubscribe = registry.subscribe( this.onStoreChange ); + this.unsubscribe = registry.subscribe( this.onStoreChange, reducerKeys ); } render() { diff --git a/packages/data/src/namespace-store.js b/packages/data/src/namespace-store.js index dc0c0075fd6201..1f2dceb74201d5 100644 --- a/packages/data/src/namespace-store.js +++ b/packages/data/src/namespace-store.js @@ -54,7 +54,7 @@ export default function createNamespace( key, options, registry ) { lastState = state; if ( hasChanged ) { - listener(); + listener( key ); } } ); }; diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index acca73b93129a3..351fe72bfcc12c 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -2,6 +2,7 @@ * External dependencies */ import { + castArray, without, mapValues, } from 'lodash'; @@ -12,6 +13,16 @@ import { import createNamespace from './namespace-store.js'; import dataStore from './store'; +/** + * Given an array of functions, invokes each function in the array with an + * empty argument set. + * + * @param {Function[]} fns Functions to invoke. + */ +function invokeForEach( fns ) { + fns.forEach( ( fn ) => fn() ); +} + /** * An isolated orchestrator of store registrations. * @@ -40,27 +51,72 @@ import dataStore from './store'; */ export function createRegistry( storeConfigs = {} ) { const stores = {}; - let listeners = []; + let globalListeners = []; + const listenersByKey = {}; /** * Global listener called for each store's update. + * + * @param {?string} reducerKey Key of reducer which changed, if provided by + * the registry implementation. */ - function globalListener() { - listeners.forEach( ( listener ) => listener() ); + function onStoreChange( reducerKey ) { + invokeForEach( globalListeners ); + + if ( reducerKey ) { + if ( listenersByKey[ reducerKey ] ) { + invokeForEach( listenersByKey[ reducerKey ] ); + } + } else { + // For backwards compatibility with non-namespace-store, reducerKey + // is optional. If omitted, call every listenersByKey. + for ( const [ , listeners ] of Object.entries( listenersByKey ) ) { + invokeForEach( listeners ); + } + } } /** * Subscribe to changes to any data. * - * @param {Function} listener Listener function. + * @param {Function} listener Listener function. + * @param {?(string|Array)} reducerKeys Optional subset of reducer + * keys on which subscribe + * function should be called. * - * @return {Function} Unsubscribe function. + * @return {Function} Unsubscribe function. */ - const subscribe = ( listener ) => { - listeners.push( listener ); + const subscribe = ( listener, reducerKeys ) => { + if ( reducerKeys ) { + // Overload to support string argument of `reducerKeys`. + reducerKeys = castArray( reducerKeys ); + + reducerKeys.forEach( ( reducerKey ) => { + if ( ! listenersByKey[ reducerKey ] ) { + listenersByKey[ reducerKey ] = []; + } + + listenersByKey[ reducerKey ].push( listener ); + } ); + + return () => { + reducerKeys.forEach( ( reducerKey ) => { + listenersByKey[ reducerKey ] = without( + listenersByKey[ reducerKey ], + listener + ); + + if ( ! listenersByKey[ reducerKey ].length ) { + delete listenersByKey[ reducerKey ]; + } + } ); + }; + } + + globalListeners.push( listener ); return () => { - listeners = without( listeners, listener ); + globalListeners = without( globalListeners, listener ); }; }; @@ -122,7 +178,7 @@ export function createRegistry( storeConfigs = {} ) { throw new TypeError( 'config.subscribe must be a function' ); } stores[ key ] = config; - config.subscribe( globalListener ); + config.subscribe( onStoreChange ); } let registry = {